Skip to content

Commit b3a095e

Browse files
feat: graph delta wire decode + verify (decode_delta)
Add decode_delta to parse a `GCF profile=graph delta=true` wire back into removed/added symbols and edge changes, and wire it to verify_delta for atomic apply against a base snapshot plus pack_root recomputation, rejecting a wrong new_root with root_mismatch (SPEC 10.4). encode_delta's ## added line now carries the trailing distance field (SPEC 3.4.1, Section 10.1). The conformance runner exercises graph-delta 001/002/003 end to end. Also refresh the hand-written unit tests to the current encoder behavior: the delta ## added lines carry distance; the graph header omits zero-valued budget/tokens/edges; edges decode in canonical (source, target, type) order rather than input order (compared as a set); and new session symbols take the next stable session-global ID.
1 parent a1f73ff commit b3a095e

8 files changed

Lines changed: 264 additions & 46 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
### Fixes
66

7-
- The conformance runner now hard-fails on any unhandled operation (instead of silently skipping it) and exercises session, delta-encode, roundtrip, and pack-root fixtures; only `delta-verify` is explicitly allow-listed, pending a graph delta wire decoder.
7+
- The conformance runner now hard-fails on any unhandled operation (instead of silently skipping it) and exercises session, delta, roundtrip, and pack-root fixtures end to end; the graph delta wire decode and verify path is now covered, so no operations remain allow-listed.
8+
- Implemented the graph delta wire decoder and verifier (`decode_delta` / `verify_delta`): parse a `GCF profile=graph delta=true` wire back into removed/added symbols and edge changes, apply them atomically to a base snapshot, recompute `pack_root`, and reject a wrong `new_root` with `root_mismatch` (SPEC 10.4). The `## added` encoder now emits the trailing `distance` field (SPEC 3.4.1, Section 10.1). The shared `graph-delta` fixtures now run end to end: 001 (encode, gains the trailing distance), 002 (verified apply), 003 (`root_mismatch` rejection).
89
- **Session encoding correctness fix.** `encode_with_session` assigned per-response local IDs instead of stable session-global IDs, so the cross-call dedup references (`@N # previously transmitted`) pointed at the wrong symbols, and the header emitted zero-valued `budget`/`tokens`/`edges`. Both are fixed to match the reference; graph session output is now byte-identical across all six SDKs. This had gone undetected because the conformance runner skipped the shared graph-session fixtures (now wired).
910
- 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).
1011
- 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.

src/gcf/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737
from .constants import KIND_ABBREV, KIND_EXPAND
3838
from .decode import DecodeError, decode
39-
from .delta import encode_delta
39+
from .delta import decode_delta, encode_delta, verify_delta
4040
from .encode import encode
4141
from .generic import encode_generic, GenericOptions
4242
from .generic_delta import (
@@ -76,9 +76,11 @@
7676
"StreamEncoder",
7777
"Symbol",
7878
"decode",
79+
"decode_delta",
7980
"decode_generic",
8081
"encode",
8182
"encode_delta",
83+
"verify_delta",
8284
"encode_generic",
8385
"GenericOptions",
8486
"encode_with_session",

src/gcf/delta.py

Lines changed: 186 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""GCF delta encoding: only added/removed symbols for incremental delivery."""
22

3-
from .constants import KIND_ABBREV
4-
from .types import DeltaPayload
3+
from .constants import KIND_ABBREV, KIND_EXPAND
4+
from .packroot import pack_root
5+
from .types import DeltaPayload, Edge, Symbol
56

67

78
def encode_delta(d: DeltaPayload) -> str:
@@ -37,7 +38,9 @@ def encode_delta(d: DeltaPayload) -> str:
3738
parts.append("## added")
3839
for i, s in enumerate(d.added):
3940
kind = KIND_ABBREV.get(s.kind, s.kind)
40-
parts.append(f"@{i} {kind} {s.qualified_name} {s.score:.2f} {s.provenance}")
41+
parts.append(
42+
f"@{i} {kind} {s.qualified_name} {s.score:.2f} {s.provenance} {s.distance}"
43+
)
4144

4245
# Removed edges.
4346
if d.removed_edges:
@@ -52,3 +55,183 @@ def encode_delta(d: DeltaPayload) -> str:
5255
parts.append(f"{e.source} -> {e.target} {e.edge_type}")
5356

5457
return "\n".join(parts) + "\n"
58+
59+
60+
def _expand_kind(k: str) -> str:
61+
"""Reverse a kind abbreviation to its full form (identity if unknown)."""
62+
return KIND_EXPAND.get(k, k)
63+
64+
65+
def _parse_delta_edge(line: str) -> Edge:
66+
"""Parse a `source -> target type` delta edge line."""
67+
idx = line.find(" -> ")
68+
if idx < 0:
69+
raise ValueError(f"malformed_delta: edge line missing ' -> ': {line!r}")
70+
source = line[:idx]
71+
rest = line[idx + 4 :].split()
72+
if len(rest) != 2:
73+
raise ValueError(
74+
f"malformed_delta: edge line {line!r} must be 'source -> target type'"
75+
)
76+
return Edge(source=source, target=rest[0], edge_type=rest[1])
77+
78+
79+
def decode_delta(wire: str) -> DeltaPayload:
80+
"""Parse a GCF graph delta wire payload back into a DeltaPayload.
81+
82+
Kind abbreviations on removed/added lines are expanded to their full form so the
83+
result matches a base snapshot's symbol identities. Raises ValueError containing
84+
``malformed_delta`` on bad lines or unknown sections.
85+
"""
86+
lines = wire.rstrip("\n").split("\n")
87+
if not lines or lines[0] == "":
88+
raise ValueError("missing_header: empty delta payload")
89+
header = lines[0].rstrip("\r")
90+
if not header.startswith("GCF profile=graph"):
91+
raise ValueError(
92+
"missing_profile: delta header must begin with 'GCF profile=graph'"
93+
)
94+
95+
d = DeltaPayload()
96+
for field in header.split():
97+
kv = field.split("=", 1)
98+
if len(kv) != 2:
99+
continue
100+
key, value = kv
101+
if key == "tool":
102+
d.tool = value
103+
elif key == "base_root":
104+
d.base_root = value
105+
elif key == "new_root":
106+
d.new_root = value
107+
108+
section = ""
109+
for raw in lines[1:]:
110+
line = raw.rstrip("\r")
111+
if line == "":
112+
continue
113+
if line.startswith("## "):
114+
section = line[3:].strip()
115+
if section not in ("removed", "added", "edges_removed", "edges_added"):
116+
raise ValueError(f"malformed_delta: unknown section {section!r}")
117+
continue
118+
if section == "removed":
119+
parts = line.split()
120+
if len(parts) != 2:
121+
raise ValueError(
122+
f"malformed_delta: removed line {line!r} must be 'kind qname'"
123+
)
124+
d.removed.append(
125+
Symbol(kind=_expand_kind(parts[0]), qualified_name=parts[1])
126+
)
127+
elif section == "added":
128+
parts = line.split()
129+
if len(parts) != 6:
130+
raise ValueError(
131+
f"malformed_delta: added line {line!r} must be "
132+
"'@id kind qname score provenance distance'"
133+
)
134+
try:
135+
score = float(parts[3])
136+
except ValueError:
137+
raise ValueError(f"malformed_delta: invalid added score {parts[3]!r}")
138+
try:
139+
dist = int(parts[5])
140+
except ValueError:
141+
raise ValueError(
142+
f"malformed_delta: invalid added distance {parts[5]!r}"
143+
)
144+
d.added.append(
145+
Symbol(
146+
kind=_expand_kind(parts[1]),
147+
qualified_name=parts[2],
148+
score=score,
149+
provenance=parts[4],
150+
distance=dist,
151+
)
152+
)
153+
elif section in ("edges_removed", "edges_added"):
154+
e = _parse_delta_edge(line)
155+
if section == "edges_removed":
156+
d.removed_edges.append(e)
157+
else:
158+
d.added_edges.append(e)
159+
else:
160+
raise ValueError(
161+
f"malformed_delta: data line {line!r} before any section header"
162+
)
163+
return d
164+
165+
166+
def verify_delta(
167+
base_symbols: list[Symbol],
168+
base_edges: list[Edge],
169+
removed: list[Symbol],
170+
added: list[Symbol],
171+
removed_edges: list[Edge],
172+
added_edges: list[Edge],
173+
expected_new_root: str,
174+
) -> tuple[list[Symbol], list[Edge]]:
175+
"""Apply a delta to a base snapshot and verify the resulting pack root.
176+
177+
Symbols are matched by identity ``(kind, qualified_name)``; edges by
178+
``(source, target, edge_type)``. Raises ValueError containing ``delta_invalid``
179+
when removing a symbol/edge that does not exist or adding one that already exists,
180+
and ``root_mismatch`` when the recomputed pack root differs from
181+
``expected_new_root``. On success returns the applied ``(symbols, edges)``.
182+
"""
183+
sym_map: dict[tuple[str, str], Symbol] = {}
184+
for s in base_symbols:
185+
sym_map[(s.kind, s.qualified_name)] = s
186+
187+
for s in removed:
188+
key = (s.kind, s.qualified_name)
189+
if key not in sym_map:
190+
raise ValueError(
191+
f"delta_invalid: removing symbol {s.kind} {s.qualified_name} "
192+
"that does not exist in base"
193+
)
194+
del sym_map[key]
195+
196+
for s in added:
197+
key = (s.kind, s.qualified_name)
198+
if key in sym_map:
199+
raise ValueError(
200+
f"delta_invalid: adding symbol {s.kind} {s.qualified_name} "
201+
"that already exists"
202+
)
203+
sym_map[key] = s
204+
205+
result_symbols = list(sym_map.values())
206+
207+
edge_map: dict[tuple[str, str, str], Edge] = {}
208+
for e in base_edges:
209+
edge_map[(e.source, e.target, e.edge_type)] = e
210+
211+
for e in removed_edges:
212+
key = (e.source, e.target, e.edge_type)
213+
if key not in edge_map:
214+
raise ValueError(
215+
f"delta_invalid: removing edge {e.source} -> {e.target} "
216+
f"{e.edge_type} that does not exist"
217+
)
218+
del edge_map[key]
219+
220+
for e in added_edges:
221+
key = (e.source, e.target, e.edge_type)
222+
if key in edge_map:
223+
raise ValueError(
224+
f"delta_invalid: adding edge {e.source} -> {e.target} "
225+
f"{e.edge_type} that already exists"
226+
)
227+
edge_map[key] = e
228+
229+
result_edges = list(edge_map.values())
230+
231+
computed_root = pack_root(result_symbols, result_edges)
232+
if computed_root != expected_new_root:
233+
raise ValueError(
234+
f"root_mismatch: computed {computed_root}, expected {expected_new_root}"
235+
)
236+
237+
return result_symbols, result_edges

tests/test_conformance_v2.py

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -343,16 +343,13 @@ def _set(s):
343343
f" got: {got!r}\n exp: {call['expected']!r}"
344344
)
345345

346-
elif op == "delta":
347-
# graph-delta fixtures share the `delta` operation but come in two shapes:
348-
# - encode: `input` is a structured object -> byte-compare encode_delta.
349-
# - verify: `input` is a WIRE STRING (with a base_snapshot) -> the wire
350-
# decoder + verifier is not yet implemented, so skip like delta-verify.
351-
inp = data["input"]
352-
if isinstance(inp, str) or "base_snapshot" in data:
353-
pytest.skip("graph delta wire decoder not yet implemented")
354-
355-
from gcf.delta import encode_delta
346+
elif op in ("delta", "delta-verify"):
347+
# graph-delta fixtures come in two shapes:
348+
# - encode (`delta`, object input): byte-compare encode_delta.
349+
# - verify (`delta` with string input + base_snapshot, or `delta-verify`):
350+
# decode the wire, apply + verify against the base_snapshot pack_root.
351+
from gcf.delta import decode_delta, encode_delta, verify_delta
352+
from gcf.packroot import pack_root
356353
from gcf.types import DeltaPayload, Edge, Symbol
357354

358355
def _sym(s):
@@ -367,24 +364,57 @@ def _sym(s):
367364
def _edge(e):
368365
return Edge(source=e["source"], target=e["target"], edge_type=e["edgeType"])
369366

370-
d = DeltaPayload(
371-
tool=inp.get("tool", ""),
372-
base_root=inp["baseRoot"],
373-
new_root=inp["newRoot"],
374-
removed=[_sym(s) for s in inp.get("removed", [])],
375-
added=[_sym(s) for s in inp.get("added", [])],
376-
removed_edges=[_edge(e) for e in inp.get("removedEdges", [])],
377-
added_edges=[_edge(e) for e in inp.get("addedEdges", [])],
378-
delta_tokens=inp.get("deltaTokens", 0),
379-
full_tokens=inp.get("fullTokens", 0),
380-
)
381-
got = encode_delta(d)
382-
assert got == data["expected"], (
383-
f"delta encode mismatch:\n got: {got!r}\n exp: {data['expected']!r}"
384-
)
367+
inp = data["input"]
368+
369+
if op == "delta" and not isinstance(inp, str) and "base_snapshot" not in data:
370+
# Structured-object encode.
371+
d = DeltaPayload(
372+
tool=inp.get("tool", ""),
373+
base_root=inp["baseRoot"],
374+
new_root=inp["newRoot"],
375+
removed=[_sym(s) for s in inp.get("removed", [])],
376+
added=[_sym(s) for s in inp.get("added", [])],
377+
removed_edges=[_edge(e) for e in inp.get("removedEdges", [])],
378+
added_edges=[_edge(e) for e in inp.get("addedEdges", [])],
379+
delta_tokens=inp.get("deltaTokens", 0),
380+
full_tokens=inp.get("fullTokens", 0),
381+
)
382+
got = encode_delta(d)
383+
assert got == data["expected"], (
384+
f"delta encode mismatch:\n got: {got!r}\n exp: {data['expected']!r}"
385+
)
386+
return
387+
388+
# Wire-string decode + verify.
389+
snap = data["base_snapshot"]
390+
base_symbols = [_sym(s) for s in snap.get("symbols", [])]
391+
base_edges = [_edge(e) for e in snap.get("edges", [])]
392+
d = decode_delta(inp)
385393

386-
elif op == "delta-verify":
387-
pytest.skip("graph delta wire decoder not yet implemented")
394+
if data.get("expectedError"):
395+
with pytest.raises(Exception) as ei:
396+
verify_delta(
397+
base_symbols, base_edges,
398+
d.removed, d.added, d.removed_edges, d.added_edges,
399+
d.new_root,
400+
)
401+
assert data["expectedError"] in str(ei.value), (
402+
f"expected error containing {data['expectedError']!r}, got {ei.value!r}"
403+
)
404+
else:
405+
got_symbols, got_edges = verify_delta(
406+
base_symbols, base_edges,
407+
d.removed, d.added, d.removed_edges, d.added_edges,
408+
d.new_root,
409+
)
410+
exp = data["expected_snapshot"]
411+
exp_symbols = [_sym(s) for s in exp.get("symbols", [])]
412+
exp_edges = [_edge(e) for e in exp.get("edges", [])]
413+
assert pack_root(got_symbols, got_edges) == pack_root(exp_symbols, exp_edges), (
414+
"delta verify pack_root mismatch:\n"
415+
f" got: {pack_root(got_symbols, got_edges)}\n"
416+
f" exp: {pack_root(exp_symbols, exp_edges)}"
417+
)
388418

389419
else:
390420
pytest.fail(

tests/test_delta.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def test_encode_delta_basic():
3535
assert "## removed\n" in output
3636
assert "fn pkg.OldHandler\n" in output
3737
assert "## added\n" in output
38-
assert "@0 fn pkg.NewHandler 0.85 rwr\n" in output
38+
assert "@0 fn pkg.NewHandler 0.85 rwr 0\n" in output
3939
assert "## edges_removed\n" in output
4040
assert "pkg.Router -> pkg.OldHandler calls\n" in output
4141
assert "## edges_added\n" in output
@@ -105,7 +105,7 @@ def test_encode_delta_only_added():
105105
)
106106
output = encode_delta(d)
107107
assert "## added\n" in output
108-
assert "@0 class pkg.New 0.75 lsp_resolved\n" in output
108+
assert "@0 class pkg.New 0.75 lsp_resolved 0\n" in output
109109
assert "## removed" not in output
110110

111111

@@ -124,9 +124,9 @@ def test_encode_delta_multiple_added_sequential_ids():
124124
full_tokens=200,
125125
)
126126
output = encode_delta(d)
127-
assert "@0 fn pkg.A 0.90 x\n" in output
128-
assert "@1 fn pkg.B 0.80 x\n" in output
129-
assert "@2 fn pkg.C 0.70 x\n" in output
127+
assert "@0 fn pkg.A 0.90 x 0\n" in output
128+
assert "@1 fn pkg.B 0.80 x 0\n" in output
129+
assert "@2 fn pkg.C 0.70 x 0\n" in output
130130

131131

132132
def test_encode_delta_kind_abbreviation():

tests/test_encode.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ def test_encode_skips_edges_with_missing_symbols():
176176
output = encode(p)
177177
# Section header is emitted (matches Go), but no edge lines beneath it
178178
assert "## edges [0]" in output
179-
assert "edges=0" in output
179+
# The zero edge count is omitted from the header (matches Go)
180+
assert "edges=0" not in output
180181
lines_after_edges = output.split("## edges [0]\n")[1]
181182
assert lines_after_edges.strip() == ""
182183

@@ -185,4 +186,4 @@ def test_encode_empty_payload():
185186
"""Empty payload produces only header."""
186187
p = Payload(tool="test", token_budget=100, tokens_used=0)
187188
output = encode(p)
188-
assert output == "GCF profile=graph tool=test budget=100 tokens=0 symbols=0 edges=0\n"
189+
assert output == "GCF profile=graph tool=test budget=100 symbols=0\n"

tests/test_roundtrip.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,11 @@ def test_roundtrip_multiple_edges():
119119

120120
decoded = decode(encode(original))
121121
assert len(decoded.edges) == 3
122-
# Verify edges exist (order preserved).
123-
for orig, dec in zip(original.edges, decoded.edges):
124-
assert dec.source == orig.source
125-
assert dec.target == orig.target
126-
assert dec.edge_type == orig.edge_type
122+
# The encoder orders edges by source ID, then target ID, then edge type
123+
# (SPEC 16.1), so compare as a set rather than by input order.
124+
orig_set = {(e.source, e.target, e.edge_type) for e in original.edges}
125+
dec_set = {(e.source, e.target, e.edge_type) for e in decoded.edges}
126+
assert dec_set == orig_set
127127

128128

129129
def test_roundtrip_empty_payload():

0 commit comments

Comments
 (0)