tokenfold 0.3.2__tar.gz → 0.3.4__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.
Files changed (37) hide show
  1. {tokenfold-0.3.2 → tokenfold-0.3.4}/Cargo.lock +10 -10
  2. {tokenfold-0.3.2 → tokenfold-0.3.4}/Cargo.toml +1 -1
  3. tokenfold-0.3.2/README.md → tokenfold-0.3.4/PKG-INFO +122 -15
  4. tokenfold-0.3.2/PKG-INFO → tokenfold-0.3.4/README.md +98 -27
  5. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/Cargo.toml +1 -1
  6. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/modes.rs +21 -0
  7. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/pipeline.rs +11 -0
  8. tokenfold-0.3.4/crates/tokenfold-core/src/transforms/log_fold.rs +296 -0
  9. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/transforms/mod.rs +1 -0
  10. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-py/Cargo.toml +1 -1
  11. {tokenfold-0.3.2 → tokenfold-0.3.4}/pyproject.toml +10 -0
  12. {tokenfold-0.3.2 → tokenfold-0.3.4}/LICENSE +0 -0
  13. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/benches/THRESHOLDS.toml +0 -0
  14. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/benches/compression_bench.rs +0 -0
  15. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/examples/quickstart.rs +0 -0
  16. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/budget.rs +0 -0
  17. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/errors.rs +0 -0
  18. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/filters.rs +0 -0
  19. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/input.rs +0 -0
  20. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/lib.rs +0 -0
  21. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/report.rs +0 -0
  22. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/retrieval_store.rs +0 -0
  23. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/safety.rs +0 -0
  24. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/stats.rs +0 -0
  25. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/status.rs +0 -0
  26. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/token_estimator.rs +0 -0
  27. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/transforms/diff.rs +0 -0
  28. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/transforms/json.rs +0 -0
  29. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/transforms/json_dict.rs +0 -0
  30. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/transforms/json_fold.rs +0 -0
  31. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/transforms/logs.rs +0 -0
  32. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/transforms/redaction.rs +0 -0
  33. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/src/transforms/schema.rs +0 -0
  34. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/tests/golden.rs +0 -0
  35. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/tests/integration.rs +0 -0
  36. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-core/tests/property_tests.rs +0 -0
  37. {tokenfold-0.3.2 → tokenfold-0.3.4}/crates/tokenfold-py/src/lib.rs +0 -0
@@ -1013,7 +1013,7 @@ dependencies = [
1013
1013
 
1014
1014
  [[package]]
1015
1015
  name = "tokenfold-adapters"
1016
- version = "0.3.2"
1016
+ version = "0.3.4"
1017
1017
  dependencies = [
1018
1018
  "serde",
1019
1019
  "serde_json",
@@ -1022,7 +1022,7 @@ dependencies = [
1022
1022
 
1023
1023
  [[package]]
1024
1024
  name = "tokenfold-admin"
1025
- version = "0.3.2"
1025
+ version = "0.3.4"
1026
1026
  dependencies = [
1027
1027
  "ed25519-dalek",
1028
1028
  "serde",
@@ -1032,7 +1032,7 @@ dependencies = [
1032
1032
 
1033
1033
  [[package]]
1034
1034
  name = "tokenfold-cli"
1035
- version = "0.3.2"
1035
+ version = "0.3.4"
1036
1036
  dependencies = [
1037
1037
  "clap",
1038
1038
  "serde",
@@ -1045,7 +1045,7 @@ dependencies = [
1045
1045
 
1046
1046
  [[package]]
1047
1047
  name = "tokenfold-core"
1048
- version = "0.3.2"
1048
+ version = "0.3.4"
1049
1049
  dependencies = [
1050
1050
  "proptest",
1051
1051
  "regex",
@@ -1059,25 +1059,25 @@ dependencies = [
1059
1059
 
1060
1060
  [[package]]
1061
1061
  name = "tokenfold-image"
1062
- version = "0.3.2"
1062
+ version = "0.3.4"
1063
1063
 
1064
1064
  [[package]]
1065
1065
  name = "tokenfold-learn"
1066
- version = "0.3.2"
1066
+ version = "0.3.4"
1067
1067
  dependencies = [
1068
1068
  "tokenfold-core",
1069
1069
  ]
1070
1070
 
1071
1071
  [[package]]
1072
1072
  name = "tokenfold-output"
1073
- version = "0.3.2"
1073
+ version = "0.3.4"
1074
1074
  dependencies = [
1075
1075
  "tokenfold-core",
1076
1076
  ]
1077
1077
 
1078
1078
  [[package]]
1079
1079
  name = "tokenfold-proxy"
1080
- version = "0.3.2"
1080
+ version = "0.3.4"
1081
1081
  dependencies = [
1082
1082
  "clap",
1083
1083
  "serde",
@@ -1089,7 +1089,7 @@ dependencies = [
1089
1089
 
1090
1090
  [[package]]
1091
1091
  name = "tokenfold-py"
1092
- version = "0.3.2"
1092
+ version = "0.3.4"
1093
1093
  dependencies = [
1094
1094
  "pyo3",
1095
1095
  "serde_json",
@@ -1098,7 +1098,7 @@ dependencies = [
1098
1098
 
1099
1099
  [[package]]
1100
1100
  name = "tokenfold-rag"
1101
- version = "0.3.2"
1101
+ version = "0.3.4"
1102
1102
 
1103
1103
  [[package]]
1104
1104
  name = "toml"
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/tokenfold-core", "crates/tokenfold-py"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.3.2"
6
+ version = "0.3.4"
7
7
  edition = "2024"
8
8
  rust-version = "1.97"
9
9
  license = "Apache-2.0"
@@ -1,14 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: tokenfold
3
+ Version: 0.3.4
4
+ Classifier: Development Status :: 4 - Beta
5
+ Classifier: Intended Audience :: Developers
6
+ Classifier: License :: OSI Approved :: Apache Software License
7
+ Classifier: Programming Language :: Rust
8
+ Classifier: Programming Language :: Python :: Implementation :: CPython
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Topic :: Text Processing
17
+ License-File: LICENSE
18
+ Summary: Token-compression toolkit for LLM payloads (Python binding over the tokenfold-core Rust crate)
19
+ Keywords: llm,tokens,compression,tokenizer,openai,json,prompt-engineering,cli,token-optimization
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
22
+
1
23
  <div align="center">
2
24
 
3
25
  # TOKENFOLD
4
26
 
5
27
  **Send less noise. Fit more context. Pay for fewer input tokens.**
6
28
 
7
- Local, provider-neutral compression for prompts, tool schemas, JSON, logs, and diffs.
29
+ **46–68% fewer tokens on JSON & schemas · deterministic · reversible · every call audited**
30
+
31
+ CLI · Python · TypeScript · proxy · MCP · local-first · provider-neutral
8
32
 
9
33
  [![CI](https://img.shields.io/github/actions/workflow/status/snchimata/tokenfold/ci.yml?branch=main&label=tests&logo=github&style=flat-square)](https://github.com/snchimata/tokenfold/actions/workflows/ci.yml) [![Coverage](https://img.shields.io/github/actions/workflow/status/snchimata/tokenfold/ci.yml?branch=main&label=coverage&logo=github&style=flat-square)](https://github.com/snchimata/tokenfold/actions/workflows/ci.yml) [![GitHub Release](https://img.shields.io/github/v/release/snchimata/tokenfold?logo=github&style=flat-square)](https://github.com/snchimata/tokenfold/releases/latest) [![PyPI](https://img.shields.io/pypi/v/tokenfold?label=PyPI&style=flat-square)](https://pypi.org/project/tokenfold/) [![npm](https://img.shields.io/npm/v/tokenfold?label=npm&logo=npm&style=flat-square)](https://www.npmjs.com/package/tokenfold) [![Rust](https://img.shields.io/crates/v/tokenfold-core?label=Rust&style=flat-square)](https://docs.rs/crate/tokenfold-core/latest) [![License](https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square)](LICENSE)
10
34
 
11
- [Quick start](#quick-start) · [Why tokenfold](#why-tokenfold) · [Integrations](#pick-your-integration) · [Benchmarks](#reproduce-the-numbers)
35
+ [Quick start](#quick-start) · [Why tokenfold](#why-tokenfold) · [Integrations](#pick-your-integration) · [Benchmarks](#reproduce-the-numbers) · [Select model](#tokenfold-select)
12
36
 
13
37
  </div>
14
38
 
@@ -26,6 +50,22 @@ JSON-data results are lossless. The schema benchmark preserves required
26
50
  fields and descriptions while trimming redundant examples. Repetitive,
27
51
  structured data benefits most.
28
52
 
53
+ ## What it does
54
+
55
+ - **CLI** — `tokenfold compress` / `inspect` files or stdin, and compare
56
+ payloads with `tokenfold diff`; `tokenfold wrap -- <command>` compresses a
57
+ command's output in place.
58
+ - **Library** — the same Rust engine and receipt shape from `pip install tokenfold`
59
+ or `npm install tokenfold`, so behavior never drifts between languages.
60
+ - **Proxy** — `tokenfold-proxy` sits in front of your provider, compresses
61
+ requests, and streams responses through untouched.
62
+ - **MCP server** — `tokenfold mcp serve` exposes compress/inspect/retrieve/stats
63
+ to any MCP-compatible agent or editor.
64
+ - **Receipts, not guesses** — every call returns exact token counts, the
65
+ transforms it applied, and any warnings, so you can audit what changed.
66
+ - **Local and lightweight** — one static Rust binary runs on your machine or
67
+ infrastructure, with no hosted service or additional data processor.
68
+
29
69
  ## Quick start
30
70
 
31
71
  Install the interface that fits your stack:
@@ -37,14 +77,16 @@ cargo add tokenfold-core # Rust library
37
77
  cargo install tokenfold-cli # Rust CLI
38
78
  ```
39
79
 
40
- Or download the `v0.3.1` CLI for Linux, macOS, or Windows from
41
- [GitHub Releases](https://github.com/snchimata/tokenfold/releases/tag/v0.3.1),
80
+ Or download the CLI for Linux, macOS, or Windows from
81
+ [GitHub Releases](https://github.com/snchimata/tokenfold/releases/latest),
42
82
  then verify it with the adjacent `.sha256` file.
43
83
 
44
- Or install the Python package:
84
+ Preview the savings without changing the input, then write the compressed
85
+ payload when you are ready:
45
86
 
46
87
  ```bash
47
- pip install tokenfold
88
+ tokenfold inspect payload.json --format json
89
+ tokenfold compress payload.json --format json --output payload.compact.json
48
90
  ```
49
91
 
50
92
  Compress an OpenAI-style request before sending it to your provider:
@@ -129,6 +171,25 @@ messages · schemas · JSON · logs · diffs
129
171
  └───────────▶ compressed payload + receipt
130
172
  ```
131
173
 
174
+ ## When to use tokenfold · when to look elsewhere
175
+
176
+ **Good fit if you...**
177
+
178
+ - want an exact, auditable receipt for every call instead of an estimate
179
+ - need lossless guarantees on structured data — JSON, schemas, tool
180
+ arguments — before it reaches a billing meter
181
+ - want a portable static binary with deterministic behavior and a small
182
+ operational footprint
183
+ - are shrinking logs, diffs, and repetitive tool output before they hit an
184
+ LLM, a log store, or a queue
185
+
186
+ **Look elsewhere if you...**
187
+
188
+ - need query-aware summarization of unstructured prose — that calls for a
189
+ model, and tokenfold stays deterministic on purpose
190
+ - want a hosted API to call — tokenfold runs entirely on your machine or
191
+ your infrastructure; there is nothing to sign up for
192
+
132
193
  ## What it improves
133
194
 
134
195
  | Workload | User benefit |
@@ -232,19 +293,60 @@ cargo run --release --locked -p tokenfold-cli -- \
232
293
  The sample reports 382 → 206 estimated tokens, a **46.1% reduction**. Ragged
233
294
  or compact inputs may save little; Tokenfold reports that result honestly.
234
295
 
235
- ## Status
296
+ ## Tokenfold Select
236
297
 
237
- Version **0.3.1** is live on every supported distribution channel:
298
+ **When structural compression ends, rank what matters.**
238
299
 
239
- | Ecosystem | Published packages | Artifacts |
300
+ Tokenfold Core removes structural waste. [Tokenfold Select][tokenfold-select]
301
+ is its optional, query-aware companion: a LoRA-fine-tuned model that ranks
302
+ text spans by relevance before you assemble a smaller context.
303
+
304
+ | | Tokenfold Core | Tokenfold Select |
240
305
  | --- | --- | --- |
241
- | Python | [`tokenfold==0.3.1`](https://pypi.org/project/tokenfold/0.3.1/) | Source distribution and ABI3 wheels for Linux x64/ARM64, macOS x64/ARM64, and Windows x64 |
242
- | TypeScript | [`tokenfold@0.3.1`](https://www.npmjs.com/package/tokenfold/v/0.3.1) | Typed Node.js API plus the matching `@tokenfold/cli-*` native package, with no post-install download or local Rust build |
243
- | Rust | [`tokenfold-core`](https://crates.io/crates/tokenfold-core/0.3.1), [`tokenfold-output`](https://crates.io/crates/tokenfold-output/0.3.1), [`tokenfold-learn`](https://crates.io/crates/tokenfold-learn/0.3.1), and [`tokenfold-cli`](https://crates.io/crates/tokenfold-cli/0.3.1) | Source packages published in dependency order |
306
+ | Best at | JSON, schemas, logs, and diffs | Query-conditioned span ranking |
307
+ | Runtime | Static Rust binary | Granite reranker + LoRA adapter |
308
+ | Contract | Compressed payload + auditable receipt | Ranking logits only |
309
+
310
+ Select is deliberately separate from the CLI and libraries. That separation
311
+ lets each tool do one job well: Core provides deterministic transforms and
312
+ auditable receipts; Select adds query-aware ranking when relevance matters.
313
+ Your allocator still owns required-content retention and the hard token
314
+ budget.
244
315
 
245
- [GitHub release `v0.3.1`](https://github.com/snchimata/tokenfold/releases/tag/v0.3.1)
246
- provides SHA-256-checksummed (not signed) CLI binaries for Linux, macOS, and
247
- Windows, plus CycloneDX SBOMs.
316
+ <details>
317
+ <summary><strong>Python: load the model and score spans</strong></summary>
318
+
319
+ ```python
320
+ from pathlib import Path
321
+
322
+ import torch
323
+ from huggingface_hub import snapshot_download
324
+ from peft import PeftModel
325
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
326
+
327
+ base_id = "ibm-granite/granite-embedding-reranker-english-r2"
328
+ repo_dir = Path(snapshot_download("snchimata/tokenfold-select"))
329
+ adapter_dir = repo_dir / "adapter"
330
+ tok = AutoTokenizer.from_pretrained(adapter_dir)
331
+ base = AutoModelForSequenceClassification.from_pretrained(base_id, dtype=torch.float32)
332
+ model = PeftModel.from_pretrained(base, adapter_dir).eval()
333
+
334
+ def score(query: str, spans: list[str]) -> list[float]:
335
+ if not spans:
336
+ return []
337
+ enc = tok([query] * len(spans), spans, padding=True, truncation=True,
338
+ max_length=8192, return_tensors="pt")
339
+ with torch.no_grad():
340
+ out = model(input_ids=enc["input_ids"], attention_mask=enc["attention_mask"])
341
+ return out.logits.view(-1).float().tolist()
342
+ ```
343
+
344
+ </details>
345
+
346
+ See the [Tokenfold Select model card][tokenfold-select] for setup, evaluation,
347
+ training data, and limitations.
348
+
349
+ [tokenfold-select]: https://huggingface.co/snchimata/tokenfold-select
248
350
 
249
351
  ## Contributing
250
352
 
@@ -270,3 +372,8 @@ receipt, and see how many tokens your application can stop sending today.
270
372
  ```bash
271
373
  pip install tokenfold
272
374
  ```
375
+
376
+ If tokenfold earns a place in your stack, a ⭐ on
377
+ [GitHub](https://github.com/snchimata/tokenfold) helps the next team
378
+ find it.
379
+
@@ -1,26 +1,16 @@
1
- Metadata-Version: 2.4
2
- Name: tokenfold
3
- Version: 0.3.2
4
- Classifier: Programming Language :: Rust
5
- Classifier: Programming Language :: Python :: Implementation :: CPython
6
- Classifier: Programming Language :: Python :: 3 :: Only
7
- Classifier: Programming Language :: Python :: 3.9
8
- License-File: LICENSE
9
- Summary: Token-compression toolkit for LLM payloads (Python binding over the tokenfold-core Rust crate)
10
- Requires-Python: >=3.9
11
- Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
12
-
13
1
  <div align="center">
14
2
 
15
3
  # TOKENFOLD
16
4
 
17
5
  **Send less noise. Fit more context. Pay for fewer input tokens.**
18
6
 
19
- Local, provider-neutral compression for prompts, tool schemas, JSON, logs, and diffs.
7
+ **46–68% fewer tokens on JSON & schemas · deterministic · reversible · every call audited**
8
+
9
+ CLI · Python · TypeScript · proxy · MCP · local-first · provider-neutral
20
10
 
21
11
  [![CI](https://img.shields.io/github/actions/workflow/status/snchimata/tokenfold/ci.yml?branch=main&label=tests&logo=github&style=flat-square)](https://github.com/snchimata/tokenfold/actions/workflows/ci.yml) [![Coverage](https://img.shields.io/github/actions/workflow/status/snchimata/tokenfold/ci.yml?branch=main&label=coverage&logo=github&style=flat-square)](https://github.com/snchimata/tokenfold/actions/workflows/ci.yml) [![GitHub Release](https://img.shields.io/github/v/release/snchimata/tokenfold?logo=github&style=flat-square)](https://github.com/snchimata/tokenfold/releases/latest) [![PyPI](https://img.shields.io/pypi/v/tokenfold?label=PyPI&style=flat-square)](https://pypi.org/project/tokenfold/) [![npm](https://img.shields.io/npm/v/tokenfold?label=npm&logo=npm&style=flat-square)](https://www.npmjs.com/package/tokenfold) [![Rust](https://img.shields.io/crates/v/tokenfold-core?label=Rust&style=flat-square)](https://docs.rs/crate/tokenfold-core/latest) [![License](https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square)](LICENSE)
22
12
 
23
- [Quick start](#quick-start) · [Why tokenfold](#why-tokenfold) · [Integrations](#pick-your-integration) · [Benchmarks](#reproduce-the-numbers)
13
+ [Quick start](#quick-start) · [Why tokenfold](#why-tokenfold) · [Integrations](#pick-your-integration) · [Benchmarks](#reproduce-the-numbers) · [Select model](#tokenfold-select)
24
14
 
25
15
  </div>
26
16
 
@@ -38,6 +28,22 @@ JSON-data results are lossless. The schema benchmark preserves required
38
28
  fields and descriptions while trimming redundant examples. Repetitive,
39
29
  structured data benefits most.
40
30
 
31
+ ## What it does
32
+
33
+ - **CLI** — `tokenfold compress` / `inspect` files or stdin, and compare
34
+ payloads with `tokenfold diff`; `tokenfold wrap -- <command>` compresses a
35
+ command's output in place.
36
+ - **Library** — the same Rust engine and receipt shape from `pip install tokenfold`
37
+ or `npm install tokenfold`, so behavior never drifts between languages.
38
+ - **Proxy** — `tokenfold-proxy` sits in front of your provider, compresses
39
+ requests, and streams responses through untouched.
40
+ - **MCP server** — `tokenfold mcp serve` exposes compress/inspect/retrieve/stats
41
+ to any MCP-compatible agent or editor.
42
+ - **Receipts, not guesses** — every call returns exact token counts, the
43
+ transforms it applied, and any warnings, so you can audit what changed.
44
+ - **Local and lightweight** — one static Rust binary runs on your machine or
45
+ infrastructure, with no hosted service or additional data processor.
46
+
41
47
  ## Quick start
42
48
 
43
49
  Install the interface that fits your stack:
@@ -49,14 +55,16 @@ cargo add tokenfold-core # Rust library
49
55
  cargo install tokenfold-cli # Rust CLI
50
56
  ```
51
57
 
52
- Or download the `v0.3.1` CLI for Linux, macOS, or Windows from
53
- [GitHub Releases](https://github.com/snchimata/tokenfold/releases/tag/v0.3.1),
58
+ Or download the CLI for Linux, macOS, or Windows from
59
+ [GitHub Releases](https://github.com/snchimata/tokenfold/releases/latest),
54
60
  then verify it with the adjacent `.sha256` file.
55
61
 
56
- Or install the Python package:
62
+ Preview the savings without changing the input, then write the compressed
63
+ payload when you are ready:
57
64
 
58
65
  ```bash
59
- pip install tokenfold
66
+ tokenfold inspect payload.json --format json
67
+ tokenfold compress payload.json --format json --output payload.compact.json
60
68
  ```
61
69
 
62
70
  Compress an OpenAI-style request before sending it to your provider:
@@ -141,6 +149,25 @@ messages · schemas · JSON · logs · diffs
141
149
  └───────────▶ compressed payload + receipt
142
150
  ```
143
151
 
152
+ ## When to use tokenfold · when to look elsewhere
153
+
154
+ **Good fit if you...**
155
+
156
+ - want an exact, auditable receipt for every call instead of an estimate
157
+ - need lossless guarantees on structured data — JSON, schemas, tool
158
+ arguments — before it reaches a billing meter
159
+ - want a portable static binary with deterministic behavior and a small
160
+ operational footprint
161
+ - are shrinking logs, diffs, and repetitive tool output before they hit an
162
+ LLM, a log store, or a queue
163
+
164
+ **Look elsewhere if you...**
165
+
166
+ - need query-aware summarization of unstructured prose — that calls for a
167
+ model, and tokenfold stays deterministic on purpose
168
+ - want a hosted API to call — tokenfold runs entirely on your machine or
169
+ your infrastructure; there is nothing to sign up for
170
+
144
171
  ## What it improves
145
172
 
146
173
  | Workload | User benefit |
@@ -244,19 +271,60 @@ cargo run --release --locked -p tokenfold-cli -- \
244
271
  The sample reports 382 → 206 estimated tokens, a **46.1% reduction**. Ragged
245
272
  or compact inputs may save little; Tokenfold reports that result honestly.
246
273
 
247
- ## Status
274
+ ## Tokenfold Select
275
+
276
+ **When structural compression ends, rank what matters.**
248
277
 
249
- Version **0.3.1** is live on every supported distribution channel:
278
+ Tokenfold Core removes structural waste. [Tokenfold Select][tokenfold-select]
279
+ is its optional, query-aware companion: a LoRA-fine-tuned model that ranks
280
+ text spans by relevance before you assemble a smaller context.
250
281
 
251
- | Ecosystem | Published packages | Artifacts |
282
+ | | Tokenfold Core | Tokenfold Select |
252
283
  | --- | --- | --- |
253
- | Python | [`tokenfold==0.3.1`](https://pypi.org/project/tokenfold/0.3.1/) | Source distribution and ABI3 wheels for Linux x64/ARM64, macOS x64/ARM64, and Windows x64 |
254
- | TypeScript | [`tokenfold@0.3.1`](https://www.npmjs.com/package/tokenfold/v/0.3.1) | Typed Node.js API plus the matching `@tokenfold/cli-*` native package, with no post-install download or local Rust build |
255
- | Rust | [`tokenfold-core`](https://crates.io/crates/tokenfold-core/0.3.1), [`tokenfold-output`](https://crates.io/crates/tokenfold-output/0.3.1), [`tokenfold-learn`](https://crates.io/crates/tokenfold-learn/0.3.1), and [`tokenfold-cli`](https://crates.io/crates/tokenfold-cli/0.3.1) | Source packages published in dependency order |
284
+ | Best at | JSON, schemas, logs, and diffs | Query-conditioned span ranking |
285
+ | Runtime | Static Rust binary | Granite reranker + LoRA adapter |
286
+ | Contract | Compressed payload + auditable receipt | Ranking logits only |
287
+
288
+ Select is deliberately separate from the CLI and libraries. That separation
289
+ lets each tool do one job well: Core provides deterministic transforms and
290
+ auditable receipts; Select adds query-aware ranking when relevance matters.
291
+ Your allocator still owns required-content retention and the hard token
292
+ budget.
293
+
294
+ <details>
295
+ <summary><strong>Python: load the model and score spans</strong></summary>
296
+
297
+ ```python
298
+ from pathlib import Path
299
+
300
+ import torch
301
+ from huggingface_hub import snapshot_download
302
+ from peft import PeftModel
303
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
304
+
305
+ base_id = "ibm-granite/granite-embedding-reranker-english-r2"
306
+ repo_dir = Path(snapshot_download("snchimata/tokenfold-select"))
307
+ adapter_dir = repo_dir / "adapter"
308
+ tok = AutoTokenizer.from_pretrained(adapter_dir)
309
+ base = AutoModelForSequenceClassification.from_pretrained(base_id, dtype=torch.float32)
310
+ model = PeftModel.from_pretrained(base, adapter_dir).eval()
311
+
312
+ def score(query: str, spans: list[str]) -> list[float]:
313
+ if not spans:
314
+ return []
315
+ enc = tok([query] * len(spans), spans, padding=True, truncation=True,
316
+ max_length=8192, return_tensors="pt")
317
+ with torch.no_grad():
318
+ out = model(input_ids=enc["input_ids"], attention_mask=enc["attention_mask"])
319
+ return out.logits.view(-1).float().tolist()
320
+ ```
321
+
322
+ </details>
323
+
324
+ See the [Tokenfold Select model card][tokenfold-select] for setup, evaluation,
325
+ training data, and limitations.
256
326
 
257
- [GitHub release `v0.3.1`](https://github.com/snchimata/tokenfold/releases/tag/v0.3.1)
258
- provides SHA-256-checksummed (not signed) CLI binaries for Linux, macOS, and
259
- Windows, plus CycloneDX SBOMs.
327
+ [tokenfold-select]: https://huggingface.co/snchimata/tokenfold-select
260
328
 
261
329
  ## Contributing
262
330
 
@@ -283,3 +351,6 @@ receipt, and see how many tokens your application can stop sending today.
283
351
  pip install tokenfold
284
352
  ```
285
353
 
354
+ If tokenfold earns a place in your stack, a ⭐ on
355
+ [GitHub](https://github.com/snchimata/tokenfold) helps the next team
356
+ find it.
@@ -6,7 +6,7 @@ rust-version.workspace = true
6
6
  license.workspace = true
7
7
  repository.workspace = true
8
8
  description = "Token-aware compression for LLM payloads: shrink JSON tool-call bodies, command output, and diffs with exact tiktoken accounting and a typed safety report."
9
- keywords = ["llm", "tokenizer", "compression", "tiktoken", "openai"]
9
+ keywords = ["llm", "tokenizer", "compression", "tiktoken", "token-optimization"]
10
10
  categories = ["compression", "text-processing"]
11
11
 
12
12
  [dependencies]
@@ -15,6 +15,7 @@ pub enum TransformId {
15
15
  JsonFieldFold,
16
16
  JsonValueDict,
17
17
  SchemaCompaction,
18
+ LogFieldFold,
18
19
  LogCompaction,
19
20
  DiffCompaction,
20
21
  }
@@ -26,6 +27,7 @@ impl TransformId {
26
27
  TransformId::JsonFieldFold => "json_field_fold",
27
28
  TransformId::JsonValueDict => "json_value_dict",
28
29
  TransformId::SchemaCompaction => "schema_compaction",
30
+ TransformId::LogFieldFold => "log_field_fold",
29
31
  TransformId::LogCompaction => "log_compaction",
30
32
  TransformId::DiffCompaction => "diff_compaction",
31
33
  }
@@ -140,6 +142,25 @@ pub static ALL_ENTRIES: &[ModeEntry] = &[
140
142
  task_scopes: &[TaskScope::All],
141
143
  applicable_formats: &[InputFormat::OpenAiJson, InputFormat::AnthropicJson],
142
144
  },
145
+ // log_field_fold (v0.4): reversible columnar fold of TEMPLATED log lines — the log-line
146
+ // analogue of json_field_fold (emit each shared line skeleton once + per-line captured fields).
147
+ // Lossless (round-trip gated in the pipeline), so max_ratio is unrestricted (1.0), but like
148
+ // log_compaction it restructures what the model sees, so it stays out of Conservative and ships
149
+ // behind --experimental until its fidelity gate is green (the same path json_field_fold and
150
+ // log_compaction took). Runs before the lossy log_compaction (lossless-before-lossy ordering).
151
+ ModeEntry {
152
+ transform_id: TransformId::LogFieldFold,
153
+ version: "1.0.0",
154
+ conservative_enabled: false,
155
+ balanced_enabled: false,
156
+ aggressive_enabled: false,
157
+ experimental: true,
158
+ max_ratio_conservative: 0.0,
159
+ max_ratio_balanced: 1.0,
160
+ max_ratio_aggressive: 1.0,
161
+ task_scopes: &[TaskScope::All],
162
+ applicable_formats: &[InputFormat::PlainText, InputFormat::CommandOutput],
163
+ },
143
164
  ModeEntry {
144
165
  transform_id: TransformId::LogCompaction,
145
166
  version: "1.0.0",
@@ -366,6 +366,10 @@ fn apply_single_transform(
366
366
  // not that Phase 2 ship distinct values per mode).
367
367
  transforms::schema::compact_schema(bytes, 1).map_err(|e| e.to_string())
368
368
  }
369
+ TransformId::LogFieldFold => {
370
+ let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
371
+ Ok(transforms::log_fold::fold_log(text).into_bytes())
372
+ }
369
373
  TransformId::LogCompaction => {
370
374
  let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
371
375
  Ok(transforms::logs::compact(text, false).into_bytes())
@@ -423,6 +427,13 @@ fn validate_safety(
423
427
  }
424
428
  }
425
429
  }
430
+ // log_field_fold restructures templated log lines into a columnar form, so its safety
431
+ // invariant is exact reversibility: unfolding the output must reproduce the input bytes.
432
+ TransformId::LogFieldFold => {
433
+ if !transforms::log_fold::round_trips(before, after) {
434
+ return false;
435
+ }
436
+ }
426
437
  TransformId::LogCompaction | TransformId::DiffCompaction => {}
427
438
  }
428
439
  safety::protected_segments_present(protected, after)
@@ -0,0 +1,296 @@
1
+ //! `log_field_fold` transform (canonical id `"log_field_fold"`, v1.0.0).
2
+ //!
3
+ //! Content-aware, **losslessly reversible** structural compression for line-oriented text
4
+ //! (`InputFormat::PlainText` / `CommandOutput`). It is the log-line analogue of
5
+ //! [`json_field_fold`](super::json_fold): where that folds arrays of homogeneous *objects* by
6
+ //! emitting each repeated key once, this folds runs of *templated* log lines by emitting each
7
+ //! repeated line **template** once.
8
+ //!
9
+ //! Most log lines share a fixed skeleton and vary only in a few fields (timestamps, ids, counts):
10
+ //!
11
+ //! ```text
12
+ //! 2026-07-01T10:05:11Z req=req-0311 status=200 ms=41
13
+ //! 2026-07-01T10:05:12Z req=req-0312 status=200 ms=44
14
+ //! ```
15
+ //!
16
+ //! Replacing each variable token (a run of digits, or a `0x…` hex literal) with a placeholder
17
+ //! yields one shared template `2026-\x00-\x00T\x00:\x00:\x00Z req=req-\x00 status=\x00 ms=\x00`
18
+ //! plus a per-line tuple of the captured values. The fold emits every distinct template once and
19
+ //! a compact row per original line (`template_id` + its captured values), so the skeleton text is
20
+ //! paid for once instead of per line — a large win on repetitive logs, unlike
21
+ //! [`log_compaction`](super::logs) which only collapses *identical adjacent* lines.
22
+ //!
23
+ //! It is a pure structural rewrite: `unfold_log` reconstructs the original bytes exactly, and the
24
+ //! pipeline gates adoption on that round-trip ([`round_trips`]) — a fold that would ever lose data
25
+ //! (or a genuine input that happens to look like our framing) is rolled back rather than emitted.
26
+
27
+ /// Canonical transform id, as registered with the pipeline.
28
+ pub const TRANSFORM_ID: &str = "log_field_fold";
29
+
30
+ /// Semantic version of this transform's output behavior.
31
+ pub const TRANSFORM_VERSION: &str = "1.0.0";
32
+
33
+ /// First line of the folded blob. Collision-unlikely in real logs; any actual collision is caught
34
+ /// by the pipeline's round-trip safety gate, so it never corrupts data.
35
+ const HEADER: &str = "__tf_logfold1__";
36
+
37
+ /// Placeholder standing in for a captured variable token inside a template.
38
+ const PH: char = '\u{0}';
39
+
40
+ /// Minimum number of lines worth folding, and the maximum distinct-template fraction below which a
41
+ /// fold can pay off. Both are cheap early-outs; the pipeline also rolls back any net token
42
+ /// regression, so these are heuristics, not the correctness boundary.
43
+ const MIN_LINES: usize = 3;
44
+
45
+ use regex::Regex;
46
+ use std::sync::OnceLock;
47
+
48
+ /// Matches a maximal variable token: a `0x…` hex literal or a run of decimal digits. Linear-time
49
+ /// (no backtracking); see `deny.toml`.
50
+ fn var_pattern() -> &'static Regex {
51
+ static RE: OnceLock<Regex> = OnceLock::new();
52
+ RE.get_or_init(|| Regex::new(r"0x[0-9a-fA-F]+|[0-9]+").expect("var_pattern is a valid literal"))
53
+ }
54
+
55
+ /// Replaces each variable token in `segment` with [`PH`], returning the template and the captured
56
+ /// tokens in left-to-right order. `caps` never contain [`PH`], `\n`, or spaces (the pattern only
57
+ /// matches `[0-9a-fA-F]`/`x`), which keeps the row serialization below unambiguous.
58
+ fn templatize(segment: &str) -> (String, Vec<&str>) {
59
+ let re = var_pattern();
60
+ let mut template = String::with_capacity(segment.len());
61
+ let mut caps = Vec::new();
62
+ let mut last = 0;
63
+ for m in re.find_iter(segment) {
64
+ template.push_str(&segment[last..m.start()]);
65
+ template.push(PH);
66
+ caps.push(m.as_str());
67
+ last = m.end();
68
+ }
69
+ template.push_str(&segment[last..]);
70
+ (template, caps)
71
+ }
72
+
73
+ /// Folds runs of templated lines in `input` into a header + a JSON array of distinct templates +
74
+ /// one compact row (`template_id` then captured values) per original line. Returns `input`
75
+ /// unchanged when folding cannot help (too few lines, no shared templates, or a line contains the
76
+ /// placeholder char). Never panics.
77
+ pub fn fold_log(input: &str) -> String {
78
+ if input.is_empty() || input.contains(PH) {
79
+ return input.to_string();
80
+ }
81
+ // split_inclusive keeps each line's trailing '\n' as part of the segment, so reassembly is
82
+ // byte-exact (CRLF, blank lines, and a missing final newline are all preserved).
83
+ let segments: Vec<&str> = input.split_inclusive('\n').collect();
84
+ if segments.len() < MIN_LINES {
85
+ return input.to_string();
86
+ }
87
+
88
+ let mut templates: Vec<String> = Vec::new();
89
+ let mut ids: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
90
+ let mut rows: Vec<(usize, Vec<&str>)> = Vec::with_capacity(segments.len());
91
+ for seg in &segments {
92
+ let (template, caps) = templatize(seg);
93
+ let id = *ids.entry(template.clone()).or_insert_with(|| {
94
+ templates.push(template);
95
+ templates.len() - 1
96
+ });
97
+ rows.push((id, caps));
98
+ }
99
+
100
+ // Only worth it if templates are actually shared (skeleton text amortizes). If every line is
101
+ // its own template there is nothing to save; let the pipeline keep the original.
102
+ if templates.len() >= segments.len() {
103
+ return input.to_string();
104
+ }
105
+
106
+ let mut out = String::with_capacity(input.len() / 2);
107
+ out.push_str(HEADER);
108
+ out.push('\n');
109
+ out.push_str(&serde_json::to_string(&templates).expect("Vec<String> always serializes"));
110
+ for (id, caps) in &rows {
111
+ out.push('\n');
112
+ out.push_str(&id.to_string());
113
+ for cap in caps {
114
+ out.push(' ');
115
+ out.push_str(cap);
116
+ }
117
+ }
118
+ out
119
+ }
120
+
121
+ /// Inverse of [`fold_log`]: expands a folded blob back to the original text. Returns `input`
122
+ /// unchanged if it is not a well-formed folded blob (so a genuine log that merely starts with the
123
+ /// header is not mangled — the pipeline's [`round_trips`] gate makes that safe either way).
124
+ pub fn unfold_log(input: &str) -> String {
125
+ match try_unfold(input) {
126
+ Some(s) => s,
127
+ None => input.to_string(),
128
+ }
129
+ }
130
+
131
+ fn try_unfold(input: &str) -> Option<String> {
132
+ let mut lines = input.split('\n');
133
+ if lines.next()? != HEADER {
134
+ return None;
135
+ }
136
+ let templates: Vec<String> = serde_json::from_str(lines.next()?).ok()?;
137
+ let mut out = String::with_capacity(input.len() * 2);
138
+ for row in lines {
139
+ let mut fields = row.split(' ');
140
+ let id: usize = fields.next()?.parse().ok()?;
141
+ let template = templates.get(id)?;
142
+ let mut caps = fields;
143
+ // Interleave the template's constant segments with the captured values. The number of
144
+ // placeholders must equal the number of captures, else this isn't our framing.
145
+ let mut parts = template.split(PH);
146
+ out.push_str(parts.next()?);
147
+ for part in parts {
148
+ out.push_str(caps.next()?);
149
+ out.push_str(part);
150
+ }
151
+ if caps.next().is_some() {
152
+ return None; // more captures than placeholders → malformed
153
+ }
154
+ }
155
+ Some(out)
156
+ }
157
+
158
+ /// True iff unfolding `after` reproduces `before` exactly. The pipeline's safety gate for this
159
+ /// transform — folding is only adopted when this holds.
160
+ pub fn round_trips(before: &[u8], after: &[u8]) -> bool {
161
+ let (Ok(before_s), Ok(after_s)) = (std::str::from_utf8(before), std::str::from_utf8(after))
162
+ else {
163
+ return false;
164
+ };
165
+ unfold_log(after_s) == before_s
166
+ }
167
+
168
+ #[cfg(test)]
169
+ mod tests {
170
+ use super::*;
171
+
172
+ fn assert_lossless(input: &str) {
173
+ let folded = fold_log(input);
174
+ assert!(
175
+ round_trips(input.as_bytes(), folded.as_bytes()),
176
+ "round_trips() rejected the fold of {input:?}"
177
+ );
178
+ assert_eq!(
179
+ unfold_log(&folded),
180
+ input,
181
+ "unfold != original for {input:?}"
182
+ );
183
+ }
184
+
185
+ #[test]
186
+ fn folds_templated_log_and_emits_skeleton_once() {
187
+ // A realistically-sized run of one skeleton, so the shared template amortizes: the header +
188
+ // template JSON is paid once, the per-line skeleton text is not.
189
+ let input: String = (0..40)
190
+ .map(|i| format!("req=req-{i:04} status=200 ms={}\n", 30 + i % 20))
191
+ .collect();
192
+ let folded = fold_log(&input);
193
+ assert!(
194
+ folded.starts_with(HEADER),
195
+ "expected folded form, got {folded:?}"
196
+ );
197
+ // the shared skeleton word "status" is emitted once (in the single template), not per line.
198
+ assert_eq!(folded.matches("status").count(), 1);
199
+ assert!(
200
+ folded.len() < input.len(),
201
+ "fold ({}) not smaller than input ({})",
202
+ folded.len(),
203
+ input.len()
204
+ );
205
+ assert_lossless(&input);
206
+ }
207
+
208
+ #[test]
209
+ fn preserves_crlf_blank_lines_and_missing_final_newline() {
210
+ assert_lossless("a=1\r\na=2\r\na=3\r\n");
211
+ assert_lossless("x=1\n\nx=2\n\nx=3\n");
212
+ assert_lossless("x=1\nx=2\nx=3"); // no trailing newline
213
+ }
214
+
215
+ #[test]
216
+ fn no_shared_templates_is_left_unchanged() {
217
+ // every line a distinct skeleton → nothing to fold.
218
+ let input = "alpha\nbeta gamma\ndelta epsilon zeta\n";
219
+ assert_eq!(fold_log(input), input);
220
+ }
221
+
222
+ #[test]
223
+ fn fewer_than_min_lines_is_left_unchanged() {
224
+ let input = "req=1 ok\nreq=2 ok\n";
225
+ assert_eq!(fold_log(input), input);
226
+ }
227
+
228
+ #[test]
229
+ fn empty_input_is_a_noop() {
230
+ assert_eq!(fold_log(""), "");
231
+ assert_eq!(unfold_log(""), "");
232
+ }
233
+
234
+ #[test]
235
+ fn lines_with_no_variable_tokens_still_fold_when_identical() {
236
+ // three identical constant lines share one (capture-less) template.
237
+ let input = "heartbeat ok\nheartbeat ok\nheartbeat ok\n";
238
+ assert_lossless(input);
239
+ }
240
+
241
+ #[test]
242
+ fn hex_and_decimal_tokens_both_captured() {
243
+ let input = "addr=0x1f val=10\naddr=0x2a val=20\naddr=0x3b val=30\n";
244
+ assert_lossless(input);
245
+ // the "addr=" / "val=" skeleton is shared → one template.
246
+ assert!(fold_log(input).starts_with(HEADER));
247
+ }
248
+
249
+ #[test]
250
+ fn input_containing_the_placeholder_char_is_not_folded() {
251
+ let input = "a\u{0}b\na\u{0}c\na\u{0}d\n";
252
+ assert_eq!(fold_log(input), input);
253
+ }
254
+
255
+ #[test]
256
+ fn genuine_input_shaped_like_the_header_round_trips_safely() {
257
+ // If real text starts with our header, unfold must not silently corrupt it: fold_log of a
258
+ // 2-line input is a no-op (< MIN_LINES), and round_trips gates the pipeline regardless.
259
+ let input = "__tf_logfold1__\n[\"x\"]\n0 boom";
260
+ let folded = fold_log(input);
261
+ // Either it wasn't folded (identity) or, if it were, the pipeline would only adopt it when
262
+ // round_trips holds — so the original is always recoverable either way.
263
+ assert_eq!(unfold_log(&folded), input);
264
+ }
265
+
266
+ use proptest::prelude::*;
267
+
268
+ // Lines built from a small alphabet of skeletons + digit/hex fields, so templates recur (the
269
+ // regime the fold targets) while still exercising CRLF, blanks, and missing final newlines.
270
+ fn arb_log() -> impl Strategy<Value = String> {
271
+ let line = prop_oneof![
272
+ (0u32..999u32).prop_map(|n| format!("req={n} status=200")),
273
+ (0u32..999u32).prop_map(|n| format!("addr=0x{n:x} ok")),
274
+ Just("heartbeat".to_string()),
275
+ "[a-z ]{0,8}".prop_map(|s| s),
276
+ ];
277
+ (prop::collection::vec(line, 0..40), any::<bool>()).prop_map(|(lines, trailing)| {
278
+ let mut s = lines.join("\n");
279
+ if trailing && !s.is_empty() {
280
+ s.push('\n');
281
+ }
282
+ s
283
+ })
284
+ }
285
+
286
+ proptest! {
287
+ // Core safety guarantee: folding never loses data. For ANY log-ish input, unfolding the
288
+ // folded form reproduces it exactly, and round_trips() (the pipeline's gate) agrees.
289
+ #[test]
290
+ fn fold_then_unfold_is_the_identity(input in arb_log()) {
291
+ let folded = fold_log(&input);
292
+ prop_assert!(round_trips(input.as_bytes(), folded.as_bytes()));
293
+ prop_assert_eq!(unfold_log(&folded), input);
294
+ }
295
+ }
296
+ }
@@ -6,6 +6,7 @@ pub mod diff;
6
6
  pub mod json;
7
7
  pub mod json_dict;
8
8
  pub mod json_fold;
9
+ pub mod log_fold;
9
10
  pub mod logs;
10
11
  pub mod redaction;
11
12
  pub mod schema;
@@ -16,7 +16,7 @@ name = "tokenfold_py"
16
16
  crate-type = ["cdylib", "rlib"]
17
17
 
18
18
  [dependencies]
19
- tokenfold-core = { version = "0.3.2", path = "../tokenfold-core" }
19
+ tokenfold-core = { version = "0.3.4", path = "../tokenfold-core" }
20
20
  serde_json = { workspace = true }
21
21
  pyo3 = { version = "0.29.0", features = ["abi3-py39"] }
22
22
 
@@ -8,11 +8,21 @@ description = "Token-compression toolkit for LLM payloads (Python binding over t
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.9"
10
10
  license = { file = "LICENSE" }
11
+ keywords = ["llm", "tokens", "compression", "tokenizer", "openai", "json", "prompt-engineering", "cli", "token-optimization"]
11
12
  classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: Apache Software License",
12
16
  "Programming Language :: Rust",
13
17
  "Programming Language :: Python :: Implementation :: CPython",
14
18
  "Programming Language :: Python :: 3 :: Only",
15
19
  "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Topic :: Text Processing",
16
26
  ]
17
27
  dynamic = ["version"]
18
28
 
File without changes