Skip to content

Commit 0fc2581

Browse files
Add GenericDeltaSession re-anchor helper (SPEC §10a.8)
Port the producer-side re-anchor session helper from gcf-go: byte-for-byte identical cadence, passing the shared generic-delta-session conformance fixtures (fixed-N, size-guard, schema-change). - GenericDeltaSession with current_full() and next() -> (wire, is_full) - ReanchorPolicy / fixed_n(n) / size_guard() / DEFAULT_REANCHOR_N = 15 - Size guard compares UTF-8 byte lengths to match Go's len(string) - Schema change (or fresh key) forces a full re-anchor (10a.7) - Thin sugar over the primitives; no new wire syntax Exports the new public symbols from gcf.__init__. Adds a generic-delta-session conformance branch and a unit suite mirroring the Go tests (cadence patterns + consumer-stays-in-sync under both policies).
1 parent e6d0a7d commit 0fc2581

5 files changed

Lines changed: 358 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
- `diff_generic_sets` (the blessed producer path; centralizes the keyed-diff invariants), `encode_generic_full`, `encode_generic_delta`
1111
- `decode_generic_full`, `decode_generic_delta` (consumer wire parsing)
1212
- `verify_generic_delta` (atomic apply + `new_root` verification)
13+
- Re-anchor session helper (SPEC §10a.8): `GenericDeltaSession` (`current_full`, `next`) with `ReanchorPolicy` / `fixed_n(n)` / `size_guard()` cadence policies and `DEFAULT_REANCHOR_N = 15`. Producer-side sugar over the primitives; introduces no new wire syntax (every emission is exactly `encode_generic_full` / `encode_generic_delta` output). Re-anchor cadence is byte-for-byte identical to `gcf-go` (size guard uses UTF-8 byte length to match Go's `len(string)`), verified by the shared `generic-delta-session` conformance fixtures.
1314
- Delta is opt-in and bilateral; the existing `encode_generic` path is unchanged (backward compatible).
1415

1516
### Tests
1617

1718
- Unit suite mirroring `gcf-go`: self-proving round-trip (diff -> encode -> apply -> recomputed root), determinism / row-order invariance, no-type-collision canonicalization, every invariant/error path, full-payload wire round-trip, the complete server -> wire -> consumer end-to-end loop, and malformed-wire-fails-closed.
1819
- Conformance runner support for `generic-pack-root`, `generic-delta`, `generic-delta-verify`, `generic-delta-decode` (12 shared fixtures); verified to produce identical pack roots and delta wire to `gcf-go`.
20+
- Session helper suite (`test_generic_delta_session.py`) mirroring `gcf-go`: FixedN cadence pattern, size-guard triggering, schema-change forced full, FixedN(15)-over-30-turns count, and the load-bearing consumer-stays-in-sync check under both policies. Conformance runner support for `generic-delta-session` (3 shared fixtures: fixed-N, size-guard, schema-change).
1921

2022
## v2.2.2 (2026-07-10)
2123

src/gcf/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@
4949
decode_generic_full,
5050
decode_generic_delta,
5151
verify_generic_delta,
52+
GenericDeltaSession,
53+
ReanchorPolicy,
54+
ReanchorMode,
55+
fixed_n,
56+
size_guard,
57+
DEFAULT_REANCHOR_N,
5258
)
5359
from .session import Session, encode_with_session
5460
from .decode_generic import decode_generic
@@ -84,6 +90,12 @@
8490
"decode_generic_full",
8591
"decode_generic_delta",
8692
"verify_generic_delta",
93+
"GenericDeltaSession",
94+
"ReanchorPolicy",
95+
"ReanchorMode",
96+
"fixed_n",
97+
"size_guard",
98+
"DEFAULT_REANCHOR_N",
8799
]
88100

89101
__version__ = "2.3.0"

src/gcf/generic_delta.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import hashlib
1111
from dataclasses import dataclass, field
12+
from enum import Enum
1213
from typing import Any
1314

1415
from .scalar import (
@@ -362,3 +363,125 @@ def decode_generic_delta(text: str) -> GenericDeltaPayload:
362363
else:
363364
raise ValueError(f"delta_invalid: unknown delta section {name!r}")
364365
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

tests/test_conformance_v2.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,5 +164,39 @@ def test_conformance(rel_path, data):
164164
res = verify_generic_delta(base, decode_generic_delta(inp["wire"]), inp["expectedNewRoot"])
165165
assert generic_pack_root(res) == data["expected"]
166166

167+
elif op == "generic-delta-session":
168+
from gcf.generic_delta import (
169+
GenericDeltaSession,
170+
GenericSet,
171+
fixed_n,
172+
size_guard,
173+
)
174+
175+
inp = data["input"]
176+
177+
def _set(s):
178+
return GenericSet(
179+
key=s["key"], fields=s["fields"], rows=s["rows"], name=s.get("name", "rows")
180+
)
181+
182+
pol = inp["policy"]
183+
if pol.get("mode") == "sizeGuard":
184+
policy = size_guard()
185+
else:
186+
policy = fixed_n(pol.get("n", 0))
187+
188+
sess = GenericDeltaSession(_set(inp["base"]), inp.get("tool", ""), policy)
189+
exp = data["expected"]
190+
assert sess.current_full() == exp["initialFull"], (
191+
f"initial full mismatch:\n got: {sess.current_full()!r}\n exp: {exp['initialFull']!r}"
192+
)
193+
for i, up in enumerate(inp["updates"]):
194+
wire, is_full = sess.next(_set(up))
195+
e = exp["emissions"][i]
196+
assert is_full == e["isFull"], f"turn {i + 1}: isFull={is_full}, want {e['isFull']}"
197+
assert wire == e["wire"], (
198+
f"turn {i + 1} wire mismatch:\n got: {wire!r}\n exp: {e['wire']!r}"
199+
)
200+
167201
else:
168202
pytest.skip(f"unknown operation: {op}")

0 commit comments

Comments
 (0)