gcf-python 2.2.2__py3-none-any.whl → 2.4.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 CHANGED
@@ -36,9 +36,27 @@ Specification: https://github.com/blackwell-systems/gcf
36
36
 
37
37
  from .constants import KIND_ABBREV, KIND_EXPAND
38
38
  from .decode import DecodeError, decode
39
- from .delta import encode_delta
39
+ from .delta import decode_delta, encode_delta, verify_delta
40
40
  from .encode import encode
41
41
  from .generic import encode_generic, GenericOptions
42
+ from .generic_delta import (
43
+ GenericSet,
44
+ GenericDeltaPayload,
45
+ generic_pack_root,
46
+ diff_generic_sets,
47
+ encode_generic_full,
48
+ encode_generic_delta,
49
+ decode_generic_full,
50
+ decode_generic_delta,
51
+ verify_generic_delta,
52
+ GenericDeltaSession,
53
+ ReanchorPolicy,
54
+ ReanchorMode,
55
+ fixed_n,
56
+ size_guard,
57
+ DEFAULT_REANCHOR_N,
58
+ )
59
+ from .packroot import pack_root
42
60
  from .session import Session, encode_with_session
43
61
  from .decode_generic import decode_generic
44
62
  from .stream import StreamEncoder
@@ -58,12 +76,30 @@ __all__ = [
58
76
  "StreamEncoder",
59
77
  "Symbol",
60
78
  "decode",
79
+ "decode_delta",
61
80
  "decode_generic",
62
81
  "encode",
63
82
  "encode_delta",
83
+ "verify_delta",
64
84
  "encode_generic",
65
85
  "GenericOptions",
66
86
  "encode_with_session",
87
+ "GenericSet",
88
+ "GenericDeltaPayload",
89
+ "generic_pack_root",
90
+ "pack_root",
91
+ "diff_generic_sets",
92
+ "encode_generic_full",
93
+ "encode_generic_delta",
94
+ "decode_generic_full",
95
+ "decode_generic_delta",
96
+ "verify_generic_delta",
97
+ "GenericDeltaSession",
98
+ "ReanchorPolicy",
99
+ "ReanchorMode",
100
+ "fixed_n",
101
+ "size_guard",
102
+ "DEFAULT_REANCHOR_N",
67
103
  ]
68
104
 
69
- __version__ = "2.2.2"
105
+ __version__ = "2.3.0"
gcf/decode_generic.py CHANGED
@@ -177,7 +177,14 @@ def _parse_object_body(
177
177
  i += 1
178
178
  continue
179
179
 
180
- i += 1
180
+ # An object-body line that is not a `## ` section, a `key=value` field, or
181
+ # an inline array is not valid content and MUST NOT be silently skipped
182
+ # (that dropped data, a lossless round-trip hole). A pipe-delimited line is
183
+ # a stray positional inline body with no eligible `^` cell (SPEC 16.5,
184
+ # orphan_inline_attachment); any other unrecognized line is likewise rejected.
185
+ if "|" in content:
186
+ raise ValueError(f"orphan_inline_attachment: {content}")
187
+ raise ValueError(f"invalid_line: unexpected content in object body: {content!r}")
181
188
  return i - start
182
189
 
183
190
 
@@ -521,6 +528,11 @@ def _parse_tabular_body(
521
528
  if row_has_id:
522
529
  inline_idx = 0
523
530
 
531
+ # Columns that carry a `^` marker cell in this row legitimately expect
532
+ # a `.field` body. Any other `.field` is an orphan (Section 16.5) unless
533
+ # its name contains `>` (the flatten-fallback attachment, Section 7.4.6.1.4).
534
+ expected_att = set(traditional_att_fields) | set(inline_att_fields)
535
+
524
536
  while i < len(lines):
525
537
  a_line = lines[i]
526
538
  a_content: str | None = None
@@ -541,6 +553,14 @@ def _parse_tabular_body(
541
553
  att_name, after_name = _parse_attachment_name(rest)
542
554
  after_name_stripped = after_name.lstrip()
543
555
 
556
+ # Orphan attachment: a `.field` with no matching `^` cell in this
557
+ # row is only legitimate for a `>`-named field (Section 7.4.6.1.4).
558
+ # Any other unmatched attachment is rejected rather than silently
559
+ # injected as an undeclared extra field, which would decode to a
560
+ # record no encoder produces (Section 16.5, lossless round-trip).
561
+ if att_name not in expected_att and ">" not in att_name:
562
+ raise ValueError(f"orphan_attachment: {att_name}")
563
+
544
564
  # Prefixed inline data.
545
565
  ifs = inline_schemas.get(att_name)
546
566
  if ifs and not after_name_stripped.startswith("{}") and not after_name_stripped.startswith("["):
gcf/delta.py CHANGED
@@ -1,7 +1,8 @@
1
1
  """GCF delta encoding: only added/removed symbols for incremental delivery."""
2
2
 
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
5
6
 
6
7
 
7
8
  def encode_delta(d: DeltaPayload) -> str:
@@ -37,7 +38,9 @@ def encode_delta(d: DeltaPayload) -> str:
37
38
  parts.append("## added")
38
39
  for i, s in enumerate(d.added):
39
40
  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
+ )
41
44
 
42
45
  # Removed edges.
43
46
  if d.removed_edges:
@@ -52,3 +55,183 @@ def encode_delta(d: DeltaPayload) -> str:
52
55
  parts.append(f"{e.source} -> {e.target} {e.edge_type}")
53
56
 
54
57
  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
gcf/encode.py CHANGED
@@ -17,10 +17,16 @@ def encode(p: Payload) -> str:
17
17
  """
18
18
  parts: list[str] = []
19
19
 
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)
21
24
  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
24
30
 
25
31
  # Count valid edges (both endpoints in symbol index).
26
32
  valid_edges = sum(
@@ -28,14 +34,20 @@ def encode(p: Payload) -> str:
28
34
  if e.source in sym_index and e.target in sym_index
29
35
  )
30
36
 
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}"
33
47
  if p.pack_root:
34
48
  header += f" pack_root={p.pack_root}"
35
49
  parts.append(header)
36
50
 
37
- # Group symbols by distance.
38
- groups = _group_by_distance(p.symbols)
39
51
  group_names = ["targets", "related", "extended"]
40
52
 
41
53
  for g_distance, g_symbols in groups:
@@ -52,17 +64,25 @@ def encode(p: Payload) -> str:
52
64
  kind = KIND_ABBREV.get(s.kind, s.kind)
53
65
  parts.append(f"@{idx} {kind} {s.qualified_name} {s.score:.2f} {s.provenance}")
54
66
 
55
- # Edges section.
67
+ # Edges section. Order edges by source ID then target ID (then edge type
68
+ # for parallel edges) so the wire is canonical regardless of the order
69
+ # edges were provided (SPEC 16.1). Edge reordering is decode-invariant
70
+ # (edges are a set) and does not affect pack_root, which sorts edge records
71
+ # independently.
56
72
  if p.edges:
57
- edge_lines: list[str] = []
73
+ resolved: list[tuple[int, int, str, str]] = []
58
74
  for e in p.edges:
59
75
  src_idx = sym_index.get(e.source)
60
76
  tgt_idx = sym_index.get(e.target)
61
77
  if src_idx is None or tgt_idx is None:
62
78
  continue
63
- line = f"@{tgt_idx}<@{src_idx} {e.edge_type}"
64
- if e.status and e.status != "unchanged":
65
- line += f" {e.status}"
79
+ resolved.append((src_idx, tgt_idx, e.edge_type, e.status))
80
+ resolved.sort(key=lambda r: (r[0], r[1], r[2]))
81
+ edge_lines: list[str] = []
82
+ for src_idx, tgt_idx, edge_type, status in resolved:
83
+ line = f"@{tgt_idx}<@{src_idx} {edge_type}"
84
+ if status and status != "unchanged":
85
+ line += f" {status}"
66
86
  edge_lines.append(line)
67
87
  parts.append(f"## edges [{len(edge_lines)}]")
68
88
  parts.extend(edge_lines)
@@ -71,15 +91,17 @@ def encode(p: Payload) -> str:
71
91
 
72
92
 
73
93
  def _group_by_distance(symbols: list[Symbol]) -> list[tuple[int, list[Symbol]]]:
74
- """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."""
75
96
  if not symbols:
76
97
  return []
77
98
 
99
+ ordered = sorted(symbols, key=lambda s: (s.distance, -s.score))
78
100
  groups: list[tuple[int, list[Symbol]]] = []
79
101
  current_distance: int | None = None
80
102
  current_symbols: list[Symbol] = []
81
103
 
82
- for s in symbols:
104
+ for s in ordered:
83
105
  if current_distance is None or current_distance != s.distance:
84
106
  if current_symbols:
85
107
  groups.append((current_distance, current_symbols)) # type: ignore[arg-type]
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/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/session.py CHANGED
@@ -77,22 +77,32 @@ def encode_with_session(p: Payload, sess: Session | None = None) -> str:
77
77
 
78
78
  parts: list[str] = []
79
79
 
80
- # Build local ID mapping for this response.
80
+ # Build session-stable ID mapping (matching the reference encoder):
81
+ # previously transmitted symbols keep their existing session-global ID;
82
+ # new symbols get the next available session IDs in payload order. These
83
+ # are NOT per-call local indices, so IDs stay stable across calls within a
84
+ # session (see graph-session conformance fixtures).
81
85
  local_index: dict[str, int] = {}
82
- for i, s in enumerate(p.symbols):
83
- local_index[s.qualified_name] = i
84
-
85
- # Count valid edges.
86
- valid_edges = sum(
87
- 1 for e in p.edges
88
- if e.source in local_index and e.target in local_index
89
- )
90
-
91
- # Header with session=true marker.
92
- header = (
93
- f"GCF profile=graph tool={p.tool} budget={p.token_budget} tokens={p.tokens_used} "
94
- f"symbols={len(p.symbols)} edges={valid_edges} session=true"
95
- )
86
+ for s in p.symbols:
87
+ if sess.transmitted(s.qualified_name):
88
+ local_index[s.qualified_name] = sess.get_id(s.qualified_name)
89
+ next_new = sess.size()
90
+ for s in p.symbols:
91
+ if not sess.transmitted(s.qualified_name):
92
+ local_index[s.qualified_name] = next_new
93
+ next_new += 1
94
+
95
+ # Header with session=true marker. Omit budget/tokens/edges when zero,
96
+ # matching the reference encoder.
97
+ header = f"GCF profile=graph tool={p.tool}"
98
+ if p.token_budget > 0:
99
+ header += f" budget={p.token_budget}"
100
+ if p.tokens_used > 0:
101
+ header += f" tokens={p.tokens_used}"
102
+ header += f" symbols={len(p.symbols)}"
103
+ if p.edges:
104
+ header += f" edges={len(p.edges)}"
105
+ header += " session=true"
96
106
  if p.pack_root:
97
107
  header += f" pack_root={p.pack_root}"
98
108
  parts.append(header)
@@ -128,7 +138,7 @@ def encode_with_session(p: Payload, sess: Session | None = None) -> str:
128
138
 
129
139
  # Edges section.
130
140
  if p.edges:
131
- parts.append(f"## edges [{valid_edges}]")
141
+ parts.append(f"## edges [{len(p.edges)}]")
132
142
  for e in p.edges:
133
143
  src_idx = local_index.get(e.source)
134
144
  tgt_idx = local_index.get(e.target)
gcf/stream.py CHANGED
@@ -38,8 +38,10 @@ class StreamEncoder:
38
38
  tokens_used: int = 0,
39
39
  pack_root: str = "",
40
40
  session: bool = False,
41
+ labeled_trailer_counts: bool = False,
41
42
  ) -> None:
42
43
  self._w = writer
44
+ self._labeled_trailer_counts = labeled_trailer_counts
43
45
  self._lock = threading.Lock()
44
46
  self._sym_index: dict[str, int] = {}
45
47
  self._next_id = 0
@@ -122,22 +124,29 @@ class StreamEncoder:
122
124
  def close(self) -> None:
123
125
  """Emit ##! summary trailer with final counts."""
124
126
  with self._lock:
125
- counts: list[str] = []
126
- group_order = ["targets", "related", "extended"]
127
-
128
- for g in group_order:
129
- c = self._group_counts.get(g, 0)
130
- if c > 0:
131
- counts.append(str(c))
132
- for g, c in self._group_counts.items():
133
- if g not in group_order and c > 0:
134
- counts.append(str(c))
135
- if self._edge_count > 0:
136
- counts.append(str(self._edge_count))
127
+ # Build label:count sections, then either emit as-is (labeled form,
128
+ # SPEC 8.4.1) or strip to values (default positional form).
129
+ # One entry per non-empty distance group in group-header emission order
130
+ # (SPEC 8.4). The dict preserves insertion order, which is the order the
131
+ # group headers were emitted, so the trailer is deterministic and matches
132
+ # the section order (including distance_N groups) across all SDKs.
133
+ sections: list[str] = [
134
+ f"{g}:{c}" for g, c in self._group_counts.items() if c > 0
135
+ ]
136
+ # The edge count is always the last counts entry, even when 0 (SPEC
137
+ # 8.4, 8.4.1): it keeps the positional form unambiguous and anchors the
138
+ # labeled form (minimal counts=edges:0). Zero-count distance groups are
139
+ # omitted, but edges is not.
140
+ sections.append(f"edges:{self._edge_count}")
141
+
142
+ if self._labeled_trailer_counts:
143
+ counts_str = ",".join(sections)
144
+ else:
145
+ counts_str = ",".join(s.split(":", 1)[1] for s in sections)
137
146
 
138
147
  self._w.write(
139
148
  f"##! summary symbols={self._next_id} edges={self._edge_count}"
140
- f" counts={','.join(counts)}\n"
149
+ f" counts={counts_str}\n"
141
150
  )
142
151
 
143
152
  @property
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gcf-python
3
- Version: 2.2.2
4
- Summary: The AI-native wire format for structured data. 50-92% fewer tokens than JSON. 100% comprehension on every frontier model. Zero dependencies.
3
+ Version: 2.4.0
4
+ Summary: The AI-native wire format for structured data. 50-92% fewer tokens than JSON, with multi-turn delta encoding for agent loops. 100% comprehension on every frontier model. Zero dependencies.
5
5
  Project-URL: Homepage, https://github.com/blackwell-systems/gcf-python
6
- Project-URL: Documentation, https://blackwell-systems.github.io/gcf/
6
+ Project-URL: Documentation, https://gcformat.com/
7
7
  Project-URL: Specification, https://github.com/blackwell-systems/gcf
8
8
  Author: Blackwell Systems
9
9
  License: MIT
10
10
  License-File: LICENSE
11
- Keywords: gcf,graph,llm,mcp,token-efficient,wire-format
12
- Classifier: Development Status :: 4 - Beta
11
+ Keywords: agents,delta,gcf,graph,llm,mcp,multi-turn,token-efficient,wire-format
12
+ Classifier: Development Status :: 5 - Production/Stable
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: License :: OSI Approved :: MIT License
15
15
  Classifier: Programming Language :: Python :: 3
@@ -23,6 +23,10 @@ Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.9
24
24
  Description-Content-Type: text/markdown
25
25
 
26
+ <p align="center">
27
+ <img src="assets/gcf-python-diagram.png" alt="gcf-python" width="100%">
28
+ </p>
29
+
26
30
  <p align="center">
27
31
  <a href="https://github.com/blackwell-systems"><img src="https://raw.githubusercontent.com/blackwell-systems/blackwell-docs-theme/main/badge-trademark.svg" alt="Blackwell Systems"></a>
28
32
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
@@ -119,7 +123,7 @@ enc = StreamEncoder(sys.stdout, "context_for_task", token_budget=5000)
119
123
  enc.write_symbol(Symbol(qualified_name="pkg.Auth", kind="function", score=0.95, provenance="lsp", distance=0))
120
124
  enc.write_symbol(Symbol(qualified_name="pkg.Server", kind="function", score=0.60, provenance="lsp", distance=1))
121
125
  enc.write_edge(Edge(source="pkg.Server", target="pkg.Auth", edge_type="calls"))
122
- enc.close() # emits ## _summary trailer
126
+ enc.close() # emits ##! summary trailer
123
127
  ```
124
128
 
125
129
  Output:
@@ -131,7 +135,7 @@ GCF tool=context_for_task budget=5000
131
135
  @1 fn pkg.Server 0.60 lsp
132
136
  ## edges [?]
133
137
  @0<@1 calls
134
- ## _summary symbols=2 edges=1 sections=targets:1,related:1,edges:1
138
+ ##! summary symbols=2 edges=1 counts=1,1,1
135
139
  ```
136
140
 
137
141
  The writer is any object with a `write(s: str)` method. Thread-safe. Standard `decode()` handles streaming output with no changes.
@@ -182,6 +186,44 @@ Output:
182
186
 
183
187
  Works on dicts, lists, and primitives. Lists of uniform dicts get tabular rows. Nested dicts use `## key` section headers.
184
188
 
189
+ ## Generic-Profile Delta (multi-turn)
190
+
191
+ In an agent loop the same keyed table gets re-queried turn after turn. Instead of re-sending the whole table each time, send only the changed rows (SPEC §10a):
192
+
193
+ ```python
194
+ from gcf import GenericSet, diff_generic_sets, encode_generic_delta, verify_generic_delta
195
+
196
+ base = GenericSet(key="id", fields=["id", "status"], rows=[
197
+ {"id": 1001, "status": "pending"},
198
+ {"id": 1002, "status": "shipped"},
199
+ ])
200
+ nxt = GenericSet(key="id", fields=["id", "status"], rows=[
201
+ {"id": 1001, "status": "shipped"}, # changed
202
+ {"id": 1003, "status": "pending"}, # added (1002 removed)
203
+ ])
204
+
205
+ d = diff_generic_sets(base, nxt)
206
+ wire = encode_generic_delta(d) # ## added / ## changed / ## removed
207
+ held = verify_generic_delta(base, d, d.new_root) # atomic apply + new_root verification
208
+ ```
209
+
210
+ Opt-in and bilateral, keyed on content-addressed pack roots. By the 5th overlapping call, ~97% fewer tokens than re-sending JSON.
211
+
212
+ ### Re-anchor session helper
213
+
214
+ `GenericDeltaSession` manages the delta/re-anchor cadence for you: each `next()` returns either a compact delta or, on its cadence, a full re-anchor (which re-grounds the consumer), updating its held base.
215
+
216
+ ```python
217
+ from gcf import GenericDeltaSession, fixed_n, size_guard
218
+
219
+ sess = GenericDeltaSession(base, tool="orders", policy=size_guard())
220
+ wire = sess.current_full() # transmit the base once to establish it
221
+ for snapshot in stream: # each turn's current GenericSet
222
+ wire, is_full = sess.next(snapshot) # a compact delta, or a periodic full re-anchor
223
+ ```
224
+
225
+ `fixed_n(15)` re-anchors every N turns; `size_guard()` (recommended) re-anchors once the cumulative delta reaches a full payload's size. It introduces no new wire syntax and the decoder stays cadence-agnostic, so a re-anchor is just the protocol's "full" outcome on a schedule.
226
+
185
227
  ## API
186
228
 
187
229
  | Function | Description |
@@ -190,7 +232,11 @@ Works on dicts, lists, and primitives. Lists of uniform dicts get tabular rows.
190
232
  | `encode_generic(data: Any) -> str` | Encode any value to GCF tabular format |
191
233
  | `decode(input_text: str) -> Payload` | Parse GCF text back to a Payload |
192
234
  | `encode_with_session(p: Payload, s: Session) -> str` | Encode with session deduplication |
193
- | `encode_delta(d: DeltaPayload) -> str` | Encode a delta (added/removed only) |
235
+ | `encode_delta(d: DeltaPayload) -> str` | Encode a graph delta (added/removed only) |
236
+ | `diff_generic_sets(base, next) -> GenericDeltaPayload` | Diff two keyed record sets (generic profile) |
237
+ | `encode_generic_delta(d) -> str` / `decode_generic_delta(s)` | Generic-profile delta wire (§10a) |
238
+ | `verify_generic_delta(base, d, root) -> GenericSet` | Atomic apply + `new_root` verification |
239
+ | `GenericDeltaSession(base, tool, policy)` | Producer-side re-anchor cadence helper (§10a.8) |
194
240
  | `Session()` | Create a new session tracker (thread-safe) |
195
241
 
196
242
  ## Types
@@ -200,7 +246,9 @@ Works on dicts, lists, and primitives. Lists of uniform dicts get tabular rows.
200
246
  | `Payload` | Full GCF payload: tool, budget, symbols, edges, pack root |
201
247
  | `Symbol` | Graph node: qualified name, kind, score, provenance, distance |
202
248
  | `Edge` | Directed relationship: source, target, edge type |
203
- | `DeltaPayload` | Diff between two packs: added/removed symbols and edges |
249
+ | `DeltaPayload` | Diff between two graph packs: added/removed symbols and edges |
250
+ | `GenericSet` / `GenericDeltaPayload` | Keyed record set and its generic-profile diff (§10a) |
251
+ | `GenericDeltaSession` | Stateful producer that schedules delta vs full re-anchor (§10a.8) |
204
252
  | `Session` | Thread-safe tracker for multi-call deduplication |
205
253
  | `KIND_ABBREV` / `KIND_EXPAND` | Bidirectional kind abbreviation dicts |
206
254
 
@@ -0,0 +1,21 @@
1
+ gcf/__init__.py,sha256=dZkdcjbG-dcolg_7TBROjRq3opftDGU423ankMIOhyA,2616
2
+ gcf/__main__.py,sha256=EpvBz1yc8H0D5OJ1zy2tYke-kRzvudKa4DEbfeW14ao,71
3
+ gcf/cli.py,sha256=UEe1CAZn-rKGNIo_ap8-oez3ucl6DSRbsdv6RDnzygY,5256
4
+ gcf/constants.py,sha256=cmZ8YJSOB0im_eyfN8v4UvrLpBC6Fuf4cfcKZGbutxY,638
5
+ gcf/decode.py,sha256=nD8bXYhoeHQQ3LCeAJQOAgFuob-V_6us4mcBYtL_bBc,5978
6
+ gcf/decode_generic.py,sha256=ozYmGiZu9S1zTGsN1PuMTF8soV53eq1v5PXMx2YNgWQ,27888
7
+ gcf/delta.py,sha256=oviQ9WsDRYXPXE8bw6SFutmzMRIvu-U_Zzw-39Nd5Ic,8053
8
+ gcf/encode.py,sha256=OYGDyF3oP2I8Y2PrPL_5yDVURqB0mghtqlOGyDEd-Fs,4081
9
+ gcf/generic.py,sha256=t0Px1fAoWsmFFeICbL65LL8UTGFfnbh4PCanvMMKqYs,18198
10
+ gcf/generic_delta.py,sha256=GU7CNumz3PQhC0Xs5pO30Nb3ACvSorHEQMu-4kazpFM,17838
11
+ gcf/packroot.py,sha256=0rZY7TEVcLzA8XhoIOc6_g9lbRCn-MUr54YTRVhzsO4,2019
12
+ gcf/scalar.py,sha256=MZay-KIROvaFHet-g2-pBghahT1bf_5bxZjG4yTZkSo,9329
13
+ gcf/session.py,sha256=rPqR4xsHKpi_G37t1ODwkTUdHhVAGa3Uig8F60h34Ms,5335
14
+ gcf/stream.py,sha256=3eyzmLMZPyEGC1MS9pujxio2x_t7rCL-eNuJ4DjYkaA,5938
15
+ gcf/stream_generic.py,sha256=RnqqiPSu5joJa-7e58QbbzSGfvxBICA587A3aArjZvE,3250
16
+ gcf/types.py,sha256=AWm-LQoSqLHAYtEjcAxWQZqJ4JXqNreLUKO2mJFgNMA,1465
17
+ gcf_python-2.4.0.dist-info/METADATA,sha256=xDum3Ytd_7KX6SP8940MQDWL08VAwmbdkQuc-nkzN6M,13115
18
+ gcf_python-2.4.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
19
+ gcf_python-2.4.0.dist-info/entry_points.txt,sha256=aFT6gqlkh8iGfM8cblE-LUMxHH08_v71IIoZtDdRIVA,37
20
+ gcf_python-2.4.0.dist-info/licenses/LICENSE,sha256=2Fit9wnaIe--RMSAgyQqxC5hfZTyZqn4fIdBtp9qPDw,1072
21
+ gcf_python-2.4.0.dist-info/RECORD,,
@@ -1,19 +0,0 @@
1
- gcf/__init__.py,sha256=ZPd4iye7cr6HgIsFoe6Lq7Qd3mmIAyVhfjjn5AwID2E,1776
2
- gcf/__main__.py,sha256=EpvBz1yc8H0D5OJ1zy2tYke-kRzvudKa4DEbfeW14ao,71
3
- gcf/cli.py,sha256=UEe1CAZn-rKGNIo_ap8-oez3ucl6DSRbsdv6RDnzygY,5256
4
- gcf/constants.py,sha256=cmZ8YJSOB0im_eyfN8v4UvrLpBC6Fuf4cfcKZGbutxY,638
5
- gcf/decode.py,sha256=nD8bXYhoeHQQ3LCeAJQOAgFuob-V_6us4mcBYtL_bBc,5978
6
- gcf/decode_generic.py,sha256=ZlFZqwenOzCnvbJijKscb6aQHoHQ81qoHBkaalVbqqo,26381
7
- gcf/delta.py,sha256=f90UC6zejXH-ujyU_OViWDCqnxLXk3i6Qion-RJGHY4,1670
8
- gcf/encode.py,sha256=CcqMSNmojrQAAw2X3MNfw8YR6l4rNw9hWDqwmf846oA,2929
9
- gcf/generic.py,sha256=t0Px1fAoWsmFFeICbL65LL8UTGFfnbh4PCanvMMKqYs,18198
10
- gcf/scalar.py,sha256=MZay-KIROvaFHet-g2-pBghahT1bf_5bxZjG4yTZkSo,9329
11
- gcf/session.py,sha256=6-wytNGaBkgqH7uTar7ojUFYNwIMeEVAZhuV-s-upZM,4704
12
- gcf/stream.py,sha256=1Kt_a2daKpYHlWP1lnDJ4g549pUt9rc-U0xbARbXRKQ,5174
13
- gcf/stream_generic.py,sha256=RnqqiPSu5joJa-7e58QbbzSGfvxBICA587A3aArjZvE,3250
14
- gcf/types.py,sha256=AWm-LQoSqLHAYtEjcAxWQZqJ4JXqNreLUKO2mJFgNMA,1465
15
- gcf_python-2.2.2.dist-info/METADATA,sha256=J-3Wr4vqhMqAucwtl4rhHBWBYUYCr3itkFla6FhLuqU,10489
16
- gcf_python-2.2.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
17
- gcf_python-2.2.2.dist-info/entry_points.txt,sha256=aFT6gqlkh8iGfM8cblE-LUMxHH08_v71IIoZtDdRIVA,37
18
- gcf_python-2.2.2.dist-info/licenses/LICENSE,sha256=2Fit9wnaIe--RMSAgyQqxC5hfZTyZqn4fIdBtp9qPDw,1072
19
- gcf_python-2.2.2.dist-info/RECORD,,