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.
- simplicio_fast/__init__.py +59 -0
- simplicio_fast/adapters.py +159 -0
- simplicio_fast/catalog.py +353 -0
- simplicio_fast/cli.py +851 -0
- simplicio_fast/content_cache.py +24 -0
- simplicio_fast/context_view.py +822 -0
- simplicio_fast/custodians.py +211 -0
- simplicio_fast/delivery.py +303 -0
- simplicio_fast/engine.py +223 -0
- simplicio_fast/engine_selection.py +106 -0
- simplicio_fast/fwht.py +61 -0
- simplicio_fast/fwht_turboquant.py +130 -0
- simplicio_fast/generation_receipts.py +124 -0
- simplicio_fast/generation_store.py +45 -0
- simplicio_fast/hbp_codec.py +49 -0
- simplicio_fast/hybrid_index.py +21 -0
- simplicio_fast/installation.py +254 -0
- simplicio_fast/integrations.py +257 -0
- simplicio_fast/ipc.py +181 -0
- simplicio_fast/journal.py +192 -0
- simplicio_fast/knowledge.py +168 -0
- simplicio_fast/ledger.py +260 -0
- simplicio_fast/ledger_store.py +292 -0
- simplicio_fast/litert_embeddings.py +148 -0
- simplicio_fast/native_backend.py +218 -0
- simplicio_fast/navigation.py +385 -0
- simplicio_fast/pager.py +389 -0
- simplicio_fast/prism_arena.py +990 -0
- simplicio_fast/prism_context_views.py +300 -0
- simplicio_fast/processor.py +511 -0
- simplicio_fast/quant_benchmark.py +1324 -0
- simplicio_fast/query_planner.py +234 -0
- simplicio_fast/release_policy.json +16 -0
- simplicio_fast/resident_daemon.py +273 -0
- simplicio_fast/rollout.py +64 -0
- simplicio_fast/runtime_backend.py +540 -0
- simplicio_fast/runtime_bridge.py +134 -0
- simplicio_fast/segments.py +317 -0
- simplicio_fast/semantic_pager.py +35 -0
- simplicio_fast/semantic_scoring.py +908 -0
- simplicio_fast/skills.py +200 -0
- simplicio_fast/slot_executor.py +307 -0
- simplicio_fast/snapshot.py +1243 -0
- simplicio_fast/streaming.py +261 -0
- simplicio_fast/temporal.py +465 -0
- simplicio_fast/turboquant.py +260 -0
- simplicio_fast/users/__init__.py +1 -0
- simplicio_fast/users/http.py +83 -0
- simplicio_fast/users/model.py +33 -0
- simplicio_fast/users/repository.py +26 -0
- simplicio_fast/users/service.py +67 -0
- simplicio_fast/vector_contracts.py +194 -0
- simplicio_fast/vector_index.py +178 -0
- simplicio_fast/workspace.py +384 -0
- simplicio_fast-2.0.17.dist-info/METADATA +546 -0
- simplicio_fast-2.0.17.dist-info/RECORD +59 -0
- simplicio_fast-2.0.17.dist-info/WHEEL +5 -0
- simplicio_fast-2.0.17.dist-info/entry_points.txt +2 -0
- 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)
|