Skip to content

Commit 54a4ad2

Browse files
authored
Merge pull request #237 from dgenio/claude/github-issue-triage-nfi1ep
feat(firewall): payload sizing, byte budgets, tiktoken counter, honest summaries
2 parents e5e9358 + a9bea94 commit 54a4ad2

18 files changed

Lines changed: 1030 additions & 221 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **Context-firewall sizing, budgeting & summary fidelity.** A grouped pass over
12+
how the firewall measures, bounds, and represents payloads:
13+
- **Allocation-free size estimation (#207).** `firewall.estimated_size` walks
14+
a value to approximate its serialised length instead of `json.dumps`-ing the
15+
whole payload just to measure it; the raw-mode budget check now uses it, so
16+
multi-MB results are no longer fully serialised for sizing.
17+
- **Byte-aware handle budgeting (#211).** `HandleStore` accepts optional
18+
`max_total_bytes` (evict oldest-first) and `max_entry_bytes` (reject an
19+
over-cap payload with the new `HandleTooLarge` error). Both default to
20+
`None`, leaving existing behaviour unchanged; `current_bytes` exposes
21+
resident usage.
22+
- **tiktoken token counter (#218).** `firewall.make_tiktoken_counter()`
23+
implements the `TokenCounter` seam with a real tokenizer (configurable
24+
encoding, default `cl100k_base`), wired via `BudgetManager(token_counter=…)`.
25+
`tiktoken` is imported lazily so the base install stays dependency-light;
26+
install the existing `weaver-kernel[tiktoken]` extra to use it.
27+
- **Honest summaries (#174).** Boolean columns are summarised as true/false
28+
counts instead of being averaged as numbers, and truncated fact lists carry
29+
an explicit "N more facts omitted" marker.
1130
- **CI / supply-chain hardening.** A focused pass over the build pipeline and
1231
repository automation:
1332
- **Bare-install CI job (#208).** Installs `weaver-kernel` with no extras,

docs/context_firewall.md

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ Budgets(
1919
)
2020
```
2121

22+
The character size used for budget comparisons is computed by an allocation-free
23+
estimator (`weaver_kernel.firewall.estimated_size`) that walks the structure
24+
rather than serialising it with `json.dumps` — so a multi-MB raw result is never
25+
fully serialised just to measure it. The estimate is deterministic and tracks
26+
the serialised length closely; only threshold comparisons depend on it.
27+
2228
## Response modes
2329

2430
| Mode | What you get | When to use |
@@ -51,6 +57,31 @@ expanded = kernel.expand(handle, query={"fields": ["id", "name"]}, principal=pri
5157
expanded = kernel.expand(handle, query={"filter": {"status": "unpaid"}}, principal=principal)
5258
```
5359

60+
### Bounding handle memory by size
61+
62+
The store holds *raw, pre-firewall* datasets, and entry count is a poor proxy
63+
for memory — one deployment's 10k entries are kilobytes, another's are
64+
gigabytes. `HandleStore` accepts two optional byte budgets (both `None` =
65+
disabled, so default behaviour is unchanged):
66+
67+
```python
68+
from weaver_kernel import HandleStore
69+
70+
store = HandleStore(
71+
max_total_bytes=512 * 1024 * 1024, # evict oldest-first until within budget
72+
max_entry_bytes=64 * 1024 * 1024, # reject a single over-cap payload
73+
)
74+
```
75+
76+
Sizes are estimated with the same `estimated_size` walk used for budgets.
77+
`max_total_bytes` evicts oldest-first after each store (never the just-stored
78+
entry); `max_entry_bytes` rejects an over-cap payload with `HandleTooLarge`
79+
rather than truncating it, keeping expansion faithful to the original dataset. A
80+
single entry larger than `max_total_bytes` can never fit, so it is rejected the
81+
same way — `current_bytes` therefore never exceeds `max_total_bytes`. Expanding
82+
an evicted handle raises the usual `HandleNotFound`. Tighter budgets mean more
83+
"handle expired/evicted" experiences — tune for your workload.
84+
5485
## Redaction
5586

5687
When a capability has `SensitivityTag.PII` or `SensitivityTag.PCI`:
@@ -84,11 +115,18 @@ never becomes a sensitive-data sink (see `docs/security.md`).
84115
## Summarization
85116

86117
Summaries are produced deterministically:
87-
- **list of dicts** → row count + top keys + numeric stats + categorical distributions
118+
- **list of dicts** → row count + top keys + numeric stats + categorical/boolean
119+
distributions
88120
- **dict** → key list + per-value type/value
89121
- **string** → truncated to 500 chars
90122
- **other** → repr() truncated to 200 chars
91123

124+
Boolean columns are reported as `True`/`False` counts, never averaged (a `bool`
125+
is an `int` subclass in Python, so "mean of `is_active` = 0.7" is nonsense). When
126+
the fact list is capped by `max_facts`, the final fact is an explicit omission
127+
marker (`… (N more facts omitted; full data via handle)`) so a truncated summary
128+
is never mistaken for a complete one.
129+
92130
## Cross-invocation budgets
93131

94132
The per-invocation `Budgets` above cap a single Frame. A separate
@@ -129,21 +167,27 @@ remaining drops *below* 5% does `handle_only` take over.
129167
`budget_remaining` in the returned `DryRunResult`, so callers can preview
130168
what their next live invocation would actually return.
131169

132-
Plug a different token counter (for example, a `tiktoken`-based one) via the
133-
`TokenCounter` protocol:
170+
The default counter (`default_token_counter`) is a character-based
171+
`len(json.dumps(value)) // 4` approximation with no extra dependencies. For real
172+
token counts, install the `tiktoken` extra and use the shipped factory:
134173

135174
```python
136-
import tiktoken # pip install weaver-kernel[tiktoken]
137-
enc = tiktoken.encoding_for_model("gpt-4o")
175+
from weaver_kernel.firewall import BudgetManager, make_tiktoken_counter
138176

139-
def tiktoken_counter(value):
140-
return len(enc.encode(str(value)))
141-
142-
manager = BudgetManager(total_budget=128_000, token_counter=tiktoken_counter)
177+
# pip install weaver-kernel[tiktoken]
178+
manager = BudgetManager(
179+
total_budget=128_000,
180+
token_counter=make_tiktoken_counter(), # default cl100k_base
181+
# token_counter=make_tiktoken_counter("o200k_base"), # GPT-4o / o-series
182+
)
143183
```
144184

145-
The default counter (`default_token_counter`) is a character-based
146-
`len(json.dumps(value)) // 4` approximation with no extra dependencies.
185+
`make_tiktoken_counter` resolves and caches the encoder eagerly, so a missing
186+
extra (`ImportError`) or an unknown encoding name (`FirewallError`) fails at
187+
construction rather than mid-budgeting. The encoding is explicit because models
188+
tokenize differently — name the one you budget against. `tiktoken` is imported
189+
lazily, so `import weaver_kernel` never pulls the heavyweight dependency. Any
190+
callable matching the `TokenCounter` protocol works too.
147191

148192
## Streaming
149193

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,7 @@ ignore_missing_imports = true
137137
[[tool.mypy.overrides]]
138138
module = "weaver_contracts.*"
139139
ignore_missing_imports = true
140+
141+
[[tool.mypy.overrides]]
142+
module = "tiktoken.*"
143+
ignore_missing_imports = true

src/weaver_kernel/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,7 @@
4040
from weaver_kernel import SQLiteTraceStore, JsonlTraceStore
4141
from weaver_kernel import SQLiteRevocationStore, InMemoryRevocationStore
4242
from weaver_kernel import verify_chain, ChainVerificationResult, TraceRecord
43-
from weaver_kernel import (
44-
TraceStoreProtocol, RevocationStoreProtocol, HandleStoreProtocol,
45-
)
43+
from weaver_kernel import TraceStoreProtocol, RevocationStoreProtocol, HandleStoreProtocol
4644
4745
LLM tool-format adapters::
4846
@@ -62,7 +60,7 @@
6260
DriverError, FirewallError, AdapterParseError,
6361
BudgetExhausted, BudgetConfigError,
6462
CapabilityNotFound, CapabilityAlreadyRegistered,
65-
HandleNotFound, HandleExpired, HandleConstraintViolation,
63+
HandleNotFound, HandleExpired, HandleTooLarge, HandleConstraintViolation,
6664
NamespaceNotFound, FederationError, ManifestError, ManifestSignatureError,
6765
TrustPolicyError, DiscoveryError,
6866
)
@@ -91,6 +89,7 @@
9189
HandleConstraintViolation,
9290
HandleExpired,
9391
HandleNotFound,
92+
HandleTooLarge,
9493
ManifestError,
9594
ManifestSignatureError,
9695
NamespaceNotFound,
@@ -246,6 +245,7 @@
246245
"HandleConstraintViolation",
247246
"HandleExpired",
248247
"HandleNotFound",
248+
"HandleTooLarge",
249249
"ManifestError",
250250
"ManifestSignatureError",
251251
"NamespaceNotFound",

src/weaver_kernel/errors.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,13 @@ class BudgetExhausted(AgentKernelError):
7878

7979

8080
class BudgetConfigError(AgentKernelError):
81-
"""Raised when a :class:`~weaver_kernel.firewall.budget_manager.BudgetManager` is
82-
constructed with invalid parameters, or asked to allocate/record/release
83-
a negative amount.
81+
"""Raised when a budget is constructed with invalid parameters.
82+
83+
Covers the :class:`~weaver_kernel.firewall.budget_manager.BudgetManager`
84+
(non-positive ``total_budget``/``default_request``, or a negative
85+
allocate/record/release amount) and the
86+
:class:`~weaver_kernel.handles.HandleStore` byte budgets (non-positive
87+
``max_total_bytes``/``max_entry_bytes``).
8488
8589
Used in place of bare :class:`ValueError` so callers can catch budget
8690
configuration mistakes without swallowing unrelated stdlib errors.
@@ -128,6 +132,19 @@ class HandleExpired(AgentKernelError):
128132
"""Raised when a handle's TTL has elapsed."""
129133

130134

135+
class HandleTooLarge(AgentKernelError):
136+
"""Raised when a single handle payload exceeds the store's byte ceiling.
137+
138+
Fires from :meth:`~weaver_kernel.handles.HandleStore.store` when a byte
139+
budget is configured and the estimated size of the data exceeds the binding
140+
per-store ceiling — ``max_entry_bytes``, or ``max_total_bytes`` (a single
141+
entry larger than the whole budget can never fit). The data is *not*
142+
retained — rejecting an over-cap payload outright (rather than silently
143+
truncating it) keeps handle expansion faithful to the original dataset and
144+
bounds resident raw data.
145+
"""
146+
147+
131148
class HandleConstraintViolation(AgentKernelError):
132149
"""Raised when a handle expansion request violates the grant's constraints.
133150

src/weaver_kernel/firewall/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from .budget_manager import BudgetManager
44
from .budgets import Budgets
55
from .redaction import redact
6+
from .size_estimate import estimated_size
67
from .summarize import summarize
78
from .token_counting import TokenCounter, default_token_counter
9+
from .token_counting_tiktoken import make_tiktoken_counter
810
from .transform import Firewall
911

1012
__all__ = [
@@ -13,6 +15,8 @@
1315
"Firewall",
1416
"TokenCounter",
1517
"default_token_counter",
18+
"estimated_size",
19+
"make_tiktoken_counter",
1620
"redact",
1721
"summarize",
1822
]
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Allocation-free payload size estimation for the firewall hot path.
2+
3+
``estimated_size`` approximates ``len(json.dumps(value, default=str))`` by
4+
walking the structure and summing approximate character contributions, *without*
5+
serialising the value to an intermediate string. The firewall runs on every
6+
egress path and only needs the size to compare against budget thresholds, so a
7+
single allocation-free pass (with an optional early exit) replaces a full JSON
8+
serialisation that would double peak memory on large payloads.
9+
10+
The estimate is intentionally approximate but **deterministic**: the same input
11+
always yields the same number (the total is order-independent). Self-referential
12+
structures are handled gracefully — each container is counted at most once, so a
13+
cycle can never hang the walk (unlike ``json.dumps``, which raises). Counting
14+
each container once also means a structure that legitimately reuses the same
15+
container in several positions (a DAG, not a cycle) is counted once, so the
16+
estimate is a *lower bound* for shared-reference inputs; firewall and handle
17+
payloads are deserialised JSON without shared references, where this does not
18+
arise. It is used by
19+
:mod:`~weaver_kernel.firewall.transform` (raw-mode budget warning, issue #207)
20+
and by :class:`~weaver_kernel.handles.HandleStore` (byte-size budgeting,
21+
issue #211).
22+
"""
23+
24+
from __future__ import annotations
25+
26+
from typing import Any
27+
28+
# Approximate JSON literal widths and structural overhead. json.dumps with the
29+
# default separators emits ``", "`` between items and ``": "`` between a key and
30+
# its value; object/array delimiters add two characters per container. These
31+
# constants keep the estimate close to the real serialised length without
32+
# reproducing json's exact escaping rules (bounded error is acceptable because
33+
# only threshold comparisons depend on the result).
34+
_NULL_LEN = 4 # "null"
35+
_TRUE_LEN = 4 # "true"
36+
_FALSE_LEN = 5 # "false"
37+
_QUOTES = 2 # surrounding "" on a string
38+
_CONTAINER_DELIMS = 2 # {} or []
39+
_ITEM_SEP = 2 # ", "
40+
_KV_SEP = 2 # ": "
41+
42+
43+
def estimated_size(value: Any, *, limit: int | None = None) -> int:
44+
"""Approximate the serialised character size of *value* without serialising.
45+
46+
Walks *value* iteratively (so deeply nested structures cannot exhaust the
47+
recursion limit) summing approximate JSON character contributions. ``bool``
48+
is handled before ``int`` because ``bool`` is an ``int`` subclass in Python.
49+
Each container is visited at most once, so self-referential inputs terminate
50+
instead of looping forever.
51+
52+
Args:
53+
value: Any value the firewall might serialise. Non-JSON types fall back
54+
to ``len(str(value))`` plus string quoting, mirroring the
55+
``default=str`` behaviour of the previous ``json.dumps`` measurement.
56+
limit: Optional early-exit threshold. When the running total exceeds
57+
*limit* the walk stops and returns a value greater than *limit* — use
58+
this when only the boolean ``size > limit`` decision is needed, not
59+
the exact size. (Which member tips the total past *limit* is
60+
unspecified, but the returned value is always ``> limit``.)
61+
62+
Returns:
63+
A non-negative integer approximating ``len(json.dumps(value,
64+
default=str))``.
65+
66+
Example:
67+
>>> estimated_size(None)
68+
4
69+
>>> estimated_size("hi")
70+
4
71+
>>> estimated_size([1, 2, 3])
72+
9
73+
"""
74+
total = 0
75+
seen: set[int] = set() # container ids already counted, to break cycles
76+
stack: list[Any] = [value]
77+
while stack:
78+
cur = stack.pop()
79+
if cur is None:
80+
total += _NULL_LEN
81+
elif cur is True:
82+
total += _TRUE_LEN
83+
elif cur is False:
84+
total += _FALSE_LEN
85+
elif isinstance(cur, str):
86+
total += len(cur) + _QUOTES
87+
elif isinstance(cur, bool): # pragma: no cover - True/False caught above
88+
total += _TRUE_LEN
89+
elif isinstance(cur, (int, float)):
90+
total += len(str(cur))
91+
elif isinstance(cur, dict):
92+
if id(cur) in seen:
93+
continue
94+
seen.add(id(cur))
95+
n = len(cur)
96+
total += _CONTAINER_DELIMS + max(0, n - 1) * _ITEM_SEP
97+
for key, val in cur.items():
98+
total += len(str(key)) + _QUOTES + _KV_SEP
99+
stack.append(val)
100+
elif isinstance(cur, (list, tuple)):
101+
if id(cur) in seen:
102+
continue
103+
seen.add(id(cur))
104+
n = len(cur)
105+
total += _CONTAINER_DELIMS + max(0, n - 1) * _ITEM_SEP
106+
stack.extend(cur)
107+
else:
108+
# Mirrors json.dumps(default=str): rendered as a quoted string.
109+
total += len(str(cur)) + _QUOTES
110+
if limit is not None and total > limit:
111+
return total
112+
return total

0 commit comments

Comments
 (0)