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.
File without changes
@@ -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)