simplicio-fast 2.0.17__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 (59) hide show
  1. simplicio_fast/__init__.py +59 -0
  2. simplicio_fast/adapters.py +159 -0
  3. simplicio_fast/catalog.py +353 -0
  4. simplicio_fast/cli.py +851 -0
  5. simplicio_fast/content_cache.py +24 -0
  6. simplicio_fast/context_view.py +822 -0
  7. simplicio_fast/custodians.py +211 -0
  8. simplicio_fast/delivery.py +303 -0
  9. simplicio_fast/engine.py +223 -0
  10. simplicio_fast/engine_selection.py +106 -0
  11. simplicio_fast/fwht.py +61 -0
  12. simplicio_fast/fwht_turboquant.py +130 -0
  13. simplicio_fast/generation_receipts.py +124 -0
  14. simplicio_fast/generation_store.py +45 -0
  15. simplicio_fast/hbp_codec.py +49 -0
  16. simplicio_fast/hybrid_index.py +21 -0
  17. simplicio_fast/installation.py +254 -0
  18. simplicio_fast/integrations.py +257 -0
  19. simplicio_fast/ipc.py +181 -0
  20. simplicio_fast/journal.py +192 -0
  21. simplicio_fast/knowledge.py +168 -0
  22. simplicio_fast/ledger.py +260 -0
  23. simplicio_fast/ledger_store.py +292 -0
  24. simplicio_fast/litert_embeddings.py +148 -0
  25. simplicio_fast/native_backend.py +218 -0
  26. simplicio_fast/navigation.py +385 -0
  27. simplicio_fast/pager.py +389 -0
  28. simplicio_fast/prism_arena.py +990 -0
  29. simplicio_fast/prism_context_views.py +300 -0
  30. simplicio_fast/processor.py +511 -0
  31. simplicio_fast/quant_benchmark.py +1324 -0
  32. simplicio_fast/query_planner.py +234 -0
  33. simplicio_fast/release_policy.json +16 -0
  34. simplicio_fast/resident_daemon.py +273 -0
  35. simplicio_fast/rollout.py +64 -0
  36. simplicio_fast/runtime_backend.py +540 -0
  37. simplicio_fast/runtime_bridge.py +134 -0
  38. simplicio_fast/segments.py +317 -0
  39. simplicio_fast/semantic_pager.py +35 -0
  40. simplicio_fast/semantic_scoring.py +908 -0
  41. simplicio_fast/skills.py +200 -0
  42. simplicio_fast/slot_executor.py +307 -0
  43. simplicio_fast/snapshot.py +1243 -0
  44. simplicio_fast/streaming.py +261 -0
  45. simplicio_fast/temporal.py +465 -0
  46. simplicio_fast/turboquant.py +260 -0
  47. simplicio_fast/users/__init__.py +1 -0
  48. simplicio_fast/users/http.py +83 -0
  49. simplicio_fast/users/model.py +33 -0
  50. simplicio_fast/users/repository.py +26 -0
  51. simplicio_fast/users/service.py +67 -0
  52. simplicio_fast/vector_contracts.py +194 -0
  53. simplicio_fast/vector_index.py +178 -0
  54. simplicio_fast/workspace.py +384 -0
  55. simplicio_fast-2.0.17.dist-info/METADATA +546 -0
  56. simplicio_fast-2.0.17.dist-info/RECORD +59 -0
  57. simplicio_fast-2.0.17.dist-info/WHEEL +5 -0
  58. simplicio_fast-2.0.17.dist-info/entry_points.txt +2 -0
  59. simplicio_fast-2.0.17.dist-info/top_level.txt +1 -0
@@ -0,0 +1,59 @@
1
+ """Lightweight public facade for Simplicio Fast.
2
+
3
+ Importing the package exposes its version without eagerly loading mmap,
4
+ Runtime subprocess, workspace, navigation, or cache implementations. Public
5
+ symbols remain source-compatible and are loaded on first access.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib import import_module
11
+
12
+
13
+ __version__ = "2.0.17"
14
+
15
+ _EXPORTS = {
16
+ "ArenaError": (".prism_arena", "ArenaError"),
17
+ "EngineSelection": (".engine_selection", "EngineSelection"),
18
+ "EngineSelectionError": (".engine_selection", "EngineSelectionError"),
19
+ "GenerationId": (".workspace", "GenerationId"),
20
+ "KnowledgeFacade": (".knowledge", "KnowledgeFacade"),
21
+ "Manifest": (".workspace", "Manifest"),
22
+ "NavigationBudget": (".navigation", "NavigationBudget"),
23
+ "NavigationError": (".navigation", "NavigationError"),
24
+ "NavigationIndex": (".navigation", "NavigationIndex"),
25
+ "NavigationItem": (".navigation", "NavigationItem"),
26
+ "NavigationPage": (".navigation", "NavigationPage"),
27
+ "PrismArena": (".prism_arena", "PrismArena"),
28
+ "PrismWorkDelta": (".prism_arena", "PrismWorkDelta"),
29
+ "RequestKey": (".pager", "RequestKey"),
30
+ "RuntimeArtifact": (".runtime_backend", "RuntimeArtifact"),
31
+ "RuntimeBackendError": (".runtime_backend", "RuntimeBackendError"),
32
+ "RuntimeFastBackend": (".runtime_backend", "RuntimeFastBackend"),
33
+ "RuntimeSelection": (".runtime_backend", "RuntimeSelection"),
34
+ "SingleFlightCoordinator": (".pager", "SingleFlightCoordinator"),
35
+ "SingleFlightError": (".pager", "SingleFlightError"),
36
+ "SlotView": (".prism_arena", "SlotView"),
37
+ "TaskOverlay": (".prism_arena", "TaskOverlay"),
38
+ "WorkspaceStore": (".workspace", "WorkspaceStore"),
39
+ "make_request_key": (".pager", "make_request_key"),
40
+ "navigate": (".navigation", "navigate"),
41
+ "select_engine": (".engine_selection", "select_engine"),
42
+ "select_runtime_backend": (".runtime_backend", "select_runtime_backend"),
43
+ }
44
+
45
+ __all__ = [*_EXPORTS, "__version__"]
46
+
47
+
48
+ def __getattr__(name: str):
49
+ target = _EXPORTS.get(name)
50
+ if target is None:
51
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
52
+ module_name, attribute = target
53
+ value = getattr(import_module(module_name, __name__), attribute)
54
+ globals()[name] = value
55
+ return value
56
+
57
+
58
+ def __dir__() -> list[str]:
59
+ return sorted({*globals(), *__all__})
@@ -0,0 +1,159 @@
1
+ """Language capability negotiation and conservative semantic adapters.
2
+
3
+ The adapters intentionally return the public :class:`Symbol` contract only. They
4
+ do not expose binary offsets, so Mapper remains the owner of public ContextGraph
5
+ handles while Fast owns extraction and persistence.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import re
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from .snapshot import Symbol
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class AdapterCapability:
20
+ language: str
21
+ status: str
22
+ parser: str
23
+ reason: str | None = None
24
+ fallback: str | None = None
25
+
26
+
27
+ SUPPORTED_EXTENSIONS = {
28
+ ".py": "python",
29
+ ".pyi": "python",
30
+ ".ts": "typescript",
31
+ ".tsx": "typescript",
32
+ ".js": "typescript",
33
+ ".jsx": "typescript",
34
+ ".rs": "rust",
35
+ ".cs": "csharp",
36
+ }
37
+
38
+
39
+ def negotiate(language: str) -> AdapterCapability:
40
+ normalized = language.casefold().replace("c#", "csharp").replace("ts", "typescript")
41
+ if normalized == "python":
42
+ return AdapterCapability("python", "available", "python-ast")
43
+ if normalized in {"typescript", "rust", "csharp"}:
44
+ # Tree-sitter/compiler bindings are optional. The deterministic lexical
45
+ # adapter is explicit so callers can distinguish it from native parsing.
46
+ return AdapterCapability(
47
+ normalized,
48
+ "fallback",
49
+ "lexical",
50
+ reason="native parser binding unavailable",
51
+ fallback="bounded lexical extraction; verify with native toolchain",
52
+ )
53
+ return AdapterCapability(
54
+ normalized,
55
+ "unavailable",
56
+ "none",
57
+ reason=f"no adapter registered for {language}",
58
+ fallback="preserve source and request a Mapper-native capability",
59
+ )
60
+
61
+
62
+ def capability_report() -> list[AdapterCapability]:
63
+ return [negotiate(language) for language in ("python", "typescript", "rust", "csharp")]
64
+
65
+
66
+ def language_for_path(path: Path) -> str | None:
67
+ return SUPPORTED_EXTENSIONS.get(path.suffix.casefold())
68
+
69
+
70
+ def parse_path(path: Path, relative_path: str | None = None) -> list[Symbol]:
71
+ relative = relative_path or path.as_posix()
72
+ language = language_for_path(path)
73
+ if language is None:
74
+ return []
75
+ if language == "python":
76
+ return _parse_python(path, relative)
77
+ return _parse_lexical(path, relative, language)
78
+
79
+
80
+ def _parse_python(path: Path, relative: str) -> list[Symbol]:
81
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=relative)
82
+ result: list[Symbol] = []
83
+ scopes: list[str] = []
84
+
85
+ def visit(node: ast.AST) -> None:
86
+ if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
87
+ kind = (
88
+ "class"
89
+ if isinstance(node, ast.ClassDef)
90
+ else "async_function"
91
+ if isinstance(node, ast.AsyncFunctionDef)
92
+ else "function"
93
+ )
94
+ qualified = ".".join([*scopes, node.name])
95
+ result.append(
96
+ Symbol(
97
+ node.name,
98
+ qualified,
99
+ kind,
100
+ relative,
101
+ node.lineno,
102
+ getattr(node, "end_lineno", None) or node.lineno,
103
+ )
104
+ )
105
+ scopes.append(node.name)
106
+ for child in ast.iter_child_nodes(node):
107
+ visit(child)
108
+ scopes.pop()
109
+ return
110
+ for child in ast.iter_child_nodes(node):
111
+ visit(child)
112
+
113
+ visit(tree)
114
+ return result
115
+
116
+ def _parse_lexical(path: Path, relative: str, language: str) -> list[Symbol]:
117
+ text = path.read_text(encoding="utf-8")
118
+ lines = text.splitlines()
119
+ patterns: list[tuple[str, str, re.Pattern[str]]] = []
120
+ if language == "typescript":
121
+ patterns = [
122
+ ("import", "import", re.compile(r"^\s*import\s+(?:type\s+)?(?:.+?from\s+)?[\"']([^\"']+)[\"']")),
123
+ ("namespace", "namespace", re.compile(r"^\s*(?:export\s+)?(?:declare\s+)?namespace\s+(\w+)")),
124
+ ("interface", "interface", re.compile(r"^\s*(?:export\s+)?interface\s+(\w+)")),
125
+ ("class", "class", re.compile(r"^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)")),
126
+ ("function", "function", re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)")),
127
+ ("function", "function", re.compile(r"^\s*(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(")),
128
+ ]
129
+ elif language == "rust":
130
+ patterns = [
131
+ ("use", "import", re.compile(r"^\s*(?:pub\s+)?use\s+([^;]+)")),
132
+ ("mod", "namespace", re.compile(r"^\s*(?:pub\s+)?mod\s+(\w+)")),
133
+ ("struct", "struct", re.compile(r"^\s*(?:pub\s+)?struct\s+(\w+)")),
134
+ ("trait", "trait", re.compile(r"^\s*(?:pub\s+)?trait\s+(\w+)")),
135
+ ("enum", "enum", re.compile(r"^\s*(?:pub\s+)?enum\s+(\w+)")),
136
+ ("function", "function", re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)")),
137
+ ]
138
+ else:
139
+ patterns = [
140
+ ("using", "import", re.compile(r"^\s*using\s+(?:static\s+)?([^;=]+)")),
141
+ ("namespace", "namespace", re.compile(r"^\s*namespace\s+([\w.]+)")),
142
+ ("interface", "interface", re.compile(r"^\s*(?:public\s+)?interface\s+(\w+)")),
143
+ ("class", "class", re.compile(r"^\s*(?:public\s+|internal\s+|private\s+)?(?:abstract\s+)?class\s+(\w+)")),
144
+ ("struct", "struct", re.compile(r"^\s*(?:public\s+)?struct\s+(\w+)")),
145
+ ("function", "function", re.compile(r"^\s*(?:public\s+|private\s+|internal\s+|protected\s+)?(?:static\s+)?[\w<>?\[\]]+\s+(\w+)\s*\([^;]*\)")),
146
+ ]
147
+ result: list[Symbol] = []
148
+ for index, line in enumerate(lines, 1):
149
+ for _, kind, pattern in patterns:
150
+ match = pattern.search(line)
151
+ if not match:
152
+ continue
153
+ name = match.group(1).strip()
154
+ if kind == "import":
155
+ name = name.replace(" ", "")
156
+ qualified = name
157
+ result.append(Symbol(name, qualified, kind, relative, index, index))
158
+ break
159
+ return result
@@ -0,0 +1,353 @@
1
+ """Verified semantic handles for the Python catalog reference implementation.
2
+
3
+ The catalog keeps the full Mapper SHA-256 as identity and derives a short alias
4
+ only inside the repository/generation scope. Its on-disk representation is a
5
+ bounded binary record stream; JSON is reserved for CLI receipts and projections.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import os
12
+ import struct
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Iterable
16
+
17
+
18
+ SCHEMA = "simplicio.fast.address-catalog/v1"
19
+ MAGIC = b"SFACAT01"
20
+ MAX_CATALOG_BYTES = 64 * 1024 * 1024
21
+ MAX_RECORDS = 1_000_000
22
+ STATES = {"active", "superseded", "tombstoned", "held"}
23
+ NAMESPACES = {"file", "symbol", "relation", "span", "test", "plan", "precedent", "receipt", "skill"}
24
+ _SHA256 = __import__("re").compile(r"^[0-9a-f]{64}$")
25
+
26
+
27
+ class CatalogResolutionError(ValueError):
28
+ """A handle could not be resolved without violating a catalog guard."""
29
+
30
+ def __init__(self, reason_code: str, message: str) -> None:
31
+ super().__init__(message)
32
+ self.reason_code = reason_code
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class CatalogEntry:
37
+ namespace: str
38
+ canonical_id: str
39
+ handle: str
40
+ repository: str
41
+ generation: str
42
+ segment_id: str
43
+ payload: bytes
44
+ payload_sha256: str
45
+ source_sha256: str
46
+ state: str
47
+
48
+ def record(self, *, include_payload: bool = True) -> dict[str, object]:
49
+ result: dict[str, object] = {
50
+ "namespace": self.namespace,
51
+ "canonical_id": self.canonical_id,
52
+ "handle": self.handle,
53
+ "repository": self.repository,
54
+ "generation": self.generation,
55
+ "segment_id": self.segment_id,
56
+ "payload_length": len(self.payload),
57
+ "payload_sha256": self.payload_sha256,
58
+ "source_sha256": self.source_sha256,
59
+ "state": self.state,
60
+ }
61
+ if include_payload:
62
+ result["payload"] = self.payload
63
+ return result
64
+
65
+
66
+ class AddressCatalog:
67
+ """In-memory catalog with a compact binary persistence boundary."""
68
+
69
+ def __init__(self, repository: str | Path, generation: str) -> None:
70
+ self.repository = str(Path(repository).resolve())
71
+ if not generation:
72
+ raise ValueError("generation must not be empty")
73
+ self.generation = generation
74
+ self._by_handle: dict[str, CatalogEntry] = {}
75
+ self._by_identity: dict[tuple[str, str], CatalogEntry] = {}
76
+ self._collisions = 0
77
+
78
+ @staticmethod
79
+ def _validate_sha(value: str, field: str) -> None:
80
+ if not isinstance(value, str) or _SHA256.fullmatch(value) is None:
81
+ raise ValueError(f"{field} must be a lowercase SHA-256 digest")
82
+
83
+ def _make_handle(self, namespace: str, canonical_id: str) -> str:
84
+ material = "|".join((SCHEMA, self.repository, self.generation, namespace, canonical_id))
85
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()[:20]
86
+
87
+ def register(
88
+ self,
89
+ namespace: str,
90
+ canonical_id: str,
91
+ payload: bytes,
92
+ *,
93
+ source_sha256: str,
94
+ segment_id: str = "default",
95
+ state: str = "active",
96
+ ) -> CatalogEntry:
97
+ if namespace not in NAMESPACES:
98
+ raise ValueError(f"unsupported catalog namespace: {namespace}")
99
+ self._validate_sha(canonical_id, "canonical_id")
100
+ self._validate_sha(source_sha256, "source_sha256")
101
+ if not isinstance(payload, bytes):
102
+ raise TypeError("payload must be bytes")
103
+ if not segment_id:
104
+ raise ValueError("segment_id must not be empty")
105
+ if state not in STATES:
106
+ raise ValueError(f"unsupported catalog state: {state}")
107
+ identity = (namespace, canonical_id)
108
+ payload_sha256 = hashlib.sha256(payload).hexdigest()
109
+ existing = self._by_identity.get(identity)
110
+ if existing is not None:
111
+ if existing.payload_sha256 != payload_sha256 or existing.source_sha256 != source_sha256:
112
+ raise CatalogResolutionError(
113
+ "canonical_id_reuse",
114
+ "canonical Mapper ID cannot silently point at a new payload",
115
+ )
116
+ return existing
117
+ handle = self._make_handle(namespace, canonical_id)
118
+ occupant = self._by_handle.get(handle)
119
+ if occupant is not None and occupant.canonical_id != canonical_id:
120
+ self._collisions += 1
121
+ raise CatalogResolutionError("handle_collision", f"handle collision for {handle}")
122
+ entry = CatalogEntry(
123
+ namespace=namespace,
124
+ canonical_id=canonical_id,
125
+ handle=handle,
126
+ repository=self.repository,
127
+ generation=self.generation,
128
+ segment_id=segment_id,
129
+ payload=payload,
130
+ payload_sha256=payload_sha256,
131
+ source_sha256=source_sha256,
132
+ state=state,
133
+ )
134
+ self._by_handle[handle] = entry
135
+ self._by_identity[identity] = entry
136
+ return entry
137
+
138
+ def resolve(
139
+ self,
140
+ handle: str,
141
+ *,
142
+ repository: str | Path | None = None,
143
+ generation: str | None = None,
144
+ namespace: str | None = None,
145
+ payload_sha256: str | None = None,
146
+ ) -> CatalogEntry:
147
+ entry = self._by_handle.get(handle)
148
+ if entry is None:
149
+ raise CatalogResolutionError("handle_not_found", f"unknown catalog handle: {handle}")
150
+ if repository is not None and str(Path(repository).resolve()) != entry.repository:
151
+ raise CatalogResolutionError("cross_repo_handle", "handle belongs to another repository")
152
+ if generation is not None and generation != entry.generation:
153
+ raise CatalogResolutionError("stale_generation", "handle belongs to another generation")
154
+ if namespace is not None and namespace != entry.namespace:
155
+ raise CatalogResolutionError("namespace_mismatch", "handle namespace does not match")
156
+ if entry.state != "active":
157
+ raise CatalogResolutionError(entry.state, f"handle is not active: {entry.state}")
158
+ if payload_sha256 is not None and payload_sha256 != entry.payload_sha256:
159
+ raise CatalogResolutionError("payload_digest_mismatch", "payload digest does not match")
160
+ if hashlib.sha256(entry.payload).hexdigest() != entry.payload_sha256:
161
+ raise CatalogResolutionError("payload_corrupt", "catalog payload digest is invalid")
162
+ return entry
163
+
164
+ def resolve_many(self, handles: Iterable[str], **guards: object) -> list[CatalogEntry]:
165
+ return [self.resolve(handle, **guards) for handle in handles]
166
+
167
+ def resolve_many_bounded(
168
+ self,
169
+ handles: Iterable[str],
170
+ *,
171
+ max_entries: int = 256,
172
+ max_bytes: int = 1 * 1024 * 1024,
173
+ **guards: object,
174
+ ) -> dict[str, object]:
175
+ """Resolve verified handles without exceeding materialization budgets."""
176
+ if max_entries < 1 or max_bytes < 1:
177
+ raise ValueError("max_entries and max_bytes must be positive")
178
+ references: list[dict[str, object]] = []
179
+ materialized: list[dict[str, object]] = []
180
+ bytes_materialized = 0
181
+ truncated = False
182
+ for handle in handles:
183
+ if len(references) >= max_entries:
184
+ truncated = True
185
+ break
186
+ entry = self.resolve(handle, **guards)
187
+ if bytes_materialized + len(entry.payload) > max_bytes:
188
+ truncated = True
189
+ break
190
+ references.append({
191
+ "handle": entry.handle,
192
+ "namespace": entry.namespace,
193
+ "canonical_id": entry.canonical_id,
194
+ "generation": entry.generation,
195
+ "payload_length": len(entry.payload),
196
+ "payload_sha256": entry.payload_sha256,
197
+ })
198
+ materialized.append({"handle": entry.handle, "payload": entry.payload})
199
+ bytes_materialized += len(entry.payload)
200
+ return {
201
+ "schema": "simplicio.fast.address-resolution/v1",
202
+ "repository": self.repository,
203
+ "generation": self.generation,
204
+ "references": references,
205
+ "materialized": materialized,
206
+ "entries_materialized": len(materialized),
207
+ "bytes_materialized": bytes_materialized,
208
+ "truncated": truncated,
209
+ "reason_code": "resolution_bounded" if truncated else "resolution_complete",
210
+ }
211
+
212
+ def tombstone(self, handle: str, *, state: str = "tombstoned") -> CatalogEntry:
213
+ if state not in {"superseded", "tombstoned", "held"}:
214
+ raise ValueError("tombstone state must be superseded, tombstoned or held")
215
+ entry = self._by_handle.get(handle)
216
+ if entry is None:
217
+ raise CatalogResolutionError("handle_not_found", f"unknown catalog handle: {handle}")
218
+ replacement = CatalogEntry(
219
+ namespace=entry.namespace,
220
+ canonical_id=entry.canonical_id,
221
+ handle=entry.handle,
222
+ repository=entry.repository,
223
+ generation=entry.generation,
224
+ segment_id=entry.segment_id,
225
+ payload=entry.payload,
226
+ payload_sha256=entry.payload_sha256,
227
+ source_sha256=entry.source_sha256,
228
+ state=state,
229
+ )
230
+ self._by_handle[handle] = replacement
231
+ self._by_identity[(entry.namespace, entry.canonical_id)] = replacement
232
+ return replacement
233
+
234
+ def stat(self) -> dict[str, object]:
235
+ by_state = {state: 0 for state in STATES}
236
+ by_namespace = {namespace: 0 for namespace in NAMESPACES}
237
+ for entry in self._by_handle.values():
238
+ by_state[entry.state] += 1
239
+ by_namespace[entry.namespace] += 1
240
+ return {
241
+ "schema": SCHEMA,
242
+ "repository": self.repository,
243
+ "generation": self.generation,
244
+ "entries": len(self._by_handle),
245
+ "handles": len(self._by_handle),
246
+ "collisions": self._collisions,
247
+ "by_state": by_state,
248
+ "by_namespace": by_namespace,
249
+ }
250
+
251
+ def verify(self) -> dict[str, object]:
252
+ invalid: list[str] = []
253
+ for entry in self._by_handle.values():
254
+ if entry.repository != self.repository or entry.generation != self.generation:
255
+ invalid.append(entry.handle)
256
+ continue
257
+ if hashlib.sha256(entry.payload).hexdigest() != entry.payload_sha256:
258
+ invalid.append(entry.handle)
259
+ return {**self.stat(), "status": "valid" if not invalid else "invalid", "invalid_handles": invalid}
260
+
261
+ def to_bytes(self) -> bytes:
262
+ repository = self.repository.encode("utf-8")
263
+ generation = self.generation.encode("utf-8")
264
+ if len(repository) > 65535 or len(generation) > 65535:
265
+ raise ValueError("catalog metadata is too long")
266
+ output = bytearray(MAGIC)
267
+ output.extend(struct.pack(">HHI", len(repository), len(generation), len(self._by_handle)))
268
+ output.extend(repository)
269
+ output.extend(generation)
270
+ for entry in sorted(self._by_handle.values(), key=lambda item: item.handle):
271
+ fields = (
272
+ entry.namespace.encode("utf-8"),
273
+ entry.canonical_id.encode("ascii"),
274
+ entry.handle.encode("ascii"),
275
+ entry.segment_id.encode("utf-8"),
276
+ entry.payload_sha256.encode("ascii"),
277
+ entry.source_sha256.encode("ascii"),
278
+ entry.state.encode("ascii"),
279
+ entry.payload,
280
+ )
281
+ if any(len(field) > 65535 for field in fields[:-1]) or len(entry.payload) > 0xFFFFFFFF:
282
+ raise ValueError("catalog record is too large")
283
+ output.extend(struct.pack(">7H I", *(len(field) for field in fields[:-1]), len(entry.payload)))
284
+ for field in fields[:-1]:
285
+ output.extend(field)
286
+ output.extend(entry.payload)
287
+ return bytes(output)
288
+
289
+ @classmethod
290
+ def from_bytes(
291
+ cls,
292
+ data: bytes,
293
+ *,
294
+ repository: str | Path | None = None,
295
+ generation: str | None = None,
296
+ ) -> "AddressCatalog":
297
+ if len(data) > MAX_CATALOG_BYTES or len(data) < len(MAGIC) + 8:
298
+ raise ValueError("catalog size is outside supported bounds")
299
+ cursor = 0
300
+
301
+ def take(length: int) -> bytes:
302
+ nonlocal cursor
303
+ if length < 0 or cursor + length > len(data):
304
+ raise ValueError("truncated catalog record")
305
+ value = data[cursor : cursor + length]
306
+ cursor += length
307
+ return value
308
+
309
+ if take(len(MAGIC)) != MAGIC:
310
+ raise ValueError("invalid catalog magic")
311
+ repository_length, generation_length, count = struct.unpack(">HHI", take(8))
312
+ if count > MAX_RECORDS:
313
+ raise ValueError("catalog record count exceeds limit")
314
+ stored_repository = take(repository_length).decode("utf-8")
315
+ stored_generation = take(generation_length).decode("utf-8")
316
+ catalog = cls(repository or stored_repository, generation or stored_generation)
317
+ if catalog.repository != str(Path(stored_repository).resolve()) or catalog.generation != stored_generation:
318
+ raise CatalogResolutionError("catalog_scope_mismatch", "catalog scope does not match requested scope")
319
+ for _ in range(count):
320
+ lengths = struct.unpack(">7H I", take(18))
321
+ namespace, canonical_id, handle, segment_id, payload_sha256, source_sha256, state = (
322
+ take(lengths[index]).decode("utf-8" if index in {0, 3} else "ascii")
323
+ for index in range(7)
324
+ )
325
+ payload = take(lengths[7])
326
+ expected_handle = catalog._make_handle(namespace, canonical_id)
327
+ if handle != expected_handle:
328
+ raise CatalogResolutionError("handle_digest_mismatch", f"invalid handle for {canonical_id}")
329
+ entry = catalog.register(
330
+ namespace,
331
+ canonical_id,
332
+ payload,
333
+ source_sha256=source_sha256,
334
+ segment_id=segment_id,
335
+ state=state,
336
+ )
337
+ if entry.handle != handle or entry.payload_sha256 != payload_sha256:
338
+ raise CatalogResolutionError("catalog_digest_mismatch", "catalog record digest mismatch")
339
+ if cursor != len(data):
340
+ raise ValueError("trailing bytes after catalog records")
341
+ return catalog
342
+
343
+ def save(self, path: str | Path) -> dict[str, object]:
344
+ target = Path(path)
345
+ target.parent.mkdir(parents=True, exist_ok=True)
346
+ temporary = target.with_name(f".{target.name}.tmp")
347
+ temporary.write_bytes(self.to_bytes())
348
+ os.replace(temporary, target)
349
+ return {**self.verify(), "path": str(target.resolve()), "bytes": target.stat().st_size}
350
+
351
+ @classmethod
352
+ def load(cls, path: str | Path, **scope: object) -> "AddressCatalog":
353
+ return cls.from_bytes(Path(path).read_bytes(), **scope)