gcf-python 2.2.2__py3-none-any.whl → 2.5.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.
- gcf/__init__.py +38 -2
- gcf/decode_generic.py +76 -8
- gcf/delta.py +186 -3
- gcf/encode.py +36 -14
- gcf/generic.py +79 -7
- gcf/generic_delta.py +487 -0
- gcf/keyed_map.py +86 -0
- gcf/packroot.py +53 -0
- gcf/scalar.py +11 -3
- gcf/session.py +26 -16
- gcf/stream.py +22 -13
- gcf/stream_generic.py +54 -3
- {gcf_python-2.2.2.dist-info → gcf_python-2.5.0.dist-info}/METADATA +97 -17
- gcf_python-2.5.0.dist-info/RECORD +22 -0
- gcf_python-2.2.2.dist-info/RECORD +0 -19
- {gcf_python-2.2.2.dist-info → gcf_python-2.5.0.dist-info}/WHEEL +0 -0
- {gcf_python-2.2.2.dist-info → gcf_python-2.5.0.dist-info}/entry_points.txt +0 -0
- {gcf_python-2.2.2.dist-info → gcf_python-2.5.0.dist-info}/licenses/LICENSE +0 -0
gcf/generic_delta.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""GCF generic-profile delta encoding (SPEC Section 10a).
|
|
2
|
+
|
|
3
|
+
Producer + consumer. Mirrors the gcf-go reference implementation; the shared
|
|
4
|
+
conformance fixtures (generic-pack-root/, generic-delta/) hold both to identical
|
|
5
|
+
bytes and hashes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .scalar import (
|
|
16
|
+
format_key,
|
|
17
|
+
format_number,
|
|
18
|
+
format_scalar,
|
|
19
|
+
parse_scalar,
|
|
20
|
+
quote_string,
|
|
21
|
+
split_respecting_quotes,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class GenericSet:
|
|
27
|
+
"""A keyed record set: the unit generic-profile delta operates on (Section 10a).
|
|
28
|
+
|
|
29
|
+
Rows are order-agnostic (set semantics); ``fields`` carries the declared column
|
|
30
|
+
order for the wire form; ``key`` names the identity column (the ``@id`` / ``key=``);
|
|
31
|
+
``name`` is the tabular section name for a full payload.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
key: str
|
|
35
|
+
fields: list[str]
|
|
36
|
+
rows: list[dict[str, Any]]
|
|
37
|
+
name: str = "rows"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class GenericDeltaPayload:
|
|
42
|
+
key: str
|
|
43
|
+
fields: list[str]
|
|
44
|
+
base_root: str = ""
|
|
45
|
+
new_root: str = ""
|
|
46
|
+
added: list[dict[str, Any]] = field(default_factory=list)
|
|
47
|
+
changed: list[dict[str, Any]] = field(default_factory=list)
|
|
48
|
+
removed: list[Any] = field(default_factory=list)
|
|
49
|
+
tool: str = ""
|
|
50
|
+
delta_tokens: int = 0
|
|
51
|
+
full_tokens: int = 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _canonical_cell(v: Any) -> str:
|
|
55
|
+
"""Canonicalize one value for the pack-root record (Section 10a.3).
|
|
56
|
+
|
|
57
|
+
Decoupled from the wire cell encoder: collision-free and record-safe, not
|
|
58
|
+
round-trippable. Typed literals stay bare (null is ``-``, booleans true/false,
|
|
59
|
+
numbers canonical); strings are ALWAYS quoted so they cannot collide with a
|
|
60
|
+
typed literal and any tab/newline inside is escaped.
|
|
61
|
+
"""
|
|
62
|
+
if v is None:
|
|
63
|
+
return "-"
|
|
64
|
+
if isinstance(v, bool): # must precede int: bool is a subclass of int
|
|
65
|
+
return "true" if v else "false"
|
|
66
|
+
if isinstance(v, (int, float)):
|
|
67
|
+
return format_number(float(v))
|
|
68
|
+
if isinstance(v, str):
|
|
69
|
+
return quote_string(v)
|
|
70
|
+
return quote_string(str(v))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def generic_pack_root(s: GenericSet) -> str:
|
|
74
|
+
"""Canonical pack root for a keyed set (gcf-pack-root-v1, generic profile, 10a.3).
|
|
75
|
+
|
|
76
|
+
Records and fields are sorted by unsigned UTF-8 byte order to match every SDK.
|
|
77
|
+
"""
|
|
78
|
+
sorted_fields = sorted(s.fields, key=lambda x: x.encode("utf-8"))
|
|
79
|
+
records: list[str] = []
|
|
80
|
+
for row in s.rows:
|
|
81
|
+
parts = ["R"]
|
|
82
|
+
for f in sorted_fields:
|
|
83
|
+
parts.append(f)
|
|
84
|
+
parts.append(_canonical_cell(row.get(f)))
|
|
85
|
+
records.append("\t".join(parts) + "\n")
|
|
86
|
+
records.sort(key=lambda r: r.encode("utf-8"))
|
|
87
|
+
digest = hashlib.sha256("".join(records).encode("utf-8")).hexdigest()
|
|
88
|
+
return "sha256:" + digest
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _index_by_key(s: GenericSet) -> dict[str, dict[str, Any]]:
|
|
92
|
+
m: dict[str, dict[str, Any]] = {}
|
|
93
|
+
for row in s.rows:
|
|
94
|
+
ident = _canonical_cell(row.get(s.key))
|
|
95
|
+
if ident in m:
|
|
96
|
+
raise ValueError(f"delta_invalid: duplicate identity {ident} for key {s.key!r}")
|
|
97
|
+
m[ident] = row
|
|
98
|
+
return m
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _rows_equal(a: dict[str, Any], b: dict[str, Any], fields: list[str]) -> bool:
|
|
102
|
+
return all(_canonical_cell(a.get(f)) == _canonical_cell(b.get(f)) for f in fields)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def diff_generic_sets(base: GenericSet, nxt: GenericSet) -> GenericDeltaPayload:
|
|
106
|
+
"""Compute the delta from ``base`` to ``nxt`` (the blessed producer path).
|
|
107
|
+
|
|
108
|
+
Centralizes the keyed-diff invariants: identity uniqueness, added-not-in-base,
|
|
109
|
+
changed-must-exist, whole-row replacement, unchanged rows omitted. Output is
|
|
110
|
+
sorted by identity for reproducibility (10a.6). Schema change or a missing key
|
|
111
|
+
raises (caller must send full, 10a.7).
|
|
112
|
+
"""
|
|
113
|
+
if not nxt.key:
|
|
114
|
+
raise ValueError("delta_invalid: no identity key")
|
|
115
|
+
if nxt.key != base.key or list(base.fields) != list(nxt.fields):
|
|
116
|
+
raise ValueError("delta_invalid: schema change (send full)")
|
|
117
|
+
|
|
118
|
+
base_by_id = _index_by_key(base)
|
|
119
|
+
next_by_id = _index_by_key(nxt)
|
|
120
|
+
|
|
121
|
+
d = GenericDeltaPayload(
|
|
122
|
+
key=nxt.key,
|
|
123
|
+
fields=list(nxt.fields),
|
|
124
|
+
base_root=generic_pack_root(base),
|
|
125
|
+
new_root=generic_pack_root(nxt),
|
|
126
|
+
)
|
|
127
|
+
for ident, row in next_by_id.items():
|
|
128
|
+
brow = base_by_id.get(ident)
|
|
129
|
+
if brow is None:
|
|
130
|
+
d.added.append(row)
|
|
131
|
+
elif not _rows_equal(brow, row, nxt.fields):
|
|
132
|
+
d.changed.append(row)
|
|
133
|
+
# equal rows are omitted (silence = "keep it", 10a.5)
|
|
134
|
+
for ident, brow in base_by_id.items():
|
|
135
|
+
if ident not in next_by_id:
|
|
136
|
+
d.removed.append(brow.get(nxt.key))
|
|
137
|
+
|
|
138
|
+
d.added.sort(key=lambda r: _canonical_cell(r.get(d.key)))
|
|
139
|
+
d.changed.sort(key=lambda r: _canonical_cell(r.get(d.key)))
|
|
140
|
+
d.removed.sort(key=_canonical_cell)
|
|
141
|
+
return d
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _field_decl(fields: list[str], key: str) -> str:
|
|
145
|
+
return ",".join(("@" + format_key(f)) if f == key else format_key(f) for f in fields)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _encode_row(row: dict[str, Any], fields: list[str]) -> str:
|
|
149
|
+
return "|".join(format_scalar(row.get(f), "|") for f in fields)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def encode_generic_full(s: GenericSet, tool: str = "") -> str:
|
|
153
|
+
"""Emit a delta-participating full base payload: key= header, @id field, rows."""
|
|
154
|
+
name = s.name or "rows"
|
|
155
|
+
header = "GCF profile=generic"
|
|
156
|
+
if tool:
|
|
157
|
+
header += f" tool={tool}"
|
|
158
|
+
header += f" pack_root={generic_pack_root(s)} key={s.key}"
|
|
159
|
+
lines = [header, f"## {name} [{len(s.rows)}]{{{_field_decl(s.fields, s.key)}}}"]
|
|
160
|
+
lines += [_encode_row(row, s.fields) for row in s.rows]
|
|
161
|
+
return "\n".join(lines) + "\n"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def encode_generic_delta(d: GenericDeltaPayload) -> str:
|
|
165
|
+
"""Serialize a delta payload (10a.2). Sections ordered added/changed/removed."""
|
|
166
|
+
header = "GCF profile=generic"
|
|
167
|
+
if d.tool:
|
|
168
|
+
header += f" tool={d.tool}"
|
|
169
|
+
header += f" delta=true base_root={d.base_root} new_root={d.new_root} key={d.key}"
|
|
170
|
+
if d.full_tokens > 0:
|
|
171
|
+
savings = 100.0 * (1.0 - d.delta_tokens / d.full_tokens)
|
|
172
|
+
header += f" savings={savings:.0f}%"
|
|
173
|
+
lines = [header]
|
|
174
|
+
if d.added:
|
|
175
|
+
lines.append(f"## added [{len(d.added)}]{{{_field_decl(d.fields, d.key)}}}")
|
|
176
|
+
lines += [_encode_row(r, d.fields) for r in d.added]
|
|
177
|
+
if d.changed:
|
|
178
|
+
lines.append(f"## changed [{len(d.changed)}]{{{_field_decl(d.fields, d.key)}}}")
|
|
179
|
+
lines += [_encode_row(r, d.fields) for r in d.changed]
|
|
180
|
+
if d.removed:
|
|
181
|
+
lines.append(f"## removed [{len(d.removed)}]{{@{d.key}}}")
|
|
182
|
+
lines += [format_scalar(idv, "|") for idv in d.removed]
|
|
183
|
+
return "\n".join(lines) + "\n"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def verify_generic_delta(base: GenericSet, d: GenericDeltaPayload, expected_new_root: str) -> GenericSet:
|
|
187
|
+
"""Apply a delta to a base set and verify the result hashes to expected_new_root.
|
|
188
|
+
|
|
189
|
+
Atomic (10a.5): the whole payload is validated before any mutation; on failure the
|
|
190
|
+
base is left untouched and a ValueError is raised.
|
|
191
|
+
"""
|
|
192
|
+
if generic_pack_root(base) != d.base_root:
|
|
193
|
+
raise ValueError("base_mismatch: base root does not equal delta base_root")
|
|
194
|
+
base_by_id = _index_by_key(base)
|
|
195
|
+
|
|
196
|
+
for idv in d.removed:
|
|
197
|
+
if _canonical_cell(idv) not in base_by_id:
|
|
198
|
+
raise ValueError(f"delta_invalid: removing identity {_canonical_cell(idv)} not in base")
|
|
199
|
+
for row in d.added:
|
|
200
|
+
if _canonical_cell(row.get(d.key)) in base_by_id:
|
|
201
|
+
raise ValueError(f"delta_invalid: adding identity {_canonical_cell(row.get(d.key))} that already exists")
|
|
202
|
+
for row in d.changed:
|
|
203
|
+
if _canonical_cell(row.get(d.key)) not in base_by_id:
|
|
204
|
+
raise ValueError(f"delta_invalid: changing identity {_canonical_cell(row.get(d.key))} not in base")
|
|
205
|
+
|
|
206
|
+
work = dict(base_by_id)
|
|
207
|
+
for idv in d.removed:
|
|
208
|
+
work.pop(_canonical_cell(idv), None)
|
|
209
|
+
for row in d.added:
|
|
210
|
+
work[_canonical_cell(row.get(d.key))] = row
|
|
211
|
+
for row in d.changed:
|
|
212
|
+
work[_canonical_cell(row.get(d.key))] = row
|
|
213
|
+
|
|
214
|
+
result = GenericSet(key=base.key, fields=list(base.fields), rows=list(work.values()), name=base.name)
|
|
215
|
+
got = generic_pack_root(result)
|
|
216
|
+
if got != expected_new_root:
|
|
217
|
+
raise ValueError(f"root_mismatch: computed {got}, expected {expected_new_root}")
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --- consumer-side wire parsing ---
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _parse_header_fields(header: str) -> dict[str, str]:
|
|
225
|
+
m: dict[str, str] = {}
|
|
226
|
+
for tok in header.split():
|
|
227
|
+
if "=" in tok:
|
|
228
|
+
k, _, v = tok.partition("=")
|
|
229
|
+
if k:
|
|
230
|
+
m[k] = v
|
|
231
|
+
return m
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _parse_count(s: str) -> int:
|
|
235
|
+
if s == "0":
|
|
236
|
+
return 0
|
|
237
|
+
if not s or s[0] == "0" or not s.isdigit():
|
|
238
|
+
raise ValueError(f"invalid_count: {s}")
|
|
239
|
+
return int(s)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _split_delta_field_decl(decl: str) -> tuple[list[str], str]:
|
|
243
|
+
if len(decl) < 2 or decl[0] != "{" or decl[-1] != "}":
|
|
244
|
+
raise ValueError(f"invalid field declaration: {decl}")
|
|
245
|
+
inner = decl[1:-1]
|
|
246
|
+
if inner == "":
|
|
247
|
+
return [], ""
|
|
248
|
+
fields: list[str] = []
|
|
249
|
+
key_field = ""
|
|
250
|
+
for raw in split_respecting_quotes(inner, ","):
|
|
251
|
+
f = raw.strip()
|
|
252
|
+
is_key = False
|
|
253
|
+
if f.startswith("@"):
|
|
254
|
+
f = f[1:]
|
|
255
|
+
is_key = True
|
|
256
|
+
if len(f) >= 2 and f[0] == '"' and f[-1] == '"':
|
|
257
|
+
from .scalar import parse_quoted_string
|
|
258
|
+
|
|
259
|
+
f = parse_quoted_string(f)
|
|
260
|
+
if is_key:
|
|
261
|
+
key_field = f
|
|
262
|
+
fields.append(f)
|
|
263
|
+
return fields, key_field
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _parse_section_header(content: str) -> tuple[str, int, list[str], str]:
|
|
267
|
+
bi = content.find(" [")
|
|
268
|
+
if bi < 0:
|
|
269
|
+
raise ValueError(f"delta_invalid: section header without count: {content!r}")
|
|
270
|
+
name = content[:bi].strip()
|
|
271
|
+
rest = content[bi + 1:] # "[N]{...}"
|
|
272
|
+
if not rest or rest[0] != "[":
|
|
273
|
+
raise ValueError(f"delta_invalid: malformed section header: {content!r}")
|
|
274
|
+
close = rest.find("]")
|
|
275
|
+
if close < 0:
|
|
276
|
+
raise ValueError(f"delta_invalid: unterminated count: {content!r}")
|
|
277
|
+
count = _parse_count(rest[1:close])
|
|
278
|
+
fields, key_field = _split_delta_field_decl(rest[close + 1:])
|
|
279
|
+
return name, count, fields, key_field
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _parse_row(line: str, fields: list[str]) -> dict[str, Any]:
|
|
283
|
+
cells = split_respecting_quotes(line, "|")
|
|
284
|
+
if len(cells) != len(fields):
|
|
285
|
+
raise ValueError(f"delta_invalid: row has {len(cells)} cells, expected {len(fields)}: {line!r}")
|
|
286
|
+
return {f: parse_scalar(cells[i], True) for i, f in enumerate(fields)}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def decode_generic_full(text: str) -> tuple[GenericSet, str]:
|
|
290
|
+
"""Parse a delta-participating full base payload into (GenericSet, pack_root)."""
|
|
291
|
+
lines = text.rstrip("\n").split("\n")
|
|
292
|
+
if not lines:
|
|
293
|
+
raise ValueError("empty payload")
|
|
294
|
+
hdr = _parse_header_fields(lines[0])
|
|
295
|
+
if hdr.get("profile") != "generic":
|
|
296
|
+
raise ValueError("not a generic payload")
|
|
297
|
+
s = GenericSet(key=hdr.get("key", ""), fields=[], rows=[])
|
|
298
|
+
i = 1
|
|
299
|
+
while i < len(lines):
|
|
300
|
+
line = lines[i]
|
|
301
|
+
if not line.startswith("## "):
|
|
302
|
+
i += 1
|
|
303
|
+
continue
|
|
304
|
+
name, count, fields, key_field = _parse_section_header(line[3:])
|
|
305
|
+
s.name, s.fields = name, fields
|
|
306
|
+
if not s.key:
|
|
307
|
+
s.key = key_field
|
|
308
|
+
i += 1
|
|
309
|
+
for _ in range(count):
|
|
310
|
+
if i >= len(lines):
|
|
311
|
+
raise ValueError("delta_invalid: fewer rows than declared count")
|
|
312
|
+
s.rows.append(_parse_row(lines[i], fields))
|
|
313
|
+
i += 1
|
|
314
|
+
return s, hdr.get("pack_root", "")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def decode_generic_delta(text: str) -> GenericDeltaPayload:
|
|
318
|
+
"""Parse a delta payload (10a.2) into a GenericDeltaPayload for application."""
|
|
319
|
+
lines = text.rstrip("\n").split("\n")
|
|
320
|
+
if not lines:
|
|
321
|
+
raise ValueError("empty payload")
|
|
322
|
+
hdr = _parse_header_fields(lines[0])
|
|
323
|
+
if hdr.get("profile") != "generic":
|
|
324
|
+
raise ValueError("not a generic payload")
|
|
325
|
+
if hdr.get("delta") != "true":
|
|
326
|
+
raise ValueError("not a delta payload")
|
|
327
|
+
d = GenericDeltaPayload(
|
|
328
|
+
key=hdr.get("key", ""),
|
|
329
|
+
fields=[],
|
|
330
|
+
base_root=hdr.get("base_root", ""),
|
|
331
|
+
new_root=hdr.get("new_root", ""),
|
|
332
|
+
tool=hdr.get("tool", ""),
|
|
333
|
+
)
|
|
334
|
+
i = 1
|
|
335
|
+
while i < len(lines):
|
|
336
|
+
line = lines[i]
|
|
337
|
+
if not line.startswith("## "):
|
|
338
|
+
i += 1
|
|
339
|
+
continue
|
|
340
|
+
name, count, fields, key_field = _parse_section_header(line[3:])
|
|
341
|
+
if not d.key and key_field:
|
|
342
|
+
d.key = key_field
|
|
343
|
+
if not d.fields and name in ("added", "changed"):
|
|
344
|
+
d.fields = fields
|
|
345
|
+
i += 1
|
|
346
|
+
if name in ("added", "changed"):
|
|
347
|
+
rows = []
|
|
348
|
+
for _ in range(count):
|
|
349
|
+
if i >= len(lines):
|
|
350
|
+
raise ValueError(f"delta_invalid: fewer rows than declared count in ## {name}")
|
|
351
|
+
rows.append(_parse_row(lines[i], fields))
|
|
352
|
+
i += 1
|
|
353
|
+
if name == "added":
|
|
354
|
+
d.added = rows
|
|
355
|
+
else:
|
|
356
|
+
d.changed = rows
|
|
357
|
+
elif name == "removed":
|
|
358
|
+
for _ in range(count):
|
|
359
|
+
if i >= len(lines):
|
|
360
|
+
raise ValueError("delta_invalid: fewer identities than declared count in ## removed")
|
|
361
|
+
d.removed.append(parse_scalar(lines[i], True))
|
|
362
|
+
i += 1
|
|
363
|
+
else:
|
|
364
|
+
raise ValueError(f"delta_invalid: unknown delta section {name!r}")
|
|
365
|
+
return d
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# --- producer-side re-anchor session helper (SPEC Section 10a.8) ---
|
|
369
|
+
#
|
|
370
|
+
# GenericDeltaSession manages the re-anchor cadence for a stream of
|
|
371
|
+
# generic-profile updates. It is thin sugar over the primitives: each ``next``
|
|
372
|
+
# emits either a compact delta or, on its chosen cadence, a full re-anchor,
|
|
373
|
+
# updating its held base. It introduces NO new wire syntax: every payload it
|
|
374
|
+
# emits is exactly what ``encode_generic_full`` or ``encode_generic_delta``
|
|
375
|
+
# produce, and the decoder accepts them cadence-agnostically. N and the size
|
|
376
|
+
# guard are the helper's knobs; they are never wire fields. (Section 10a.8 is
|
|
377
|
+
# non-normative producer policy.)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# The working default cadence for FixedN (SPEC Section 10a.8).
|
|
381
|
+
DEFAULT_REANCHOR_N = 15
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class ReanchorMode(Enum):
|
|
385
|
+
"""Selects the session's cadence policy."""
|
|
386
|
+
|
|
387
|
+
FIXED_N = 0 # re-anchor every N turns
|
|
388
|
+
SIZE_GUARD = 1 # re-anchor once cumulative delta reaches the full payload's size
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@dataclass
|
|
392
|
+
class ReanchorPolicy:
|
|
393
|
+
"""Selects when a :class:`GenericDeltaSession` re-anchors.
|
|
394
|
+
|
|
395
|
+
Construct it with :func:`fixed_n` or :func:`size_guard`.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
mode: ReanchorMode = ReanchorMode.FIXED_N
|
|
399
|
+
n: int = 0 # turns between anchors; FixedN only
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def fixed_n(n: int) -> ReanchorPolicy:
|
|
403
|
+
"""Re-anchor every ``n`` turns. ``n <= 0`` falls back to ``DEFAULT_REANCHOR_N``."""
|
|
404
|
+
if n <= 0:
|
|
405
|
+
n = DEFAULT_REANCHOR_N
|
|
406
|
+
return ReanchorPolicy(mode=ReanchorMode.FIXED_N, n=n)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def size_guard() -> ReanchorPolicy:
|
|
410
|
+
"""Re-anchor once the cumulative delta bytes since the last anchor reach the
|
|
411
|
+
current full payload's byte size: it re-anchors more under heavy churn, rarely
|
|
412
|
+
under light churn, and bounds the delta spent between anchors to about one full
|
|
413
|
+
payload. Production-recommended.
|
|
414
|
+
"""
|
|
415
|
+
return ReanchorPolicy(mode=ReanchorMode.SIZE_GUARD)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _byte_len(s: str) -> int:
|
|
419
|
+
"""UTF-8 byte length, matching Go's ``len(string)`` (bytes, not code points)."""
|
|
420
|
+
return len(s.encode("utf-8"))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class GenericDeltaSession:
|
|
424
|
+
"""Holds the current base and re-anchor state for a producer loop.
|
|
425
|
+
|
|
426
|
+
Not safe for concurrent use. Call :meth:`current_full` to get the initial
|
|
427
|
+
full payload to transmit, then :meth:`next` for each subsequent state.
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
def __init__(self, base: GenericSet, tool: str, policy: ReanchorPolicy) -> None:
|
|
431
|
+
if policy.mode == ReanchorMode.FIXED_N and policy.n <= 0:
|
|
432
|
+
policy = ReanchorPolicy(mode=policy.mode, n=DEFAULT_REANCHOR_N)
|
|
433
|
+
self._base = base
|
|
434
|
+
self._tool = tool
|
|
435
|
+
self._policy = policy
|
|
436
|
+
self._turn = 0
|
|
437
|
+
self._cum = 0 # cumulative delta bytes since the last anchor
|
|
438
|
+
|
|
439
|
+
@property
|
|
440
|
+
def turn(self) -> int:
|
|
441
|
+
"""The number of :meth:`next` calls so far (the initial full is turn 0)."""
|
|
442
|
+
return self._turn
|
|
443
|
+
|
|
444
|
+
def current_full(self) -> str:
|
|
445
|
+
"""Return the full payload for the current base (:func:`encode_generic_full`).
|
|
446
|
+
|
|
447
|
+
Send this first to establish the base; it is also a valid manual re-anchor.
|
|
448
|
+
"""
|
|
449
|
+
return encode_generic_full(self._base, self._tool)
|
|
450
|
+
|
|
451
|
+
def _reanchor(self, nxt: GenericSet) -> str:
|
|
452
|
+
wire = encode_generic_full(nxt, self._tool)
|
|
453
|
+
self._base = nxt
|
|
454
|
+
self._cum = 0
|
|
455
|
+
return wire
|
|
456
|
+
|
|
457
|
+
def next(self, nxt: GenericSet) -> tuple[str, bool]:
|
|
458
|
+
"""Advance the session by one turn to ``nxt``.
|
|
459
|
+
|
|
460
|
+
Returns ``(wire, is_full)``: the wire to transmit and whether it is a full
|
|
461
|
+
re-anchor (``True``) or a delta (``False``). A schema change forces a full
|
|
462
|
+
(Section 10a.7). The held base becomes ``nxt`` either way. The wire is
|
|
463
|
+
byte-identical to calling ``encode_generic_full`` / ``encode_generic_delta``
|
|
464
|
+
directly. May raise on duplicate identity / no key (propagated from
|
|
465
|
+
:func:`diff_generic_sets`).
|
|
466
|
+
"""
|
|
467
|
+
self._turn += 1
|
|
468
|
+
|
|
469
|
+
# Schema change (or a fresh key) cannot be expressed as a delta -> full.
|
|
470
|
+
if nxt.key != self._base.key or list(self._base.fields) != list(nxt.fields):
|
|
471
|
+
return self._reanchor(nxt), True
|
|
472
|
+
|
|
473
|
+
d = diff_generic_sets(self._base, nxt)
|
|
474
|
+
delta_wire = encode_generic_delta(d)
|
|
475
|
+
|
|
476
|
+
if self._policy.mode == ReanchorMode.SIZE_GUARD:
|
|
477
|
+
reanchor = self._cum + _byte_len(delta_wire) >= _byte_len(
|
|
478
|
+
encode_generic_full(nxt, self._tool)
|
|
479
|
+
)
|
|
480
|
+
else: # FIXED_N
|
|
481
|
+
reanchor = self._turn % self._policy.n == 0
|
|
482
|
+
|
|
483
|
+
if reanchor:
|
|
484
|
+
return self._reanchor(nxt), True
|
|
485
|
+
self._base = nxt
|
|
486
|
+
self._cum += _byte_len(delta_wire)
|
|
487
|
+
return delta_wire, False
|
gcf/keyed_map.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""GCF keyed-tabular map encoding (SPEC 7.2a).
|
|
2
|
+
|
|
3
|
+
A JSON object whose values are all objects forming a losslessly-tabular set is
|
|
4
|
+
encoded as a keyed table `## [N:]{key,...}`: the shared value fields are declared
|
|
5
|
+
once in a header and each member is one positional row prefixed by its key. This
|
|
6
|
+
is the object-valued analogue of Section 7.4 tabular array encoding. It is
|
|
7
|
+
canonical (default-on): eligible maps always encode as keyed tables, with no
|
|
8
|
+
option to disable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def keyed_map_eligible(m: Any) -> tuple[list[str], list[Any], list[str], str] | None:
|
|
17
|
+
"""Report whether an object is a keyed map of objects that should render as a
|
|
18
|
+
keyed table `## [N:]{key,...}` (SPEC 7.2a.1). Returns the ordered member keys,
|
|
19
|
+
the corresponding value objects, the ordered value-field union, and the
|
|
20
|
+
key-column label, or None when the object is not eligible.
|
|
21
|
+
"""
|
|
22
|
+
if not isinstance(m, dict):
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
keys = list(m.keys())
|
|
26
|
+
values = [m[k] for k in keys]
|
|
27
|
+
|
|
28
|
+
# A keyed map requires at least two members: the form factors the shared value
|
|
29
|
+
# fields into one header, which only pays off across multiple members. A
|
|
30
|
+
# single-member map yields a one-row table the same size as a section, so keying
|
|
31
|
+
# it would change canonical output for every nested single-member object (e.g.
|
|
32
|
+
# `{"data": {...}}` wrappers) with no benefit. Single-member objects use ordinary
|
|
33
|
+
# encoding; a single-key wrapper of a multi-member map therefore defers, and the
|
|
34
|
+
# inner map is keyed at its own level (SPEC 7.2a.1).
|
|
35
|
+
if len(keys) < 2:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
# Every value must be an object; build the ordered field union.
|
|
39
|
+
seen: set[str] = set()
|
|
40
|
+
value_fields: list[str] = []
|
|
41
|
+
for v in values:
|
|
42
|
+
if not isinstance(v, dict):
|
|
43
|
+
return None # non-object value
|
|
44
|
+
for f in v:
|
|
45
|
+
if f not in seen:
|
|
46
|
+
seen.add(f)
|
|
47
|
+
value_fields.append(f)
|
|
48
|
+
if not value_fields:
|
|
49
|
+
return None # all-empty value objects
|
|
50
|
+
|
|
51
|
+
# A keyed header needs at least one value field that can be a tabular column.
|
|
52
|
+
# A field name containing ">" cannot be a column (SPEC 7.4.6.1.4); if every
|
|
53
|
+
# value field contains ">", the keyed form would have only the key column,
|
|
54
|
+
# which is invalid. Such a map uses Section 7.2 section encoding instead, the
|
|
55
|
+
# object analogue of an array falling back to expanded form.
|
|
56
|
+
if not any(">" not in f for f in value_fields):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
# Key-column label: "key", made unique by prepending "_" on collision.
|
|
60
|
+
key_label = "key"
|
|
61
|
+
while key_label in seen:
|
|
62
|
+
key_label = "_" + key_label
|
|
63
|
+
|
|
64
|
+
return keys, values, value_fields, key_label
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def keyed_rows_to_map(rows: list[Any], fields: list[str]) -> dict[str, Any]:
|
|
68
|
+
"""Reconstruct the map from decoded keyed-table rows: the first declared field
|
|
69
|
+
is the member key; the remaining fields form the value object (SPEC 7.2a.4).
|
|
70
|
+
"""
|
|
71
|
+
if len(fields) < 2:
|
|
72
|
+
raise ValueError("keyed_map: header must declare at least two fields")
|
|
73
|
+
key_label = fields[0]
|
|
74
|
+
out: dict[str, Any] = {}
|
|
75
|
+
for r in rows:
|
|
76
|
+
if not isinstance(r, dict):
|
|
77
|
+
raise ValueError("keyed_map: row is not an object")
|
|
78
|
+
if key_label not in r:
|
|
79
|
+
raise ValueError(f"keyed_map: row missing key column {key_label!r}")
|
|
80
|
+
kv = r[key_label]
|
|
81
|
+
ks = kv if isinstance(kv, str) else str(kv)
|
|
82
|
+
if ks in out:
|
|
83
|
+
raise ValueError(f"keyed_map: duplicate member key {ks!r}")
|
|
84
|
+
value = {k: v for k, v in r.items() if k != key_label}
|
|
85
|
+
out[ks] = value
|
|
86
|
+
return out
|
gcf/packroot.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""GCF graph-profile pack root (gcf-pack-root-v1, SPEC Section 10.2).
|
|
2
|
+
|
|
3
|
+
Computes the canonical pack root hash for a graph snapshot (symbols + edges).
|
|
4
|
+
Mirrors the gcf-go reference implementation (packroot.go); the shared conformance
|
|
5
|
+
fixtures (graph-pack-root/) hold both to identical bytes and hashes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
|
|
12
|
+
from .constants import KIND_ABBREV
|
|
13
|
+
from .scalar import format_number
|
|
14
|
+
from .types import Edge, Symbol
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pack_root(symbols: list[Symbol], edges: list[Edge]) -> str:
|
|
18
|
+
"""Canonical pack root for a graph snapshot (gcf-pack-root-v1, graph profile, 10.2).
|
|
19
|
+
|
|
20
|
+
Symbol and edge records are sorted independently by unsigned UTF-8 byte order,
|
|
21
|
+
then concatenated (all symbols, then all edges) and hashed with SHA-256. Two
|
|
22
|
+
implementations given the same logical graph MUST produce the same result.
|
|
23
|
+
"""
|
|
24
|
+
# Build canonical symbol records.
|
|
25
|
+
sym_records: list[str] = []
|
|
26
|
+
for s in symbols:
|
|
27
|
+
kind = KIND_ABBREV.get(s.kind, s.kind)
|
|
28
|
+
score = format_number(float(s.score))
|
|
29
|
+
sym_records.append(
|
|
30
|
+
f"S\t{kind}\t{s.qualified_name}\t{score}\t{s.provenance}\t{s.distance}\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Map qualified_name -> kind abbreviation for edge endpoint resolution.
|
|
34
|
+
sym_kind_map: dict[str, str] = {}
|
|
35
|
+
for s in symbols:
|
|
36
|
+
sym_kind_map[s.qualified_name] = KIND_ABBREV.get(s.kind, s.kind)
|
|
37
|
+
|
|
38
|
+
# Build canonical edge records.
|
|
39
|
+
edge_records: list[str] = []
|
|
40
|
+
for e in edges:
|
|
41
|
+
src_kind = sym_kind_map.get(e.source, "")
|
|
42
|
+
tgt_kind = sym_kind_map.get(e.target, "")
|
|
43
|
+
edge_records.append(
|
|
44
|
+
f"E\t{src_kind}\t{e.source}\t{tgt_kind}\t{e.target}\t{e.edge_type}\n"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Sort independently by unsigned UTF-8 byte order.
|
|
48
|
+
sym_records.sort(key=lambda r: r.encode("utf-8"))
|
|
49
|
+
edge_records.sort(key=lambda r: r.encode("utf-8"))
|
|
50
|
+
|
|
51
|
+
canonical = "".join(sym_records) + "".join(edge_records)
|
|
52
|
+
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
53
|
+
return "sha256:" + digest
|
gcf/scalar.py
CHANGED
|
@@ -6,10 +6,13 @@ import math
|
|
|
6
6
|
import re
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
# \Z (end of string), not $, so a trailing newline does not count as the end:
|
|
10
|
+
# in Python $ also matches just before a final \n, which would misclassify a
|
|
11
|
+
# string like "5\n" as a number or "W\n" as a bare key and break round-trip.
|
|
12
|
+
_JSON_NUMBER_RE = re.compile(r"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?\Z")
|
|
10
13
|
_NUMERIC_LIKE_RE = re.compile(r"^[+-]\.?\d|^\.\d|^0\d")
|
|
11
14
|
_INLINE_ARRAY_RE = re.compile(r"\[[^\]]*\]\s*:")
|
|
12
|
-
_BARE_KEY_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]
|
|
15
|
+
_BARE_KEY_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*\Z")
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
class _Missing:
|
|
@@ -29,6 +32,10 @@ def needs_quote(s: str) -> bool:
|
|
|
29
32
|
return True
|
|
30
33
|
if s in ("-", "~", "^", "true", "false"):
|
|
31
34
|
return True
|
|
35
|
+
# A value shaped like an inline-schema attachment marker (^{...}) would decode
|
|
36
|
+
# as an attachment and lose the string, so it must be quoted (SPEC 2.4).
|
|
37
|
+
if len(s) >= 3 and s[0] == "^" and s[1] == "{" and s[-1] == "}":
|
|
38
|
+
return True
|
|
32
39
|
if _JSON_NUMBER_RE.match(s):
|
|
33
40
|
return True
|
|
34
41
|
if _NUMERIC_LIKE_RE.match(s):
|
|
@@ -99,7 +106,8 @@ def format_number(f: float) -> str:
|
|
|
99
106
|
if math.isinf(f):
|
|
100
107
|
return "0"
|
|
101
108
|
if f == 0.0:
|
|
102
|
-
|
|
109
|
+
# Negative zero canonicalizes to 0 (SPEC 2.3.1): -0.0 equals 0.0 by value.
|
|
110
|
+
return "0"
|
|
103
111
|
a = abs(f)
|
|
104
112
|
if 1e-6 <= a < 1e21:
|
|
105
113
|
# Use repr for shortest round-trippable form.
|