Skip to content

Commit 93770bd

Browse files
feat: v1.0.0 SPEC v2.0 implementation
126/133 conformance fixtures passing (7 skipped). 40M property-based round-trips with zero failures. New: scalar.py, test_conformance_v2.py, test_roundtrip_v2.py. Rewritten: generic.py, decode_generic.py. Updated: encode.py, decode.py, session.py, delta.py, stream.py, stream_generic.py (profile=graph, ##! summary, v2.0 error categories).
1 parent 9f3f4c5 commit 93770bd

18 files changed

Lines changed: 1297 additions & 356 deletions

.github/workflows/ci.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,17 @@ jobs:
1414
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
1515
steps:
1616
- uses: actions/checkout@v4
17+
- uses: actions/checkout@v4
18+
with:
19+
repository: blackwell-systems/gcf
20+
path: gcf
1721
- uses: actions/setup-python@v5
1822
with:
1923
python-version: ${{ matrix.python-version }}
2024
- run: pip install -e ".[dev]" || pip install -e . && pip install pytest
21-
- run: pytest tests/ -v
25+
- name: Unit tests
26+
run: pytest tests/ -v --ignore=tests/test_conformance_v2.py --ignore=tests/test_roundtrip_v2.py
27+
- name: Conformance tests (133 fixtures)
28+
run: pytest tests/test_conformance_v2.py -v
29+
- name: Property-based round-trip (100K random + 100K adversarial)
30+
run: pytest tests/test_roundtrip_v2.py -v

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

3+
## v1.0.0 (2026-06-10)
4+
5+
SPEC v2.0 implementation. 126/133 conformance fixtures passing (7 skipped: session, delta, binary UTF-8, negative zero, graph encode). 40M property-based round-trips with zero failures.
6+
7+
### Breaking changes from v0.5.0
8+
9+
- `encode_generic` emits `GCF profile=generic` header
10+
- `decode_generic` requires `GCF profile=` header
11+
- Strings colliding with typed literals are quoted
12+
- Full JSON string escaping and number grammar
13+
- `-` for null, `~` for absent, `^` for nested attachments
14+
- `##! summary` trailer replaces `## _summary`
15+
- Graph encoder emits `profile=graph`
16+
17+
### New
18+
19+
- `scalar.py`: common scalar grammar (quoting, escaping, parsing, number formatting)
20+
- Conformance test runner (133 fixtures)
21+
- Property-based round-trip tests (40M verified, configurable via `GCF_ITERATIONS`)
22+
323
## v0.5.0 (2026-06-06)
424

525
- `GenericStreamEncoder`: zero-buffering tabular streaming encode (begin_array/write_row/end_array/write_kv/write_section/write_inline_array)

src/gcf/decode.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ def decode(input_text: str) -> Payload:
3535
_parse_header(header[4:], p)
3636

3737
if not p.tool:
38-
raise DecodeError("header missing required 'tool' field")
38+
raise DecodeError("missing_tool: header missing required 'tool' field")
39+
40+
# Detect delta mode.
41+
is_delta = "delta=true" in header
42+
valid_delta_sections = {"removed", "added", "edges_removed", "edges_added"}
3943

4044
# Parse body: symbols and edges.
4145
symbols: list[Symbol] = []
@@ -48,13 +52,19 @@ def decode(input_text: str) -> Payload:
4852
if not line:
4953
continue
5054

55+
# Skip ##! summary trailer.
56+
if line.startswith("##! "):
57+
continue
58+
5159
# Group header.
5260
if line.startswith("## "):
5361
group = line[3:]
5462
# Strip bracket suffix: "edges [200]" -> "edges"
5563
bracket_idx = group.find(" [")
5664
if bracket_idx >= 0:
5765
group = group[:bracket_idx]
66+
if is_delta and group not in valid_delta_sections:
67+
raise DecodeError(f"malformed_delta: invalid delta section {group!r}")
5868
in_edges = group == "edges"
5969
if not in_edges:
6070
if group == "targets":
@@ -113,19 +123,19 @@ def _parse_header(fields: str, p: Payload) -> None:
113123
def _parse_symbol_line(line: str, distance: int) -> tuple[Symbol, int]:
114124
"""Parse a symbol line into a Symbol and its local ID."""
115125
if not line.startswith("@"):
116-
raise DecodeError(f"expected symbol line starting with @, got {line!r}")
126+
raise DecodeError(f"invalid_node_line: expected symbol line starting with @, got {line!r}")
117127

118128
parts = line.split()
119129
if len(parts) < 5:
120130
raise DecodeError(
121-
f"symbol line needs at least 5 fields, got {len(parts)} in {line!r}"
131+
f"invalid_node_line: symbol line needs at least 5 fields, got {len(parts)} in {line!r}"
122132
)
123133

124134
id_str = parts[0][1:] # strip @
125135
try:
126136
sym_id = int(id_str)
127137
except ValueError as e:
128-
raise DecodeError(f"invalid symbol id {id_str!r}: {e}") from e
138+
raise DecodeError(f"invalid_symbol_id: invalid symbol id {id_str!r}: {e}") from e
129139

130140
kind = parts[1]
131141
kind = KIND_EXPAND.get(kind, kind)
@@ -135,7 +145,7 @@ def _parse_symbol_line(line: str, distance: int) -> tuple[Symbol, int]:
135145
try:
136146
score = float(parts[3])
137147
except ValueError as e:
138-
raise DecodeError(f"invalid score {parts[3]!r}: {e}") from e
148+
raise DecodeError(f"invalid_score: invalid score {parts[3]!r}: {e}") from e
139149

140150
provenance = parts[4]
141151

@@ -157,7 +167,7 @@ def _parse_edge_line(line: str, sym_by_id: dict[int, Symbol]) -> Edge:
157167
ref = parts[0]
158168
lt_idx = ref.find("<")
159169
if lt_idx < 0:
160-
raise DecodeError(f"edge line missing '<' separator in {ref!r}")
170+
raise DecodeError(f"invalid_edge_syntax: edge line missing '<' separator in {ref!r}")
161171

162172
target_id_str = ref[1:lt_idx] # strip leading @
163173
source_id_str = ref[lt_idx + 2:] # strip <@
@@ -176,7 +186,7 @@ def _parse_edge_line(line: str, sym_by_id: dict[int, Symbol]) -> Edge:
176186
source_sym = sym_by_id.get(source_id)
177187
if target_sym is None or source_sym is None:
178188
raise DecodeError(
179-
f"edge references unknown symbol id(s): target={target_id} source={source_id}"
189+
f"unknown_edge_reference: edge references unknown symbol id(s): target={target_id} source={source_id}"
180190
)
181191

182192
edge_type = parts[1]

0 commit comments

Comments
 (0)