promptbudget 0.1.0__tar.gz

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.
@@ -0,0 +1,93 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - name: Install
21
+ run: |
22
+ python -m pip install --upgrade pip
23
+ pip install -e ".[dev,tiktoken]"
24
+ - name: Test
25
+ run: pytest -q --cov=promptbudget --cov-report=term-missing
26
+
27
+ package:
28
+ # Catches packaging regressions that unit tests cannot see, such as a
29
+ # missing py.typed marker silently disabling type hints for consumers.
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ - uses: actions/setup-python@v5
34
+ with:
35
+ python-version: "3.12"
36
+
37
+ - name: Build sdist and wheel
38
+ run: |
39
+ python -m pip install --upgrade pip
40
+ pip install build twine
41
+ python -m build
42
+
43
+ - name: Validate metadata renders on PyPI
44
+ run: twine check dist/*
45
+
46
+ - name: Assert py.typed ships in the wheel
47
+ run: |
48
+ python - <<'PY'
49
+ import glob, sys, zipfile
50
+ wheel = glob.glob("dist/*.whl")[0]
51
+ names = zipfile.ZipFile(wheel).namelist()
52
+ if not any(n.endswith("promptbudget/py.typed") for n in names):
53
+ sys.exit("py.typed missing from the wheel; PEP 561 consumers would lose all type hints")
54
+ print(f"py.typed present in {wheel}")
55
+ PY
56
+
57
+ - name: Install the built wheel and smoke-test it
58
+ run: |
59
+ python -m venv /tmp/smoke
60
+ /tmp/smoke/bin/pip install --quiet dist/*.whl
61
+ /tmp/smoke/bin/python - <<'PY'
62
+ from promptbudget import Item, Section, pack
63
+
64
+ result = pack(
65
+ [
66
+ Section("system", "You are helpful.", priority=100),
67
+ Section("history", [Item("x" * 50) for _ in range(10)], priority=10),
68
+ ],
69
+ budget=100,
70
+ reserved_output=10,
71
+ )
72
+ assert result.used_tokens <= 90, result.used_tokens
73
+ assert result.section("system").text
74
+ print("smoke test passed:", result.used_tokens, "tokens")
75
+ PY
76
+
77
+ lint:
78
+ runs-on: ubuntu-latest
79
+ steps:
80
+ - uses: actions/checkout@v4
81
+ - uses: actions/setup-python@v5
82
+ with:
83
+ python-version: "3.12"
84
+ - name: Install
85
+ run: |
86
+ python -m pip install --upgrade pip
87
+ pip install -e ".[dev]"
88
+ - name: Ruff
89
+ run: ruff check .
90
+ - name: Ruff format
91
+ run: ruff format --check .
92
+ - name: Mypy
93
+ run: mypy
@@ -0,0 +1,104 @@
1
+ name: Release
2
+
3
+ # Trigger by pushing a tag like v0.1.0. See RELEASING.md for the full
4
+ # checklist -- this workflow assumes the version bump and changelog
5
+ # entry are already committed before the tag is pushed.
6
+ on:
7
+ push:
8
+ tags:
9
+ - "v*.*.*"
10
+
11
+ permissions:
12
+ contents: write # to create the GitHub Release
13
+ id-token: write # for PyPI trusted publishing (no API token needed)
14
+
15
+ jobs:
16
+ build:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.12"
24
+
25
+ - name: Verify the tag matches pyproject.toml
26
+ run: |
27
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
28
+ PYPROJECT_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
29
+ if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ]; then
30
+ echo "::error::Tag is v$TAG_VERSION but pyproject.toml says $PYPROJECT_VERSION. Fix one of them before releasing."
31
+ exit 1
32
+ fi
33
+
34
+ - name: Run the full test suite before shipping anything
35
+ run: |
36
+ python -m pip install --upgrade pip
37
+ pip install -e ".[dev,tiktoken]"
38
+ pytest -q
39
+
40
+ - name: Build sdist and wheel
41
+ run: |
42
+ pip install build
43
+ python -m build
44
+
45
+ - name: Upload build artifacts
46
+ uses: actions/upload-artifact@v4
47
+ with:
48
+ name: dist
49
+ path: dist/
50
+
51
+ publish-pypi:
52
+ needs: build
53
+ runs-on: ubuntu-latest
54
+ environment:
55
+ name: pypi
56
+ url: https://pypi.org/project/promptbudget/
57
+ permissions:
58
+ id-token: write
59
+ steps:
60
+ - uses: actions/download-artifact@v4
61
+ with:
62
+ name: dist
63
+ path: dist/
64
+
65
+ - name: Publish to PyPI
66
+ uses: pypa/gh-action-pypi-publish@release/v1
67
+ # Requires a trusted publisher configured on pypi.org for this
68
+ # repo + workflow first. See RELEASING.md -- this step will fail
69
+ # until that one-time manual setup is done.
70
+
71
+ github-release:
72
+ needs: publish-pypi
73
+ runs-on: ubuntu-latest
74
+ permissions:
75
+ contents: write
76
+ steps:
77
+ - uses: actions/checkout@v4
78
+
79
+ - uses: actions/download-artifact@v4
80
+ with:
81
+ name: dist
82
+ path: dist/
83
+
84
+ - name: Extract this version's changelog section
85
+ id: notes
86
+ run: |
87
+ VERSION="${GITHUB_REF_NAME#v}"
88
+ awk -v ver="$VERSION" '
89
+ $0 ~ "^## \\[" ver "\\]" { found=1; next }
90
+ found && /^## \[/ { exit }
91
+ found && /^\[.*\]: / { exit }
92
+ found { print }
93
+ ' CHANGELOG.md > release_notes.md
94
+ if [ ! -s release_notes.md ]; then
95
+ echo "::error::No CHANGELOG.md section found for version $VERSION. Add one before tagging."
96
+ exit 1
97
+ fi
98
+
99
+ - name: Create GitHub Release
100
+ uses: softprops/action-gh-release@v2
101
+ with:
102
+ body_path: release_notes.md
103
+ files: dist/*
104
+ generate_release_notes: false
@@ -0,0 +1,17 @@
1
+ .venv/
2
+ venv/
3
+ __pycache__/
4
+ *.py[cod]
5
+ *.egg-info/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .hypothesis/
12
+ .coverage
13
+ htmlcov/
14
+ .DS_Store
15
+ .idea/
16
+ .vscode/
17
+ .env
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [Unreleased]
9
+
10
+ Nothing yet.
11
+
12
+ ## [0.1.0] - 2026-09-09
13
+
14
+ Initial release.
15
+
16
+ ### Added
17
+
18
+ - `pack()` / `ContextPacker`: allocate a fixed token budget across competing
19
+ `Section`s using declared priority, with ties broken deterministically by
20
+ section name.
21
+ - `min_tokens` / `max_tokens` guarantees per section. Infeasible minimums
22
+ raise `BudgetTooSmallError` instead of silently violating one.
23
+ - `reserved_output`: hold back tokens for the model's reply so it is never
24
+ offered to a section.
25
+ - Eviction strategies: `truncate` (default), `drop_oldest`, `drop_newest`,
26
+ `lowest_score_first`, `all_or_nothing`. Custom strategies plug in via
27
+ `Strategy` + `register_strategy()` with no change to the allocator.
28
+ - Pinned items (`Item(..., pinned=True)`) that are never dropped or cut,
29
+ and raise `SectionOverflowError` if they genuinely cannot fit.
30
+ - Tokenizer adapters: dependency-free `CharTokenizer` (default), any plain
31
+ `str -> int` callable, and an optional `TiktokenTokenizer` for exact
32
+ OpenAI-family counts.
33
+ - `PackResult` audit trail: `used_tokens`, `remaining_tokens`, `complete`,
34
+ per-section `SectionReport`s, and a full `ItemDecision` for every item
35
+ with a human-readable reason. Serializes via `to_dict()` / `to_json()`.
36
+ - Fully typed and PEP 561 compliant: a `py.typed` marker ships in the
37
+ wheel, so type checkers in consuming projects honour these annotations.
38
+ - Value semantics on `Item`: hashable (usable in sets and as dict keys),
39
+ with `metadata` copied on construction so packing stays a pure function
40
+ and no caller dict can be mutated out from under you.
41
+ - Eviction is linear in tokenizer calls, not quadratic -- token counts are
42
+ computed once and the running total maintained incrementally. Packing 400
43
+ messages costs 405 tokenizer calls rather than 80,603, and the gap widens
44
+ with size.
45
+ - Zero runtime dependencies in the core install.
46
+
47
+ [Unreleased]: https://github.com/mathewOracle/promptbudget/compare/v0.1.0...HEAD
48
+ [0.1.0]: https://github.com/mathewOracle/promptbudget/releases/tag/v0.1.0
@@ -0,0 +1,91 @@
1
+ # Contributing to promptbudget
2
+
3
+ Thanks for taking a look. This is a small library on purpose, so the bar for
4
+ what goes in is deliberately narrow.
5
+
6
+ ## Ground rules
7
+
8
+ **The budget invariant is sacred.** No change may allow packed output to exceed
9
+ the declared budget. If you can produce an input that breaks it, that is a
10
+ high-priority bug — please open an issue with the reproducing input.
11
+
12
+ **Determinism is a feature.** Identical inputs must produce byte-identical
13
+ prompts. Anything relying on set iteration order, dict insertion luck, or
14
+ unstable sorts will be rejected. Tie-breaks must be explicit.
15
+
16
+ **Fail loudly, never silently.** When the request cannot be satisfied, raise a
17
+ descriptive error. Quietly returning a prompt that violates a declared guarantee
18
+ is the exact failure mode this library exists to prevent.
19
+
20
+ ## Adding a strategy
21
+
22
+ This should not require touching the allocator. If it does, that is a design
23
+ smell worth raising in an issue first.
24
+
25
+ ```python
26
+ from promptbudget import Strategy, StrategyResult, register_strategy
27
+
28
+
29
+ class MyStrategy(Strategy):
30
+ name = "my_strategy"
31
+
32
+ def apply(self, section, items, allocated, tokenizer, separator):
33
+ # Must never return items whose rendered cost exceeds `allocated`.
34
+ # Must emit exactly one ItemDecision per input item.
35
+ return StrategyResult(kept_items, decisions)
36
+
37
+
38
+ register_strategy("my_strategy", MyStrategy())
39
+ ```
40
+
41
+ Strategies that only differ in *eviction order* should subclass the shared
42
+ `_ItemwiseStrategy` helper and implement `eviction_order` — the cost accounting,
43
+ separator handling and pinned-item logic already live there exactly once. Please
44
+ don't copy it.
45
+
46
+ Every new strategy needs:
47
+
48
+ 1. A unit test showing the ordering it promises.
49
+ 2. Inclusion in `SAFE_STRATEGIES` in `tests/test_properties.py`, so the property
50
+ suite proves it respects the budget across generated inputs.
51
+ 3. A row in the strategy table in the README.
52
+
53
+ ## Adding a tokenizer
54
+
55
+ Subclass `Tokenizer` and implement `count`. You get a correct binary-search
56
+ `truncate` for free — only override it if the underlying tokenizer can slice
57
+ token ids directly, which is faster and exact.
58
+
59
+ New tokenizers must stay **optional dependencies**. The core installs with none,
60
+ and that is a promise to users, not an accident.
61
+
62
+ ## Running things
63
+
64
+ ```bash
65
+ pip install -e ".[dev]"
66
+ pytest # unit + property tests + README doctests
67
+ ruff check . && ruff format --check .
68
+ mypy
69
+ ```
70
+
71
+ CI runs on Python 3.9 through 3.13. Please make sure it is green before asking
72
+ for a review.
73
+
74
+ ## Pull requests
75
+
76
+ - One logical change per PR.
77
+ - Explain *why*, and say what you ruled out. Reviewers care more about the
78
+ discarded alternatives than the chosen one.
79
+ - New behaviour needs a test that fails without your change. Verify that by
80
+ reverting the source and watching it go red.
81
+ - Add an entry under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md) for
82
+ anything a user of the library would notice -- new behaviour, a fixed bug,
83
+ a changed default. Pure refactors and test-only changes don't need one.
84
+ - If you used an AI tool, that's fine -- but you must understand the code well
85
+ enough to defend every line in review. You are the author; the tool is not.
86
+
87
+ ## Reporting bugs
88
+
89
+ The most useful bug report contains a `pack()` call, the inputs, what you
90
+ expected, and what you got. Since packing is a pure function of its arguments,
91
+ that is almost always enough to reproduce it exactly.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mathew Kadambatt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,238 @@
1
+ Metadata-Version: 2.5
2
+ Name: promptbudget
3
+ Version: 0.1.0
4
+ Summary: Deterministic context-window budgeting for LLM prompts, with a full audit trail of every token decision.
5
+ Project-URL: Homepage, https://github.com/mathewOracle/promptbudget
6
+ Project-URL: Repository, https://github.com/mathewOracle/promptbudget
7
+ Project-URL: Issues, https://github.com/mathewOracle/promptbudget/issues
8
+ Author-email: Mathew Kadambatt <49642721+mathewOracle@users.noreply.github.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai,budget,context-window,llm,prompt,prompt-engineering,rag,tokenizer,tokens
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Provides-Extra: dev
26
+ Requires-Dist: hypothesis>=6.0; extra == 'dev'
27
+ Requires-Dist: mypy>=1.8; extra == 'dev'
28
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
29
+ Requires-Dist: pytest>=7.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.5; extra == 'dev'
31
+ Provides-Extra: tiktoken
32
+ Requires-Dist: tiktoken>=0.5; extra == 'tiktoken'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # promptbudget
36
+
37
+ **Deterministic context-window budgeting for LLM prompts — with a full audit trail of every token decision.**
38
+
39
+ [![CI](https://github.com/mathewOracle/promptbudget/actions/workflows/ci.yml/badge.svg)](https://github.com/mathewOracle/promptbudget/actions/workflows/ci.yml)
40
+ [![PyPI](https://img.shields.io/pypi/v/promptbudget)](https://pypi.org/project/promptbudget/)
41
+ [![Python](https://img.shields.io/badge/python-3.9%2B-blue)](https://pypi.org/project/promptbudget/)
42
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
43
+ [![Typed](https://img.shields.io/badge/typing-strict-blue)](https://peps.python.org/pep-0561/)
44
+
45
+ Your system prompt, tool schemas, chat history and retrieved chunks are all competing
46
+ for the same context window. Most codebases resolve that fight with a `while
47
+ total > limit: messages.pop(0)` loop buried in a helper — which silently evicts the
48
+ one document the answer depended on, and leaves you no way to find out.
49
+
50
+ `promptbudget` makes that allocation an explicit, testable, auditable decision.
51
+
52
+ ```python
53
+ from promptbudget import Section, Item, pack
54
+
55
+ result = pack(
56
+ [
57
+ Section("system", SYSTEM_PROMPT, priority=100, strategy="all_or_nothing"),
58
+ Section("tools", tool_schemas, priority=90, min_tokens=2_000),
59
+ Section("history", messages, priority=50, strategy="drop_oldest"),
60
+ Section("retrieval", chunks, priority=40, strategy="lowest_score_first"),
61
+ Section("scratchpad", notes, priority=10, max_tokens=1_000),
62
+ ],
63
+ budget=128_000,
64
+ reserved_output=4_000,
65
+ )
66
+
67
+ send(result.text)
68
+
69
+ for d in result.dropped():
70
+ log.info("%s[%d] %s: %s", d.section, d.index, d.action.value, d.reason)
71
+ ```
72
+
73
+ ```
74
+ retrieval[7] dropped: lowest relevance score, evicted first to fit the section allocation
75
+ scratchpad[0] truncated: cut to the tokens remaining in the section allocation
76
+ ```
77
+
78
+ ## Why this exists
79
+
80
+ | The usual approach | What goes wrong |
81
+ |---|---|
82
+ | `messages[-10:]` | Breaks the moment one message is huge |
83
+ | `while tokens > limit: pop(0)` | No priorities, no guarantees, drops pinned context |
84
+ | Framework-native trimming | Handles chat history only — not tools, RAG chunks and history *together* |
85
+ | Retry on `context_length_exceeded` | Pays for a failed call, and still doesn't decide *what* to shed |
86
+ | All of the above | Cannot answer "why is that chunk missing from my prompt?" |
87
+
88
+ Existing tools each cover a slice: LangChain's `trim_messages` trims message lists,
89
+ LLMLingua compresses text, `tiktoken` counts tokens. None of them arbitrate a fixed
90
+ budget across several sections with declared guarantees and tell you what they did.
91
+
92
+ ## Install
93
+
94
+ ```bash
95
+ pip install promptbudget # zero dependencies
96
+ pip install "promptbudget[tiktoken]" # exact OpenAI-family token counts
97
+ ```
98
+
99
+ Python 3.9+. The core has **no runtime dependencies**.
100
+
101
+ ## Core ideas
102
+
103
+ ### Sections compete; priority decides who wins
104
+
105
+ Sections are served highest priority first. Ties break on name, so identical inputs
106
+ always produce an identical prompt — the same reason you pin dependency versions.
107
+
108
+ Rendering follows *your* list order, not priority order. Priority decides who gets
109
+ tokens; you decide what the prompt reads like.
110
+
111
+ ### Guarantees are kept or the call fails
112
+
113
+ ```python
114
+ Section("tools", tool_schemas, priority=90, min_tokens=2_000)
115
+ ```
116
+
117
+ `min_tokens` is reserved before any lower-priority section is served — a greedy
118
+ high-priority section cannot eat tokens promised downstream. If the minimums
119
+ collectively don't fit, you get `BudgetTooSmallError` rather than a prompt that
120
+ quietly violates one:
121
+
122
+ ```
123
+ BudgetTooSmallError: Guaranteed minimums require 6000 tokens but only 4000 are
124
+ available (budget=8000, reserved_output=4000). Lower a min_tokens value, raise
125
+ the budget, or reduce reserved_output.
126
+ ```
127
+
128
+ `max_tokens` is the mirror image: a cap, so a runaway history can't crowd out retrieval
129
+ even when the window is empty.
130
+
131
+ ### Reserve room for the reply
132
+
133
+ ```python
134
+ pack(sections, budget=128_000, reserved_output=4_000)
135
+ ```
136
+
137
+ Forgetting the model's own output is the most common way to get a context-length
138
+ error from a prompt that "obviously fits". Reserved tokens are never offered to a section.
139
+
140
+ ### Strategies decide what gets shed
141
+
142
+ | Strategy | Behaviour | Use for |
143
+ |---|---|---|
144
+ | `"truncate"` *(default)* | Cuts the tail of the overflowing item | Prose, scratchpads |
145
+ | `"drop_oldest"` | Evicts from the front | Chat history |
146
+ | `"drop_newest"` | Evicts from the back | Append-only logs |
147
+ | `"lowest_score_first"` | Evicts least-relevant items | RAG chunks |
148
+ | `"all_or_nothing"` | Delivers intact or raises | System prompts, tool schemas |
149
+
150
+ Pin anything that must survive:
151
+
152
+ ```python
153
+ Item("The user's actual question", pinned=True)
154
+ ```
155
+
156
+ Pinned items are never dropped or cut. If a pinned item genuinely cannot fit, you get
157
+ `SectionOverflowError` — a loud failure beats a mangled prompt.
158
+
159
+ Bring your own by subclassing `Strategy`:
160
+
161
+ ```python
162
+ from promptbudget import Strategy, StrategyResult, register_strategy
163
+
164
+
165
+ class DeduplicateThenTruncate(Strategy):
166
+ name = "dedupe"
167
+
168
+ def apply(
169
+ self, section, items, allocated, tokenizer, separator
170
+ ): ... # return StrategyResult(items, decisions)
171
+
172
+
173
+ register_strategy("dedupe", DeduplicateThenTruncate())
174
+ ```
175
+
176
+ ### Any tokenizer, or none
177
+
178
+ ```python
179
+ from promptbudget import TiktokenTokenizer, pack
180
+
181
+ pack(sections, budget=128_000, tokenizer=TiktokenTokenizer("gpt-4o"))
182
+ pack(sections, budget=8_000, tokenizer=lambda text: len(text) // 4) # any str -> int
183
+ pack(sections, budget=8_000) # built-in heuristic
184
+ ```
185
+
186
+ The allocator never imports a tokenizer directly — it talks to the `Tokenizer`
187
+ interface, so HuggingFace, a provider SDK, or your own estimator all drop straight in.
188
+
189
+ ### The result explains itself
190
+
191
+ ```python
192
+ result.text # the fitted prompt
193
+ result.used_tokens # what it actually costs
194
+ result.remaining_tokens # headroom left
195
+ result.complete # True when nothing was lost
196
+ result.dropped() # every item that lost content, and why
197
+ result.section("rag") # per-section accounting
198
+ result.to_json(indent=2) # structured logging, ready to ship
199
+ ```
200
+
201
+ Because packing is a pure function returning a plain value, you can assert on it:
202
+
203
+ ```python
204
+ def test_the_user_question_always_survives():
205
+ result = pack(sections, budget=4_000)
206
+ assert "user_question" not in {d.section for d in result.dropped()}
207
+ ```
208
+
209
+ ## Guarantees
210
+
211
+ Verified by property-based tests across hundreds of generated inputs
212
+ ([`tests/test_properties.py`](tests/test_properties.py)):
213
+
214
+ - The output **never** exceeds the budget.
215
+ - Reserved output tokens are **never** consumed.
216
+ - `max_tokens` caps are **never** breached.
217
+ - Identical inputs produce **byte-identical** prompts.
218
+ - Every item receives **exactly one** decision — nothing vanishes unrecorded.
219
+ - `complete` never lies about dropped content.
220
+
221
+ ## Development
222
+
223
+ ```bash
224
+ git clone https://github.com/mathewOracle/promptbudget && cd promptbudget
225
+ python -m venv .venv && source .venv/bin/activate
226
+ pip install -e ".[dev]"
227
+ pytest && ruff check . && mypy
228
+ ```
229
+
230
+ Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). New strategies are
231
+ especially welcome, and shouldn't require touching the allocator at all.
232
+
233
+ See [CHANGELOG.md](CHANGELOG.md) for release history and
234
+ [RELEASING.md](RELEASING.md) for how versions get published.
235
+
236
+ ## License
237
+
238
+ MIT — see [LICENSE](LICENSE).