Skip to content

Commit 13d2160

Browse files
fix: buffered graph encoder byte-matches reference; run graph-encode fixtures
Python's buffered graph encoder diverged from the reference on two axes, hidden because its conformance runner skipped all graph-encode fixtures: - symbols were emitted in input order, not sorted by distance then descending score (IDs are now assigned in output order); - the header always emitted budget=/tokens=/edges= even when zero. Both fixed to match Go/rust/ts/swift/kotlin. The conformance runner now builds a graph Payload and byte-compares graph-encode 001/002/003 (previously skipped), so this class of divergence is now caught. Full suite green.
1 parent 4d325ab commit 13d2160

3 files changed

Lines changed: 55 additions & 10 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+
- 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.
78
- 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.
89
- 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`).
910
- Decoder: reject an orphan positional inline body (a pipe-delimited line with no eligible `^{}` attachment-marker cell) instead of silently dropping it. The object-body parser previously skipped any unrecognized line, so a stray positional body (e.g. a second `Bob|b@t.com` after a row's one inline cell was filled) vanished with no error (silent data loss); now rejected per SPEC 16.5 (`orphan_inline_attachment`).

src/gcf/encode.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,37 @@ def encode(p: Payload) -> str:
1717
"""
1818
parts: list[str] = []
1919

20-
# Build symbol index for edge references.
20+
# Group symbols by distance (sorted by score descending within each group),
21+
# then assign local IDs in output order so they are sequential in the wire
22+
# (SPEC 16.1).
23+
groups = _group_by_distance(p.symbols)
2124
sym_index: dict[str, int] = {}
22-
for i, s in enumerate(p.symbols):
23-
sym_index[s.qualified_name] = i
25+
next_id = 0
26+
for _distance, g_symbols in groups:
27+
for s in g_symbols:
28+
sym_index[s.qualified_name] = next_id
29+
next_id += 1
2430

2531
# Count valid edges (both endpoints in symbol index).
2632
valid_edges = sum(
2733
1 for e in p.edges
2834
if e.source in sym_index and e.target in sym_index
2935
)
3036

31-
# Header line.
32-
header = f"GCF profile=graph tool={p.tool} budget={p.token_budget} tokens={p.tokens_used} symbols={len(p.symbols)} edges={valid_edges}"
37+
# Header line (SPEC 16.1): omit budget/tokens/edges when zero, matching the
38+
# reference encoder.
39+
header = f"GCF profile=graph tool={p.tool}"
40+
if p.token_budget > 0:
41+
header += f" budget={p.token_budget}"
42+
if p.tokens_used > 0:
43+
header += f" tokens={p.tokens_used}"
44+
header += f" symbols={len(p.symbols)}"
45+
if valid_edges > 0:
46+
header += f" edges={valid_edges}"
3347
if p.pack_root:
3448
header += f" pack_root={p.pack_root}"
3549
parts.append(header)
3650

37-
# Group symbols by distance.
38-
groups = _group_by_distance(p.symbols)
3951
group_names = ["targets", "related", "extended"]
4052

4153
for g_distance, g_symbols in groups:
@@ -79,15 +91,17 @@ def encode(p: Payload) -> str:
7991

8092

8193
def _group_by_distance(symbols: list[Symbol]) -> list[tuple[int, list[Symbol]]]:
82-
"""Group symbols by distance, preserving order."""
94+
"""Group symbols by distance ascending, sorted by score descending within each
95+
group (stable), matching the reference encoder so IDs are assigned canonically."""
8396
if not symbols:
8497
return []
8598

99+
ordered = sorted(symbols, key=lambda s: (s.distance, -s.score))
86100
groups: list[tuple[int, list[Symbol]]] = []
87101
current_distance: int | None = None
88102
current_symbols: list[Symbol] = []
89103

90-
for s in symbols:
104+
for s in ordered:
91105
if current_distance is None or current_distance != s.distance:
92106
if current_symbols:
93107
groups.append((current_distance, current_symbols)) # type: ignore[arg-type]

tests/test_conformance_v2.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,37 @@ def test_conformance(rel_path, data):
9595
if op == "encode":
9696
expected = data["expected"]
9797
if expected.startswith("GCF profile=graph"):
98-
pytest.skip("graph encode test")
98+
# Buffered graph encode (distinct from generic encode and the streaming
99+
# encoder). Build a graph Payload from the fixture and byte-compare.
100+
from gcf.encode import encode as encode_graph
101+
from gcf.types import Edge, Payload, Symbol
102+
103+
inp = data["input"]
104+
payload = Payload(
105+
tool=inp.get("tool", ""),
106+
token_budget=inp.get("tokenBudget", 0),
107+
tokens_used=inp.get("tokensUsed", 0),
108+
pack_root=inp.get("packRoot", ""),
109+
symbols=[
110+
Symbol(
111+
qualified_name=s["qualifiedName"],
112+
kind=s["kind"],
113+
score=s["score"],
114+
provenance=s["provenance"],
115+
distance=s.get("distance", 0),
116+
)
117+
for s in inp.get("symbols", [])
118+
],
119+
edges=[
120+
Edge(source=e["source"], target=e["target"], edge_type=e["edgeType"])
121+
for e in inp.get("edges", [])
122+
],
123+
)
124+
got = encode_graph(payload)
125+
assert got == expected, (
126+
f"graph encode mismatch:\n got: {got!r}\n exp: {expected!r}"
127+
)
128+
return
99129
got = encode_generic(data["input"])
100130
# v3 encoder produces different byte output for attachment/array fixtures.
101131
v3_affected = any(rel_path.startswith(d) for d in ("attachments/", "arrays/"))

0 commit comments

Comments
 (0)