agos-context 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.
- agos_context/__init__.py +0 -0
- agos_context/compile.py +363 -0
- agos_context/graph.py +280 -0
- agos_context/predicate.py +74 -0
- agos_context/py.typed +0 -0
- agos_context/traversal.py +349 -0
- agos_context-0.1.0.dist-info/METADATA +148 -0
- agos_context-0.1.0.dist-info/RECORD +12 -0
- agos_context-0.1.0.dist-info/WHEEL +5 -0
- agos_context-0.1.0.dist-info/licenses/LICENSE +201 -0
- agos_context-0.1.0.dist-info/licenses/NOTICE +2 -0
- agos_context-0.1.0.dist-info/top_level.txt +1 -0
agos_context/__init__.py
ADDED
|
File without changes
|
agos_context/compile.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Deterministic closure, bounds, and identity for context slices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from pydantic import AwareDatetime, TypeAdapter
|
|
10
|
+
|
|
11
|
+
from agos_context.graph import (
|
|
12
|
+
Bounds,
|
|
13
|
+
Limits,
|
|
14
|
+
Link,
|
|
15
|
+
Node,
|
|
16
|
+
Omission,
|
|
17
|
+
Pin,
|
|
18
|
+
Policy,
|
|
19
|
+
Ref,
|
|
20
|
+
Slice,
|
|
21
|
+
canonical_json_bytes,
|
|
22
|
+
omission_key,
|
|
23
|
+
pin_key,
|
|
24
|
+
policy_key,
|
|
25
|
+
ref_key,
|
|
26
|
+
stable_hash,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_AWARE_DATETIME = TypeAdapter(AwareDatetime)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class SliceOrder:
|
|
34
|
+
"""Complete retention order supplied by the purpose compiler."""
|
|
35
|
+
|
|
36
|
+
nodes: tuple[Pin, ...]
|
|
37
|
+
links: tuple[str, ...]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def retention_order(
|
|
41
|
+
*,
|
|
42
|
+
roots: Sequence[Pin],
|
|
43
|
+
nodes: Sequence[Node],
|
|
44
|
+
links: Sequence[Link],
|
|
45
|
+
) -> SliceOrder:
|
|
46
|
+
"""Complete node order around already-prioritized roots and links."""
|
|
47
|
+
|
|
48
|
+
pins = {node.pin for node in nodes}
|
|
49
|
+
pin_by_ref: dict[Ref, Pin] = {}
|
|
50
|
+
for pin in pins:
|
|
51
|
+
current = pin_by_ref.get(pin.ref)
|
|
52
|
+
if current is not None and current != pin:
|
|
53
|
+
raise ValueError("context_slice_node_ref_ambiguous")
|
|
54
|
+
pin_by_ref[pin.ref] = pin
|
|
55
|
+
|
|
56
|
+
ordered: list[Pin] = []
|
|
57
|
+
seen: set[Pin] = set()
|
|
58
|
+
|
|
59
|
+
def add(pin: Pin) -> None:
|
|
60
|
+
if pin in pins and pin not in seen:
|
|
61
|
+
ordered.append(pin)
|
|
62
|
+
seen.add(pin)
|
|
63
|
+
|
|
64
|
+
for pin in roots:
|
|
65
|
+
add(pin)
|
|
66
|
+
for row in links:
|
|
67
|
+
add(row.owner)
|
|
68
|
+
for ref in (row.source, row.target):
|
|
69
|
+
candidate = pin_by_ref.get(ref)
|
|
70
|
+
if candidate is not None:
|
|
71
|
+
add(candidate)
|
|
72
|
+
for pin in sorted(pins, key=pin_key):
|
|
73
|
+
add(pin)
|
|
74
|
+
return SliceOrder(
|
|
75
|
+
nodes=tuple(ordered),
|
|
76
|
+
links=tuple(dict.fromkeys(row.id for row in links)),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def compile_slice(
|
|
81
|
+
*,
|
|
82
|
+
purpose: str,
|
|
83
|
+
roots: Sequence[Ref],
|
|
84
|
+
effective_as_of: AwareDatetime,
|
|
85
|
+
observed_before: AwareDatetime,
|
|
86
|
+
nodes: Sequence[Node],
|
|
87
|
+
order: SliceOrder,
|
|
88
|
+
links: Sequence[Link] = (),
|
|
89
|
+
omissions: Sequence[Omission] = (),
|
|
90
|
+
policies: Sequence[Policy],
|
|
91
|
+
limits: Limits | None = None,
|
|
92
|
+
) -> Slice:
|
|
93
|
+
"""Compile already-authorized owner contributions without adding authority."""
|
|
94
|
+
|
|
95
|
+
selected_limits = Limits.model_validate(limits or Limits())
|
|
96
|
+
canonical_roots = tuple(sorted(set(roots), key=ref_key))
|
|
97
|
+
canonical_nodes = _nodes(nodes)
|
|
98
|
+
canonical_links = _links(links)
|
|
99
|
+
canonical_policies = _policies(policies)
|
|
100
|
+
source_omissions = list(_omissions(omissions))
|
|
101
|
+
effective_as_of = _AWARE_DATETIME.validate_python(effective_as_of)
|
|
102
|
+
observed_before = _AWARE_DATETIME.validate_python(observed_before)
|
|
103
|
+
nodes_by_ref = _nodes_by_ref(canonical_nodes)
|
|
104
|
+
if (
|
|
105
|
+
not canonical_roots
|
|
106
|
+
or any(root not in nodes_by_ref for root in canonical_roots)
|
|
107
|
+
):
|
|
108
|
+
raise ValueError("context_slice_root_missing_or_ambiguous")
|
|
109
|
+
pins = {node.pin for node in canonical_nodes}
|
|
110
|
+
refs = set(nodes_by_ref)
|
|
111
|
+
for row in canonical_links:
|
|
112
|
+
if row.owner not in pins:
|
|
113
|
+
raise ValueError("context_slice_link_owner_missing")
|
|
114
|
+
if row.source not in refs or row.target not in refs:
|
|
115
|
+
raise ValueError("context_slice_link_ref_missing")
|
|
116
|
+
|
|
117
|
+
ordered_nodes = _ordered_nodes(canonical_nodes, order.nodes)
|
|
118
|
+
ordered_links = _ordered_links(canonical_links, order.links)
|
|
119
|
+
retained_links = list(ordered_links[:selected_limits.max_links])
|
|
120
|
+
count_link_drops = len(canonical_links) - len(retained_links)
|
|
121
|
+
required = _required(canonical_roots, retained_links, nodes_by_ref)
|
|
122
|
+
while len(required) > selected_limits.max_nodes and retained_links:
|
|
123
|
+
retained_links.pop()
|
|
124
|
+
count_link_drops += 1
|
|
125
|
+
required = _required(canonical_roots, retained_links, nodes_by_ref)
|
|
126
|
+
if len(required) > selected_limits.max_nodes:
|
|
127
|
+
raise ValueError("context_slice_root_limit")
|
|
128
|
+
|
|
129
|
+
optional = [
|
|
130
|
+
node
|
|
131
|
+
for node in ordered_nodes
|
|
132
|
+
if node.pin not in required
|
|
133
|
+
]
|
|
134
|
+
retained_nodes = (
|
|
135
|
+
[node for node in ordered_nodes if node.pin in required]
|
|
136
|
+
+ optional[:selected_limits.max_nodes - len(required)]
|
|
137
|
+
)
|
|
138
|
+
count_node_drops = len(canonical_nodes) - len(retained_nodes)
|
|
139
|
+
byte_drops = 0
|
|
140
|
+
|
|
141
|
+
while True:
|
|
142
|
+
generated = _limit_omissions(
|
|
143
|
+
source_omissions
|
|
144
|
+
+ _generated_omissions(
|
|
145
|
+
nodes=count_node_drops,
|
|
146
|
+
links=count_link_drops,
|
|
147
|
+
byte_count=byte_drops,
|
|
148
|
+
),
|
|
149
|
+
selected_limits.max_omissions,
|
|
150
|
+
)
|
|
151
|
+
payload = _payload(
|
|
152
|
+
purpose=purpose,
|
|
153
|
+
roots=canonical_roots,
|
|
154
|
+
effective_as_of=effective_as_of,
|
|
155
|
+
observed_before=observed_before,
|
|
156
|
+
nodes=retained_nodes,
|
|
157
|
+
links=retained_links,
|
|
158
|
+
omissions=generated,
|
|
159
|
+
policies=canonical_policies,
|
|
160
|
+
limits=selected_limits,
|
|
161
|
+
)
|
|
162
|
+
if payload["bounds"]["content_bytes"] <= selected_limits.max_bytes:
|
|
163
|
+
return Slice.model_validate(payload)
|
|
164
|
+
|
|
165
|
+
retained_by_ref = _nodes_by_ref(retained_nodes)
|
|
166
|
+
referenced = _required(canonical_roots, retained_links, retained_by_ref)
|
|
167
|
+
optional_pins = [
|
|
168
|
+
node.pin
|
|
169
|
+
for node in retained_nodes
|
|
170
|
+
if node.pin not in referenced
|
|
171
|
+
]
|
|
172
|
+
if optional_pins:
|
|
173
|
+
removed = optional_pins[-1]
|
|
174
|
+
retained_nodes = [node for node in retained_nodes if node.pin != removed]
|
|
175
|
+
elif retained_links:
|
|
176
|
+
retained_links.pop()
|
|
177
|
+
elif source_omissions:
|
|
178
|
+
source_omissions.pop()
|
|
179
|
+
else:
|
|
180
|
+
raise ValueError("context_slice_byte_limit")
|
|
181
|
+
byte_drops += 1
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _nodes(values: Sequence[Node]) -> tuple[Node, ...]:
|
|
185
|
+
by_ref: dict[Ref, Node] = {}
|
|
186
|
+
for row in values:
|
|
187
|
+
current = by_ref.get(row.pin.ref)
|
|
188
|
+
if current is not None and current != row:
|
|
189
|
+
raise ValueError("context_slice_node_ref_ambiguous")
|
|
190
|
+
by_ref[row.pin.ref] = row
|
|
191
|
+
return tuple(sorted(by_ref.values(), key=lambda row: pin_key(row.pin)))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _nodes_by_ref(values: Sequence[Node]) -> dict[Ref, Node]:
|
|
195
|
+
return {row.pin.ref: row for row in values}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _ordered_nodes(
|
|
199
|
+
values: Sequence[Node],
|
|
200
|
+
order: Sequence[Pin],
|
|
201
|
+
) -> tuple[Node, ...]:
|
|
202
|
+
by_pin = {row.pin: row for row in values}
|
|
203
|
+
if len(order) != len(set(order)) or set(order) != set(by_pin):
|
|
204
|
+
raise ValueError("context_slice_node_order_invalid")
|
|
205
|
+
return tuple(by_pin[pin] for pin in order)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _links(values: Sequence[Link]) -> tuple[Link, ...]:
|
|
209
|
+
by_id: dict[str, Link] = {}
|
|
210
|
+
for row in values:
|
|
211
|
+
current = by_id.get(row.id)
|
|
212
|
+
if current is not None and current != row:
|
|
213
|
+
raise ValueError("context_slice_link_id_conflict")
|
|
214
|
+
by_id[row.id] = row
|
|
215
|
+
return tuple(by_id[key] for key in sorted(by_id))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _ordered_links(
|
|
219
|
+
values: Sequence[Link],
|
|
220
|
+
order: Sequence[str],
|
|
221
|
+
) -> tuple[Link, ...]:
|
|
222
|
+
by_id = {row.id: row for row in values}
|
|
223
|
+
if len(order) != len(set(order)) or set(order) != set(by_id):
|
|
224
|
+
raise ValueError("context_slice_link_order_invalid")
|
|
225
|
+
return tuple(by_id[item_id] for item_id in order)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _policies(values: Sequence[Policy]) -> tuple[Policy, ...]:
|
|
229
|
+
by_key: dict[tuple[str, str], Policy] = {}
|
|
230
|
+
for row in values:
|
|
231
|
+
key = policy_key(row)
|
|
232
|
+
current = by_key.get(key)
|
|
233
|
+
if current is not None and current != row:
|
|
234
|
+
raise ValueError("context_slice_policy_conflict")
|
|
235
|
+
by_key[key] = row
|
|
236
|
+
if not by_key:
|
|
237
|
+
raise ValueError("context_slice_policy_required")
|
|
238
|
+
return tuple(by_key[key] for key in sorted(by_key))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _omissions(values: Sequence[Omission]) -> tuple[Omission, ...]:
|
|
242
|
+
counts: dict[tuple[str, str, str], int] = {}
|
|
243
|
+
for row in values:
|
|
244
|
+
key = omission_key(row)
|
|
245
|
+
counts[key] = counts.get(key, 0) + row.count
|
|
246
|
+
return tuple(
|
|
247
|
+
Omission(owner=key[0], reason=key[1], selector=key[2], count=counts[key])
|
|
248
|
+
for key in sorted(counts)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _limit_omissions(values: Sequence[Omission], limit: int) -> tuple[Omission, ...]:
|
|
253
|
+
canonical = _omissions(values)
|
|
254
|
+
if len(canonical) <= limit:
|
|
255
|
+
return canonical
|
|
256
|
+
retained = list(canonical[:max(0, limit - 1)])
|
|
257
|
+
removed = canonical[len(retained):]
|
|
258
|
+
retained.append(Omission(
|
|
259
|
+
owner="context",
|
|
260
|
+
reason="omission_limit",
|
|
261
|
+
selector="*",
|
|
262
|
+
count=sum(row.count for row in removed),
|
|
263
|
+
))
|
|
264
|
+
return _omissions(retained)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _generated_omissions(
|
|
268
|
+
*,
|
|
269
|
+
nodes: int,
|
|
270
|
+
links: int,
|
|
271
|
+
byte_count: int,
|
|
272
|
+
) -> list[Omission]:
|
|
273
|
+
output: list[Omission] = []
|
|
274
|
+
for reason, count in (
|
|
275
|
+
("node_limit", nodes),
|
|
276
|
+
("link_limit", links),
|
|
277
|
+
("byte_limit", byte_count),
|
|
278
|
+
):
|
|
279
|
+
if count:
|
|
280
|
+
output.append(Omission(
|
|
281
|
+
owner="context",
|
|
282
|
+
reason=reason,
|
|
283
|
+
selector="*",
|
|
284
|
+
count=count,
|
|
285
|
+
))
|
|
286
|
+
return output
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _required(
|
|
290
|
+
roots: Sequence[Ref],
|
|
291
|
+
links: Sequence[Link],
|
|
292
|
+
nodes_by_ref: dict[Ref, Node],
|
|
293
|
+
) -> set[Pin]:
|
|
294
|
+
refs = {
|
|
295
|
+
*roots,
|
|
296
|
+
*(ref for row in links for ref in (row.source, row.target)),
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
*(row.owner for row in links),
|
|
300
|
+
*(nodes_by_ref[ref].pin for ref in refs if ref in nodes_by_ref),
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _payload(
|
|
305
|
+
*,
|
|
306
|
+
purpose: str,
|
|
307
|
+
roots: tuple[Ref, ...],
|
|
308
|
+
effective_as_of: AwareDatetime,
|
|
309
|
+
observed_before: AwareDatetime,
|
|
310
|
+
nodes: Sequence[Node],
|
|
311
|
+
links: Sequence[Link],
|
|
312
|
+
omissions: tuple[Omission, ...],
|
|
313
|
+
policies: tuple[Policy, ...],
|
|
314
|
+
limits: Limits,
|
|
315
|
+
) -> dict[str, Any]:
|
|
316
|
+
canonical_nodes = tuple(sorted(nodes, key=lambda node: pin_key(node.pin)))
|
|
317
|
+
canonical_links = tuple(sorted(links, key=lambda row: row.id))
|
|
318
|
+
truncated = tuple(sorted({
|
|
319
|
+
row.reason
|
|
320
|
+
for row in omissions
|
|
321
|
+
if row.owner == "context" and row.reason.endswith("_limit")
|
|
322
|
+
}))
|
|
323
|
+
payload: dict[str, Any] = {
|
|
324
|
+
"schema_version": 2,
|
|
325
|
+
"untrusted": True,
|
|
326
|
+
"purpose": purpose,
|
|
327
|
+
"roots": [row.model_dump(mode="json") for row in roots],
|
|
328
|
+
"effective_as_of": _time(effective_as_of),
|
|
329
|
+
"observed_before": _time(observed_before),
|
|
330
|
+
"nodes": [row.model_dump(mode="json") for row in canonical_nodes],
|
|
331
|
+
"links": [row.model_dump(mode="json") for row in canonical_links],
|
|
332
|
+
"bounds": Bounds(
|
|
333
|
+
limits=limits,
|
|
334
|
+
nodes=len(canonical_nodes),
|
|
335
|
+
links=len(canonical_links),
|
|
336
|
+
omissions=len(omissions),
|
|
337
|
+
truncated=truncated,
|
|
338
|
+
content_bytes=0,
|
|
339
|
+
).model_dump(mode="json"),
|
|
340
|
+
"omissions": [row.model_dump(mode="json") for row in omissions],
|
|
341
|
+
"policies": [row.model_dump(mode="json") for row in policies],
|
|
342
|
+
"content_hash": "sha256:" + "0" * 64,
|
|
343
|
+
}
|
|
344
|
+
_settle_content_bytes(payload)
|
|
345
|
+
payload["content_hash"] = stable_hash([
|
|
346
|
+
"context-slice-v2",
|
|
347
|
+
{key: value for key, value in payload.items() if key != "content_hash"},
|
|
348
|
+
])
|
|
349
|
+
return payload
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _settle_content_bytes(payload: dict[str, Any]) -> None:
|
|
353
|
+
for _ in range(8):
|
|
354
|
+
size = len(canonical_json_bytes(payload))
|
|
355
|
+
if payload["bounds"]["content_bytes"] == size:
|
|
356
|
+
return
|
|
357
|
+
payload["bounds"]["content_bytes"] = size
|
|
358
|
+
raise ValueError("context_slice_content_bytes_unstable")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _time(value: AwareDatetime) -> str:
|
|
362
|
+
parsed = _AWARE_DATETIME.validate_python(value)
|
|
363
|
+
return cast(str, _AWARE_DATETIME.dump_python(parsed, mode="json"))
|
agos_context/graph.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Minimal immutable carrier for authorized context reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from typing import Annotated, Literal, cast
|
|
9
|
+
|
|
10
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
Owner = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_]{0,63}$")]
|
|
14
|
+
RecordClass = Literal["record", "subject", "evidence", "receipt"]
|
|
15
|
+
Family = Literal["structure", "source", "correction", "association"]
|
|
16
|
+
type _JsonValue = None | bool | int | float | str | list[_JsonValue] | dict[str, _JsonValue]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _json_value(value: object) -> _JsonValue:
|
|
20
|
+
if value is None:
|
|
21
|
+
return value
|
|
22
|
+
if type(value) is bool:
|
|
23
|
+
return value
|
|
24
|
+
if type(value) is int:
|
|
25
|
+
return value
|
|
26
|
+
if type(value) is float:
|
|
27
|
+
if not math.isfinite(value):
|
|
28
|
+
raise ValueError("canonical JSON numbers must be finite")
|
|
29
|
+
return value
|
|
30
|
+
if type(value) is str:
|
|
31
|
+
return value
|
|
32
|
+
if type(value) is list:
|
|
33
|
+
return [_json_value(item) for item in cast(list[object], value)]
|
|
34
|
+
if type(value) is dict:
|
|
35
|
+
items = cast(dict[object, object], value)
|
|
36
|
+
for key in items:
|
|
37
|
+
if type(key) is not str:
|
|
38
|
+
raise TypeError(f"canonical JSON object keys must be strings, got {type(key).__name__}")
|
|
39
|
+
return {cast(str, key): _json_value(item) for key, item in items.items()}
|
|
40
|
+
raise TypeError(f"{type(value).__name__} is not a canonical JSON value")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def canonical_json_bytes(value: object) -> bytes:
|
|
44
|
+
return json.dumps(
|
|
45
|
+
_json_value(value),
|
|
46
|
+
sort_keys=True,
|
|
47
|
+
separators=(",", ":"),
|
|
48
|
+
ensure_ascii=True,
|
|
49
|
+
allow_nan=False,
|
|
50
|
+
).encode("utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def stable_hash(value: object) -> str:
|
|
54
|
+
return f"sha256:{hashlib.sha256(canonical_json_bytes(value)).hexdigest()}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class GraphModel(BaseModel):
|
|
58
|
+
model_config = ConfigDict(
|
|
59
|
+
extra="forbid",
|
|
60
|
+
frozen=True,
|
|
61
|
+
revalidate_instances="always",
|
|
62
|
+
str_strip_whitespace=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Ref(GraphModel):
|
|
67
|
+
owner: Owner
|
|
68
|
+
kind: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$")
|
|
69
|
+
id: str = Field(min_length=1, max_length=512)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Pin(GraphModel):
|
|
73
|
+
ref: Ref
|
|
74
|
+
revision: str = Field(min_length=1, max_length=256)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Node(GraphModel):
|
|
78
|
+
pin: Pin
|
|
79
|
+
record_class: RecordClass
|
|
80
|
+
label: str = Field(min_length=1, max_length=240)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Link(GraphModel):
|
|
84
|
+
id: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
|
85
|
+
owner: Pin
|
|
86
|
+
source: Ref
|
|
87
|
+
predicate: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}\.[a-z][a-z0-9_]{0,63}$")
|
|
88
|
+
target: Ref
|
|
89
|
+
family: Family
|
|
90
|
+
|
|
91
|
+
@model_validator(mode="after")
|
|
92
|
+
def _identity(self) -> "Link":
|
|
93
|
+
if self.predicate.partition(".")[0] != self.owner.ref.owner:
|
|
94
|
+
raise ValueError("context_link_owner_mismatch")
|
|
95
|
+
if self.id != stable_hash([
|
|
96
|
+
"context-link-v1",
|
|
97
|
+
self.model_dump(mode="json", exclude={"id"}),
|
|
98
|
+
]):
|
|
99
|
+
raise ValueError("context_link_id_mismatch")
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def link(
|
|
104
|
+
*,
|
|
105
|
+
owner: Pin,
|
|
106
|
+
source: Ref,
|
|
107
|
+
predicate: str,
|
|
108
|
+
target: Ref,
|
|
109
|
+
family: Family,
|
|
110
|
+
) -> Link:
|
|
111
|
+
"""Construct one content-addressed context link."""
|
|
112
|
+
|
|
113
|
+
payload = {
|
|
114
|
+
"owner": owner.model_dump(mode="json"),
|
|
115
|
+
"source": source.model_dump(mode="json"),
|
|
116
|
+
"predicate": predicate,
|
|
117
|
+
"target": target.model_dump(mode="json"),
|
|
118
|
+
"family": family,
|
|
119
|
+
}
|
|
120
|
+
return Link.model_validate({
|
|
121
|
+
"id": stable_hash(["context-link-v1", payload]),
|
|
122
|
+
**payload,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Omission(GraphModel):
|
|
127
|
+
owner: Owner
|
|
128
|
+
reason: str = Field(pattern=r"^[a-z][a-z0-9_.]{0,127}$")
|
|
129
|
+
selector: str = Field(min_length=1, max_length=512)
|
|
130
|
+
count: int = Field(default=1, ge=1, strict=True)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Policy(GraphModel):
|
|
134
|
+
owner: Owner
|
|
135
|
+
name: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$")
|
|
136
|
+
revision: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Contribution(GraphModel):
|
|
140
|
+
"""One owner's closed, immutable contribution to a context slice."""
|
|
141
|
+
|
|
142
|
+
owner: Owner
|
|
143
|
+
nodes: tuple[Node, ...] = Field(default=(), max_length=500)
|
|
144
|
+
links: tuple[Link, ...] = Field(default=(), max_length=1_000)
|
|
145
|
+
omissions: tuple[Omission, ...] = Field(default=(), max_length=200)
|
|
146
|
+
policies: tuple[Policy, ...] = Field(min_length=1, max_length=20)
|
|
147
|
+
|
|
148
|
+
@model_validator(mode="after")
|
|
149
|
+
def _closed_owner_contribution(self) -> "Contribution":
|
|
150
|
+
pins = tuple(node.pin for node in self.nodes)
|
|
151
|
+
refs = tuple(pin.ref for pin in pins)
|
|
152
|
+
if len(set(pins)) != len(pins) or len(set(refs)) != len(refs):
|
|
153
|
+
raise ValueError("context_contribution_node_duplicated")
|
|
154
|
+
if len({row.id for row in self.links}) != len(self.links):
|
|
155
|
+
raise ValueError("context_contribution_link_duplicated")
|
|
156
|
+
available_pins = set(pins)
|
|
157
|
+
available_refs = set(refs)
|
|
158
|
+
for row in self.links:
|
|
159
|
+
if row.owner.ref.owner != self.owner:
|
|
160
|
+
raise ValueError("context_contribution_link_owner_mismatch")
|
|
161
|
+
if row.owner not in available_pins:
|
|
162
|
+
raise ValueError("context_contribution_link_owner_missing")
|
|
163
|
+
if row.source not in available_refs or row.target not in available_refs:
|
|
164
|
+
raise ValueError("context_contribution_link_ref_missing")
|
|
165
|
+
if any(row.owner != self.owner for row in self.omissions):
|
|
166
|
+
raise ValueError("context_contribution_omission_owner_mismatch")
|
|
167
|
+
if any(row.owner != self.owner for row in self.policies):
|
|
168
|
+
raise ValueError("context_contribution_policy_owner_mismatch")
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Limits(GraphModel):
|
|
173
|
+
max_nodes: int = Field(default=128, ge=1, le=500, strict=True)
|
|
174
|
+
max_links: int = Field(default=128, ge=0, le=1_000, strict=True)
|
|
175
|
+
max_omissions: int = Field(default=100, ge=1, le=200, strict=True)
|
|
176
|
+
max_bytes: int = Field(default=500_000, ge=1_000, le=500_000, strict=True)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Bounds(GraphModel):
|
|
180
|
+
limits: Limits
|
|
181
|
+
nodes: int = Field(ge=1, strict=True)
|
|
182
|
+
links: int = Field(ge=0, strict=True)
|
|
183
|
+
omissions: int = Field(ge=0, strict=True)
|
|
184
|
+
truncated: tuple[str, ...] = Field(default=(), max_length=4)
|
|
185
|
+
content_bytes: int = Field(ge=0, strict=True)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Slice(GraphModel):
|
|
189
|
+
schema_version: Literal[2] = 2
|
|
190
|
+
untrusted: Literal[True] = True
|
|
191
|
+
purpose: str = Field(pattern=r"^[a-z][a-z0-9_]{0,79}$")
|
|
192
|
+
roots: tuple[Ref, ...] = Field(min_length=1, max_length=20)
|
|
193
|
+
effective_as_of: AwareDatetime
|
|
194
|
+
observed_before: AwareDatetime
|
|
195
|
+
nodes: tuple[Node, ...] = Field(min_length=1, max_length=500)
|
|
196
|
+
links: tuple[Link, ...] = Field(default=(), max_length=1_000)
|
|
197
|
+
bounds: Bounds
|
|
198
|
+
omissions: tuple[Omission, ...] = Field(default=(), max_length=200)
|
|
199
|
+
policies: tuple[Policy, ...] = Field(min_length=1, max_length=20)
|
|
200
|
+
content_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
|
201
|
+
|
|
202
|
+
@model_validator(mode="after")
|
|
203
|
+
def _closed_canonical_slice(self) -> "Slice":
|
|
204
|
+
if self.roots != tuple(sorted(set(self.roots), key=ref_key)):
|
|
205
|
+
raise ValueError("context_slice_roots_not_canonical")
|
|
206
|
+
if self.nodes != tuple(sorted(self.nodes, key=lambda node: pin_key(node.pin))):
|
|
207
|
+
raise ValueError("context_slice_nodes_not_canonical")
|
|
208
|
+
if self.links != tuple(sorted(self.links, key=lambda row: row.id)):
|
|
209
|
+
raise ValueError("context_slice_links_not_canonical")
|
|
210
|
+
if len({row.id for row in self.links}) != len(self.links):
|
|
211
|
+
raise ValueError("context_slice_link_duplicated")
|
|
212
|
+
if self.omissions != tuple(sorted(self.omissions, key=omission_key)):
|
|
213
|
+
raise ValueError("context_slice_omissions_not_canonical")
|
|
214
|
+
if len({omission_key(row) for row in self.omissions}) != len(self.omissions):
|
|
215
|
+
raise ValueError("context_slice_omission_duplicated")
|
|
216
|
+
if self.policies != tuple(sorted(self.policies, key=policy_key)):
|
|
217
|
+
raise ValueError("context_slice_policies_not_canonical")
|
|
218
|
+
if len({policy_key(row) for row in self.policies}) != len(self.policies):
|
|
219
|
+
raise ValueError("context_slice_policy_duplicated")
|
|
220
|
+
|
|
221
|
+
pins = tuple(node.pin for node in self.nodes)
|
|
222
|
+
if len(set(pins)) != len(pins):
|
|
223
|
+
raise ValueError("context_slice_node_pin_duplicated")
|
|
224
|
+
refs = tuple(pin.ref for pin in pins)
|
|
225
|
+
if len(set(refs)) != len(refs):
|
|
226
|
+
raise ValueError("context_slice_node_ref_ambiguous")
|
|
227
|
+
available_refs = set(refs)
|
|
228
|
+
available_pins = set(pins)
|
|
229
|
+
if any(sum(pin.ref == root for pin in pins) != 1 for root in self.roots):
|
|
230
|
+
raise ValueError("context_slice_root_missing_or_ambiguous")
|
|
231
|
+
for row in self.links:
|
|
232
|
+
if row.owner not in available_pins:
|
|
233
|
+
raise ValueError("context_slice_link_owner_missing")
|
|
234
|
+
if row.source not in available_refs or row.target not in available_refs:
|
|
235
|
+
raise ValueError("context_slice_link_ref_missing")
|
|
236
|
+
|
|
237
|
+
if (
|
|
238
|
+
self.bounds.nodes != len(self.nodes)
|
|
239
|
+
or self.bounds.links != len(self.links)
|
|
240
|
+
or self.bounds.omissions != len(self.omissions)
|
|
241
|
+
):
|
|
242
|
+
raise ValueError("context_slice_bounds_mismatch")
|
|
243
|
+
if (
|
|
244
|
+
len(self.nodes) > self.bounds.limits.max_nodes
|
|
245
|
+
or len(self.links) > self.bounds.limits.max_links
|
|
246
|
+
or len(self.omissions) > self.bounds.limits.max_omissions
|
|
247
|
+
or self.bounds.content_bytes > self.bounds.limits.max_bytes
|
|
248
|
+
):
|
|
249
|
+
raise ValueError("context_slice_limit_exceeded")
|
|
250
|
+
expected_truncated = tuple(sorted({
|
|
251
|
+
row.reason
|
|
252
|
+
for row in self.omissions
|
|
253
|
+
if row.owner == "context" and row.reason.endswith("_limit")
|
|
254
|
+
}))
|
|
255
|
+
if self.bounds.truncated != expected_truncated:
|
|
256
|
+
raise ValueError("context_slice_truncation_mismatch")
|
|
257
|
+
if self.bounds.content_bytes != len(canonical_json_bytes(self.model_dump(mode="json"))):
|
|
258
|
+
raise ValueError("context_slice_content_bytes_mismatch")
|
|
259
|
+
if self.content_hash != stable_hash([
|
|
260
|
+
"context-slice-v2",
|
|
261
|
+
self.model_dump(mode="json", exclude={"content_hash"}),
|
|
262
|
+
]):
|
|
263
|
+
raise ValueError("context_slice_content_hash_mismatch")
|
|
264
|
+
return self
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def ref_key(ref: Ref) -> tuple[str, str, str]:
|
|
268
|
+
return (ref.owner, ref.kind, ref.id)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def pin_key(pin: Pin) -> tuple[str, str, str, str]:
|
|
272
|
+
return (*ref_key(pin.ref), pin.revision)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def omission_key(row: Omission) -> tuple[str, str, str]:
|
|
276
|
+
return (row.owner, row.reason, row.selector)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def policy_key(row: Policy) -> tuple[str, str]:
|
|
280
|
+
return (row.owner, row.name)
|