Skip to content

Commit 6228b61

Browse files
feat: implement graph-profile PackRoot (gcf-pack-root-v1, SPEC 10.2)
Adds the graph content-addressing primitive (content-addressed sha256 over canonical, independently-sorted S/E records with abbreviated kinds and shortest-decimal scores), mirroring the Go reference byte-for-byte. The conformance runner now exercises the shared graph-pack-root fixtures, which it had been skipping - so this primitive was previously unimplemented and untested. Verified: canonical bytes match the fixtures' canonicalBytes exactly and the sha256 matches expected on all four fixtures, byte-identical across all six SDKs.
1 parent 13d2160 commit 6228b61

4 files changed

Lines changed: 80 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixes
66

7+
- Added the graph-profile PackRoot (`pack_root(symbols, edges)`, gcf-pack-root-v1, SPEC 10.2): the content-addressed sha256 over canonical, independently-sorted symbol/edge records, byte-identical to gcf-go/rust/typescript/swift/kotlin. The conformance runner now exercises the shared `graph-pack-root` fixtures, which it had been skipping (so this primitive was previously unimplemented and untested).
78
- Buffered graph encoder now matches the reference byte-for-byte: symbols are ordered by distance then descending score with local IDs assigned in output order, and the header omits `budget`/`tokens`/`edges` when zero (previously symbols kept input order and zero-valued header fields were always emitted). The conformance runner now exercises the shared `graph-encode` fixtures (001-003), which it had been skipping - which is how this divergence went uncaught.
89
- Buffered graph encoder: order edges by source ID, then target ID, then edge type (SPEC 16.1), instead of emitting them in input order. Decode-invariant (edges are a set) and does not affect `pack_root` (which sorts edge records independently), so no content addresses change. Pinned by shared fixture `graph-encode/003`. Streaming edges remain in producer-arrival order.
910
- Decoder: reject an orphan `.field` attachment (a `.field` whose name is neither a `^`-marked column of its row nor a `>`-containing field name, SPEC 7.4.6.1.4) instead of silently absorbing it as an undeclared extra field. Such a stray attachment previously decoded to a record no encoder produces, silently injecting a field onto the last-parsed row (a lossless round-trip hole); now rejected per SPEC 16.5 (`orphan_attachment`).

src/gcf/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
size_guard,
5757
DEFAULT_REANCHOR_N,
5858
)
59+
from .packroot import pack_root
5960
from .session import Session, encode_with_session
6061
from .decode_generic import decode_generic
6162
from .stream import StreamEncoder
@@ -84,6 +85,7 @@
8485
"GenericSet",
8586
"GenericDeltaPayload",
8687
"generic_pack_root",
88+
"pack_root",
8789
"diff_generic_sets",
8890
"encode_generic_full",
8991
"encode_generic_delta",

src/gcf/packroot.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

tests/test_conformance_v2.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,5 +285,29 @@ def _set(s):
285285
f"stream encode mismatch:\n got: {got!r}\n exp: {data['expected']!r}"
286286
)
287287

288+
elif op == "pack-root":
289+
from gcf.packroot import pack_root
290+
from gcf.types import Edge, Symbol
291+
292+
inp = data["input"]
293+
symbols = [
294+
Symbol(
295+
qualified_name=s["qualifiedName"],
296+
kind=s["kind"],
297+
score=s["score"],
298+
provenance=s["provenance"],
299+
distance=s.get("distance", 0),
300+
)
301+
for s in inp.get("symbols", [])
302+
]
303+
edges = [
304+
Edge(source=e["source"], target=e["target"], edge_type=e["edgeType"])
305+
for e in inp.get("edges", [])
306+
]
307+
got = pack_root(symbols, edges)
308+
assert got == data["expected"], (
309+
f"graph pack-root mismatch:\n got: {got}\n exp: {data['expected']}"
310+
)
311+
288312
else:
289313
pytest.skip(f"unknown operation: {op}")

0 commit comments

Comments
 (0)