inweriok commited on
Commit
a484e22
·
verified ·
1 Parent(s): 0114d2b

Initial release: SpecMem harness (code only, credentials-free)

Browse files
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+
5
+ # benchmark data is downloaded, never committed (see data/README.md)
6
+ data/*
7
+ !data/README.md
8
+
9
+ # experiment outputs
10
+ results/
11
+
12
+ # credentials — never commit keys; pass them via environment variables
13
+ *.env
14
+ secrets/
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SpecMem: Accelerating Agentic Tool Calling via Live Memory Management
2
+
3
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
4
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/)
5
+ [![Paper](https://img.shields.io/badge/paper-under_review-b31b1b.svg)](#citation)
6
+
7
+ Agents spend much of their latency decoding tool calls token by token, yet the
8
+ calls a user needs are highly repetitive across sessions. **SpecMem** turns
9
+ that repetition into speed: it keeps a small, per-user, capacity-bounded memory
10
+ of past tool calls that is updated *live* as the agent runs, and retrieves the
11
+ closest past call as a **draft** for speculative decoding. The served model
12
+ verifies the draft, so outputs are exactly those of standard decoding — wrong
13
+ drafts cost only compute, never correctness.
14
+
15
+ The key finding is that **liveness drives the gain**: a store that keeps
16
+ ingesting and evicting stays fresh as the query distribution drifts, while a
17
+ frozen datastore (built once, then fixed) degrades over sessions.
18
+
19
+ **Highlights**
20
+
21
+ - **Live per-user memory as a drafter.** Top-1 cosine retrieval over
22
+ lightweight query embeddings (`all-MiniLM-L6-v2`, CPU), per-user
23
+ partitioning, LRU eviction at a small fixed capacity, online write-back
24
+ after every verified call. No training, no extra GPU.
25
+ - **End-to-end wall-clock speedups** of 1.62x / 1.74x / 1.18x / 1.69x over
26
+ vanilla autoregressive decoding on API-Bank, ToolAlpaca, BFCL v4, and
27
+ ToolBench, matching or exceeding a faithful frozen-datastore
28
+ (ToolSpec-style) baseline on all four.
29
+ - **Verified across serving stacks and architectures:** `gpt-oss-120b` (MoE)
30
+ and `gemma-4-31B-it` (dense) on sglang, `Nemotron-3-Super-120B`
31
+ (hybrid-SSM) on vLLM — anything with an OpenAI-compatible endpoint works.
32
+ - **Safety-aware speculation:** an idempotency gate defers speculative
33
+ *execution* of irreversible tools (payments, deletes), keeping the speedup
34
+ while avoiding side effects a verifier cannot undo.
35
+
36
+ ## How it works
37
+
38
+ ```
39
+ user query ──> embed ──> per-user memory (capacity-bounded, LRU)
40
+ │ top-1 cosine ≥ τ
41
+
42
+ drafted tool call ──> served model verifies
43
+ ▲ (token-level accept)
44
+
45
+ write-back of the verified call (live update)
46
+ ```
47
+
48
+ Every query is answered once by the served model with greedy decoding (the
49
+ *target*). Each memory policy ("arm") drafts from its own store and is scored
50
+ by the token-level longest common prefix between its draft and the target —
51
+ the accepted-token count a speculative decoder would realize. The compared
52
+ arms:
53
+
54
+ | Arm (code name) | Description |
55
+ |---|---|
56
+ | `no_memory` | schema-only draft; lower bound |
57
+ | `static_global` | one global store frozen after warmup (ToolSpec-style) |
58
+ | `personal_memory` | **SpecMem**: per-user, live-updating, capacity-bounded |
59
+ | `toolspec` | faithful ToolSpec reimplementation (frozen kNN-vote + schema FSM) |
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ git clone <this-repo> specmem && cd specmem
65
+ pip install -r requirements.txt
66
+ bash scripts/download_data.sh # fetches BFCL, Seal-Tools, ToolAlpaca, API-Bank
67
+ ```
68
+
69
+ Benchmark data is downloaded from the official sources, never redistributed
70
+ here; two datasets need a small manual step (ToolBench, tau2-bench) — see
71
+ [`data/README.md`](data/README.md).
72
+
73
+ ## Quickstart
74
+
75
+ **1. Serve a tool-calling model** behind any OpenAI-compatible endpoint, e.g.
76
+
77
+ ```bash
78
+ python -m sglang.launch_server --model-path openai/gpt-oss-120b --port 30000
79
+ ```
80
+
81
+ **2. Run the main acceptance experiment** (3 arms x 40 users x 12 sessions,
82
+ 3 stream seeds — the paper's headline setting):
83
+
84
+ ```bash
85
+ python -m harness.run_accept \
86
+ --users 40 --tasks-per-user 15 --sessions 12 --queries-per-session 6 \
87
+ --capacity 48 --n-seeds 3 --url http://localhost:30000/v1 --tag main
88
+ ```
89
+
90
+ Targets are cached by exact query string, so re-runs and all memory-arm
91
+ replays are GPU-free. Results land in `results/main_accept_results.json`
92
+ (per-session and overall MAT / accepted fraction / exact rate).
93
+
94
+ Add `--benchmark sealtools` for Seal-Tools. For other served models, point
95
+ `--url`/`--model` at the endpoint and `--model-path` (or the
96
+ `SPECMEM_TOKENIZER` env var) at the model's tokenizer so acceptance is
97
+ measured in that model's own tokens.
98
+
99
+ ## Reproducing the paper
100
+
101
+ | Experiment | Command |
102
+ |---|---|
103
+ | Main acceptance table (BFCL / Seal-Tools) | `python -m harness.run_accept ...` (above) |
104
+ | 4-benchmark main table + wall-clock speedups | `python -m harness.phase4_maintable` |
105
+ | Freshness-over-sessions curve | `python -m harness.phase4_partb`, then `python -m harness.phase4_freshness_fig` |
106
+ | Memory-capacity sweep | `python -m harness.capacity_sweep` |
107
+ | Ablations (eviction, sharing, perturbation) | `python -m harness.run_ablation` |
108
+ | Warmup-fraction sweep | `python -m harness.review_r1_warmup` |
109
+ | Provenance (shared vs per-user) | `python -m harness.review_r2_provenance` |
110
+ | Retrieval-threshold sweep | `python -m harness.review_r3_confidence` |
111
+ | Reset / TTL memory-policy arms | `python -m harness.reset_arm`, `python -m harness.ttl_arm` |
112
+ | Suffix-decoding baseline | `python -m harness.phase4_suffixdecoding_maintable` |
113
+ | Throughput / overlap under load | `python -m harness.phase4_throughput`, `python -m harness.phase4_overlap` |
114
+ | Speculative-execution safety gate | `python -m harness.safety` |
115
+ | Bootstrap confidence intervals | `python -m harness.bootstrap_ci` |
116
+ | tau2-bench live traces + scoring | `python -m harness.tau2_live generate / extract / score` |
117
+
118
+ The tau2-bench `generate` mode runs the served model as the agent against a
119
+ live GPT-4.1 user simulator and requires `OPENAI_API_KEY` (and optionally
120
+ `OPENAI_BASE_URL`) in the environment, plus a
121
+ [tau2-bench](https://github.com/sierra-research/tau2-bench) install
122
+ (`TAU2_BIN`, `TAU2_DATA_DIR`). Credentials are read from environment
123
+ variables only and a leak check aborts if a key ever appears in an artifact.
124
+
125
+ ## Repository layout
126
+
127
+ ```
128
+ harness/ all experiment code (run as python -m harness.<module>)
129
+ memory.py memory arms: NoMemory, StaticGlobal, PersonalMemory (SpecMem),
130
+ ToolSpecBaseline, suffix-decoding baseline
131
+ simulate.py multi-session, multi-user query-stream generator
132
+ data.py benchmark loaders (BFCL, Seal-Tools, ToolAlpaca, API-Bank,
133
+ ToolBench, tau2)
134
+ client.py OpenAI-compatible client + tool-call parsers (harmony, XML)
135
+ metrics.py canonicalization + token-LCP acceptance scoring
136
+ run_accept.py main 3-arm acceptance experiment
137
+ ... see the table above for the per-experiment entry points
138
+ scripts/ data download
139
+ data/ benchmark data (downloaded; see data/README.md)
140
+ results/ experiment outputs (created at runtime)
141
+ ```
142
+
143
+ ## Environment variables
144
+
145
+ | Variable | Purpose | Default |
146
+ |---|---|---|
147
+ | `TOOL_SERVER_URL` | served-model endpoint | `http://localhost:30000/v1` |
148
+ | `SPECMEM_TOKENIZER` | tokenizer for the accept metric | `openai/gpt-oss-120b` |
149
+ | `OPENAI_API_KEY` / `OPENAI_BASE_URL` | tau2 user-simulator credentials | — |
150
+ | `TAU2_BIN` / `TAU2_DATA_DIR` | tau2-bench CLI and data locations | `tau2` / — |
151
+
152
+ ## Citation
153
+
154
+ The paper is currently under review. If you use this code, please cite:
155
+
156
+ ```bibtex
157
+ @article{specmem2026,
158
+ title = {SpecMem: Accelerating Agentic Tool Calling via Live Memory Management},
159
+ author = {Anonymous},
160
+ note = {Under review},
161
+ year = {2026}
162
+ }
163
+ ```
164
+
165
+ ## License
166
+
167
+ This repository is released under the [Apache License 2.0](LICENSE).
168
+ Benchmark datasets and served models keep their own licenses (see
169
+ [`data/README.md`](data/README.md)).
data/README.md ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Benchmark data
2
+
3
+ We do not redistribute any third-party benchmark. Run
4
+
5
+ ```bash
6
+ bash scripts/download_data.sh
7
+ ```
8
+
9
+ from the repository root to fetch everything that has a direct official URL.
10
+ Expected layout after download:
11
+
12
+ ```
13
+ data/
14
+ bfcl/ BFCL_v4_simple_python.json BFCL_v4_multiple.json BFCL_v4_parallel.json
15
+ sealtools/ tool.jsonl test_in_domain.jsonl
16
+ toolalpaca/ eval_simulated.json eval_real.json
17
+ apibank/ level-1-api.json level-2-api.json
18
+ toolbench/ test_instruction/G1_instruction.json ... (manual, see below)
19
+ tau2/ tools_airline.json tools_retail.json tools_telecom.json (generated, see below)
20
+ ```
21
+
22
+ ## Provenance and licenses
23
+
24
+ | Dataset | Source | License |
25
+ |---|---|---|
26
+ | BFCL v4 | [ShishirPatil/gorilla](https://github.com/ShishirPatil/gorilla) (`berkeley-function-call-leaderboard/bfcl_eval/data`) | Apache-2.0 |
27
+ | Seal-Tools | [fairyshine/Seal-Tools](https://github.com/fairyshine/Seal-Tools) (`Seal-Tools_Dataset`) | Apache-2.0 |
28
+ | ToolAlpaca | [tangqiaoyu/ToolAlpaca](https://github.com/tangqiaoyu/ToolAlpaca) (`data/`) | Apache-2.0 |
29
+ | API-Bank | [HF: liminghao1630/API-Bank](https://huggingface.co/datasets/liminghao1630/API-Bank) (`test-data/`) | MIT |
30
+ | ToolBench | [OpenBMB/ToolBench](https://github.com/OpenBMB/ToolBench) official Google Drive release | Apache-2.0 |
31
+ | tau2-bench | [sierra-research/tau2-bench](https://github.com/sierra-research/tau2-bench) | MIT |
32
+
33
+ ## Manual steps
34
+
35
+ **ToolBench** (only needed for the 4-benchmark main table,
36
+ `harness/phase4_maintable.py`): download the data archive linked from the
37
+ [OpenBMB/ToolBench README](https://github.com/OpenBMB/ToolBench) (Google
38
+ Drive) and copy `data/test_instruction/G{1,2,3}_*.json` to
39
+ `data/toolbench/test_instruction/`.
40
+
41
+ **tau2-bench** (only needed for the tau2 experiments): the harness needs the
42
+ per-domain tool registries `data/tau2/tools_{domain}.json`. These are
43
+ generated from the tau2-bench repo's domain toolkit sources
44
+ (`src/tau2/domains/<domain>/tools.py`) with `harness/tau2_extract.py`, which
45
+ parses the `@is_tool` methods into BFCL-style JSON schemas. Live trace
46
+ generation additionally requires installing
47
+ [tau2-bench](https://github.com/sierra-research/tau2-bench) itself (the
48
+ `tau2` CLI) — see `harness/tau2_live.py`.
harness/__init__.py ADDED
File without changes
harness/arch_fig.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conceptual/architecture figures for the paper (no model server needed).
2
+
3
+ Produces two vector figures under paper/figures/:
4
+ * architecture.pdf/png -- the three memory arms feeding a draft-then-verify
5
+ speculative decoder (no-memory vs frozen global
6
+ datastore vs our persistent per-user evicting store).
7
+ * safety_gate.pdf/png -- the speculative-execution decision flow contrasting
8
+ naive-exec, confidence-gate, and idempotency-gate.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import matplotlib
15
+ matplotlib.use("Agg")
16
+ import matplotlib.pyplot as plt
17
+ from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
18
+
19
+ ROOT = Path(__file__).resolve().parent.parent
20
+ FIGS = ROOT / "figures"
21
+
22
+ BLUE = "#1b6ca8"
23
+ RED = "#d1495b"
24
+ GREY = "#888888"
25
+ GREEN = "#2e8b57"
26
+ DARK = "#2b2b2b"
27
+
28
+
29
+ def _box(ax, x, y, w, h, text, fc="white", ec=DARK, fs=8.5, lw=1.3, tc=DARK,
30
+ style="round,pad=0.02,rounding_size=0.06", bold=False):
31
+ p = FancyBboxPatch((x, y), w, h, boxstyle=style, fc=fc, ec=ec, lw=lw,
32
+ mutation_aspect=1.0, zorder=2)
33
+ ax.add_patch(p)
34
+ ax.text(x + w / 2, y + h / 2, text, ha="center", va="center", fontsize=fs,
35
+ color=tc, zorder=3, fontweight="bold" if bold else "normal",
36
+ wrap=True)
37
+ return p
38
+
39
+
40
+ def _arrow(ax, xy1, xy2, color=DARK, lw=1.4, style="-|>", ls="-", rad=0.0):
41
+ a = FancyArrowPatch(xy1, xy2, arrowstyle=style, mutation_scale=12,
42
+ color=color, lw=lw, linestyle=ls, zorder=1,
43
+ connectionstyle=f"arc3,rad={rad}")
44
+ ax.add_patch(a)
45
+
46
+
47
+ def architecture():
48
+ fig, ax = plt.subplots(figsize=(10.5, 4.6))
49
+ ax.set_xlim(0, 10.5)
50
+ ax.set_ylim(0, 4.6)
51
+ ax.axis("off")
52
+
53
+ # user query stream (sessions)
54
+ _box(ax, 0.15, 2.0, 1.5, 0.8,
55
+ "User $u$ query\n(session $s$)", fc="#eef3f7", fs=8.5)
56
+ ax.text(0.9, 1.75, "recurring, evolving\nper-user tool use",
57
+ ha="center", va="top", fontsize=6.8, color=GREY, style="italic")
58
+
59
+ # three arms as stacked draft sources
60
+ arm_x = 2.35
61
+ arms = [
62
+ (3.55, GREY, "No memory",
63
+ "schema-only draft\n(zero history)"),
64
+ (2.30, RED, "Static datastore (ToolSpec-style)",
65
+ "one global store,\nbuilt once then FROZEN"),
66
+ (1.05, BLUE, "Ours: persistent per-user memory",
67
+ "grows across sessions,\nLRU/LFU eviction,\npersonalized retrieval"),
68
+ ]
69
+ for y, color, title, sub in arms:
70
+ _box(ax, arm_x, y, 3.0, 1.05, "", ec=color, lw=1.6,
71
+ fc=color + "14" if False else "white")
72
+ ax.text(arm_x + 0.12, y + 0.86, title, ha="left", va="center",
73
+ fontsize=8.2, color=color, fontweight="bold")
74
+ ax.text(arm_x + 0.12, y + 0.36, sub, ha="left", va="center",
75
+ fontsize=7.2, color=DARK)
76
+ _arrow(ax, (1.65, 2.4), (arm_x, y + 0.55), color=color, lw=1.2,
77
+ rad=0.05)
78
+
79
+ # draft -> speculative decoder
80
+ dec_x = 6.15
81
+ _box(ax, dec_x, 1.75, 1.95, 1.3,
82
+ "Draft-then-verify\nspeculative\ndecoder", fc="#fff7e6",
83
+ ec="#c8860a", fs=8.5, bold=True)
84
+ for y, color, *_ in arms:
85
+ _arrow(ax, (arm_x + 3.0, y + 0.55), (dec_x, 2.4), color=color, lw=1.1,
86
+ ls="--", rad=-0.05)
87
+ ax.text(dec_x + 0.98, 3.12, "draft tool call", ha="center", va="bottom",
88
+ fontsize=7, color="#c8860a")
89
+
90
+ # served target model
91
+ _box(ax, dec_x - 0.1, 0.35, 2.15, 0.85,
92
+ "Served target model\n(gpt-oss-120b, greedy)", fc="#f2f2f2", fs=7.6)
93
+ _arrow(ax, (dec_x + 0.95, 1.75), (dec_x + 0.95, 1.2), color=DARK, lw=1.2)
94
+ _arrow(ax, (dec_x + 1.15, 1.2), (dec_x + 1.15, 1.75), color=DARK, lw=1.2)
95
+ ax.text(dec_x + 1.35, 1.47, "verify\n(token LCP)", ha="left", va="center",
96
+ fontsize=6.6, color=DARK)
97
+
98
+ # accepted output + metric
99
+ _box(ax, 8.55, 1.75, 1.8, 1.3,
100
+ "Accepted tokens\n= mean accepted\ntokens (MAT)", fc="#eaf5ee",
101
+ ec=GREEN, fs=8.2, tc=DARK)
102
+ _arrow(ax, (dec_x + 1.95, 2.4), (8.55, 2.4), color=GREEN, lw=1.6)
103
+
104
+ # observe/write-back loop (target grows the personal store)
105
+ _arrow(ax, (dec_x + 0.05, 1.9), (arm_x + 3.0, 1.5), color=BLUE, lw=1.1,
106
+ ls=":", rad=0.28)
107
+ ax.text(5.1, 1.05, "observe target $\\rightarrow$ write back\n"
108
+ "(personal store only; static store frozen after warmup)",
109
+ ha="center", va="center", fontsize=6.8, color=BLUE, style="italic")
110
+
111
+ ax.set_title("Persistent, personalized tool-call memory feeding a "
112
+ "speculative decoder", fontsize=10.5, fontweight="bold")
113
+ fig.tight_layout()
114
+ FIGS.mkdir(parents=True, exist_ok=True)
115
+ fig.savefig(FIGS / "architecture.pdf")
116
+ fig.savefig(FIGS / "architecture.png", dpi=150)
117
+ plt.close(fig)
118
+ print("wrote architecture.{pdf,png}")
119
+
120
+
121
+ def safety_gate():
122
+ fig, ax = plt.subplots(figsize=(9.5, 4.2))
123
+ ax.set_xlim(0, 9.5)
124
+ ax.set_ylim(0, 4.2)
125
+ ax.axis("off")
126
+
127
+ _box(ax, 0.2, 1.75, 1.75, 0.9,
128
+ "Memory proposes\na drafted\ntool call", fc="#eef3f7", fs=8.2)
129
+
130
+ # decision diamond-ish: idempotent?
131
+ _box(ax, 2.35, 1.75, 1.6, 0.9, "Tool\nidempotent?", fc="#fff7e6",
132
+ ec="#c8860a", fs=8.4, bold=True)
133
+ _arrow(ax, (1.95, 2.2), (2.35, 2.2))
134
+
135
+ # idempotent -> always speculatively execute (safe)
136
+ _box(ax, 4.35, 3.0, 2.4, 0.85,
137
+ "Speculatively EXECUTE\n(wrong $\\Rightarrow$ just re-run)",
138
+ fc="#eaf5ee", ec=GREEN, fs=8.0)
139
+ _arrow(ax, (3.95, 2.5), (4.35, 3.35), color=GREEN, rad=0.15)
140
+ ax.text(4.15, 3.05, "yes", fontsize=7.5, color=GREEN, ha="left")
141
+
142
+ # non-idempotent -> policy fork
143
+ ax.text(4.15, 1.55, "no", fontsize=7.5, color=RED, ha="left")
144
+ _box(ax, 4.35, 0.35, 2.4, 2.2, "", ec=RED, lw=1.4, fc="#fdeef1")
145
+ ax.text(5.55, 2.35, "Non-idempotent tool\n(policy decides)",
146
+ ha="center", va="center", fontsize=8.0, color=RED,
147
+ fontweight="bold")
148
+ _arrow(ax, (3.95, 1.95), (4.35, 1.6), color=RED, rad=-0.12)
149
+
150
+ ax.text(4.5, 1.75, "naive:", fontsize=7.6, color=DARK, ha="left",
151
+ fontweight="bold")
152
+ ax.text(4.95, 1.75, "execute anyway", fontsize=7.6, color=RED, ha="left")
153
+ ax.text(4.5, 1.32, "conf-gate:", fontsize=7.6, color=DARK, ha="left",
154
+ fontweight="bold")
155
+ ax.text(5.15, 1.32, "execute if conf$\\geq\\tau$", fontsize=7.6,
156
+ color="#c8860a", ha="left")
157
+ ax.text(4.5, 0.89, "ours:", fontsize=7.6, color=DARK, ha="left",
158
+ fontweight="bold")
159
+ ax.text(4.9, 0.89, "NEVER execute;", fontsize=7.6, color=GREEN, ha="left")
160
+ ax.text(4.9, 0.58, "draft only, wait for verify", fontsize=7.0,
161
+ color=GREEN, ha="left")
162
+
163
+ # outcomes
164
+ _box(ax, 7.15, 2.55, 2.15, 0.95,
165
+ "Irreversible harm\nif draft wrong\n(severity-weighted cost)",
166
+ fc="#fdeef1", ec=RED, fs=7.6, tc=RED)
167
+ _box(ax, 7.15, 0.65, 2.15, 0.95,
168
+ "Zero cost, keeps\nlatency win on\nsafe tools", fc="#eaf5ee",
169
+ ec=GREEN, fs=7.8, tc=DARK)
170
+ _arrow(ax, (6.75, 1.65), (7.15, 2.9), color=RED, rad=0.1, lw=1.2)
171
+ _arrow(ax, (6.75, 0.95), (7.15, 1.1), color=GREEN, rad=-0.05, lw=1.4)
172
+
173
+ ax.set_title("Idempotency-gated speculative execution: naive and "
174
+ "confidence gates still leak irreversible cost",
175
+ fontsize=10.0, fontweight="bold")
176
+ fig.tight_layout()
177
+ FIGS.mkdir(parents=True, exist_ok=True)
178
+ fig.savefig(FIGS / "safety_gate.pdf")
179
+ fig.savefig(FIGS / "safety_gate.png", dpi=150)
180
+ plt.close(fig)
181
+ print("wrote safety_gate.{pdf,png}")
182
+
183
+
184
+ if __name__ == "__main__":
185
+ architecture()
186
+ safety_gate()
harness/bootstrap_ci.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bootstrap/paired CIs + variance decomposition (external-review item 5).
2
+
3
+ Deterministically replays the phase-2 seed-0 stream against CACHED targets
4
+ (no model calls) to obtain per-instance paired (static, personal) accepts,
5
+ then reports:
6
+ - paired bootstrap 95% CI for the post-warmup MAT gap and % gap
7
+ - per-user, per-session, per-signature-task gap distributions
8
+ Writes results/phase2_bootstrap_ci.json.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import random
14
+ from collections import defaultdict
15
+ from pathlib import Path
16
+
17
+ from . import metrics
18
+ from .data import load_bfcl
19
+ from .memory import Embedder, PersonalMemory, StaticGlobal
20
+ from .run_accept import MODEL_PATH, _parse_target
21
+ from .simulate import build_users
22
+
23
+ ROOT = Path(__file__).resolve().parent.parent
24
+ RESULTS = ROOT / "results"
25
+
26
+
27
+ def main():
28
+ metrics.get_tokenizer(MODEL_PATH)
29
+ tasks = load_bfcl()
30
+ embedder = Embedder()
31
+ instances = build_users(tasks, n_users=40, tasks_per_user=15,
32
+ n_sessions=12, queries_per_session=6, seed=0)
33
+ instances.sort(key=lambda x: (x.session, x.user_id))
34
+ targets = json.loads((RESULTS / "phase2_targets_seed0.json").read_text())
35
+
36
+ static, personal = StaticGlobal(), PersonalMemory(capacity=48,
37
+ eviction="lru")
38
+ rows = [] # (user, session, sig, a_static, a_personal)
39
+ cur = -1
40
+ for ins in instances:
41
+ tgt = targets.get(ins.query)
42
+ if tgt is None:
43
+ continue
44
+ if ins.session != cur:
45
+ cur = ins.session
46
+ if cur == 1:
47
+ static.freeze()
48
+ a_s = metrics.score(static.draft(ins.query, ins.functions,
49
+ ins.user_id, embedder),
50
+ tgt)["accept_length"]
51
+ a_p = metrics.score(personal.draft(ins.query, ins.functions,
52
+ ins.user_id, embedder),
53
+ tgt)["accept_length"]
54
+ rows.append((ins.user_id, ins.session, ins.signature_id, a_s, a_p))
55
+ cname, cargs = _parse_target(tgt)
56
+ for a in (static, personal):
57
+ a.observe(ins.query, ins.functions, ins.user_id, cname, cargs,
58
+ embedder)
59
+ if ins.session == 0:
60
+ personal.seed_shared(ins.query, cname, cargs, embedder)
61
+
62
+ post = [r for r in rows if r[1] > 0]
63
+ n = len(post)
64
+ mat_s = sum(r[3] for r in post) / n
65
+ mat_p = sum(r[4] for r in post) / n
66
+
67
+ rng = random.Random(0)
68
+ B = 10_000
69
+ gaps, pct = [], []
70
+ for _ in range(B):
71
+ idx = [rng.randrange(n) for _ in range(n)]
72
+ s = sum(post[i][3] for i in idx) / n
73
+ p = sum(post[i][4] for i in idx) / n
74
+ gaps.append(p - s)
75
+ pct.append(100 * (p - s) / s)
76
+ gaps.sort(); pct.sort()
77
+ ci = lambda xs: (round(xs[int(0.025 * B)], 3), round(xs[int(0.975 * B)], 3))
78
+
79
+ def group_gaps(key):
80
+ g = defaultdict(lambda: [0.0, 0.0, 0])
81
+ for r in post:
82
+ k = key(r); g[k][0] += r[3]; g[k][1] += r[4]; g[k][2] += 1
83
+ vals = sorted((v[1] - v[0]) / v[2] for v in g.values())
84
+ m = len(vals)
85
+ return {"n_groups": m,
86
+ "mean_gap": round(sum(vals) / m, 3),
87
+ "min": round(vals[0], 3), "p25": round(vals[m // 4], 3),
88
+ "median": round(vals[m // 2], 3),
89
+ "p75": round(vals[3 * m // 4], 3), "max": round(vals[-1], 3),
90
+ "groups_with_negative_gap": sum(1 for v in vals if v < 0)}
91
+
92
+ out = {
93
+ "config": {"seed": 0, "targets": "phase2_targets_seed0.json (cached)",
94
+ "n_post_warmup_paired": n, "bootstrap_resamples": B},
95
+ "MAT": {"static": round(mat_s, 3), "personal": round(mat_p, 3),
96
+ "gap": round(mat_p - mat_s, 3),
97
+ "gap_pct": round(100 * (mat_p - mat_s) / mat_s, 2)},
98
+ "paired_bootstrap_95CI": {"gap_MAT": ci(gaps), "gap_pct": ci(pct)},
99
+ "per_user_gap": group_gaps(lambda r: r[0]),
100
+ "per_session_gap": group_gaps(lambda r: r[1]),
101
+ "per_task_gap": group_gaps(lambda r: r[2]),
102
+ }
103
+ (RESULTS / "phase2_bootstrap_ci.json").write_text(json.dumps(out,
104
+ indent=2))
105
+ print(json.dumps(out, indent=2))
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
harness/capacity_sweep.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Capacity sweep for the personal evicting memory (reviewer ask, round 7).
2
+
3
+ Where does eviction start to cost acceptance? Replays the phase-2 seed-0
4
+ instance stream (identical construction; cached greedy targets, so no GPU/model
5
+ calls) through PersonalMemory at several capacities C plus the unbounded
6
+ variant, and reports post-warmup MAT per capacity. Per-user load is
7
+ tasks_per_user=15 signature tasks and 8+66=74 queries across 12 sessions, so
8
+ C=48 barely binds while small C forces heavy eviction.
9
+
10
+ Usage: python -m harness.capacity_sweep (from code/)
11
+ Writes results/phase2_capacity_sweep.json.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from collections import defaultdict
17
+ from pathlib import Path
18
+
19
+ from . import metrics
20
+ from .data import load_bfcl
21
+ from .memory import Embedder, PersonalMemory, PersonalNoEvict
22
+ from .run_accept import MODEL_PATH, _parse_target
23
+ from .simulate import build_users
24
+
25
+ ROOT = Path(__file__).resolve().parent.parent
26
+ RESULTS = ROOT / "results"
27
+
28
+ CAPACITIES = [4, 8, 16, 32, 48]
29
+
30
+
31
+ def main():
32
+ metrics.get_tokenizer(MODEL_PATH)
33
+ tasks = load_bfcl()
34
+ embedder = Embedder()
35
+ instances = build_users(tasks, n_users=40, tasks_per_user=15,
36
+ n_sessions=12, queries_per_session=6, seed=0)
37
+ instances.sort(key=lambda x: (x.session, x.user_id))
38
+ targets = json.loads(
39
+ (RESULTS / "phase2_targets_seed0.json").read_text())
40
+
41
+ arms = [PersonalMemory(capacity=c, eviction="lru") for c in CAPACITIES]
42
+ labels = [f"C={c}" for c in CAPACITIES] + ["unbounded"]
43
+ arms.append(PersonalNoEvict())
44
+
45
+ agg = {lab: defaultdict(list) for lab in labels}
46
+ for ins in instances:
47
+ tgt = targets.get(ins.query)
48
+ if tgt is None:
49
+ continue
50
+ for lab, a in zip(labels, arms):
51
+ draft = a.draft(ins.query, ins.functions, ins.user_id, embedder)
52
+ agg[lab][ins.session].append(metrics.score(draft, tgt))
53
+ name, argd = _parse_target(tgt)
54
+ for lab, a in zip(labels, arms):
55
+ a.observe(ins.query, ins.functions, ins.user_id, name, argd,
56
+ embedder)
57
+ if isinstance(a, PersonalMemory) and ins.session == 0:
58
+ a.seed_shared(ins.query, name, argd, embedder)
59
+
60
+ out = {}
61
+ for lab in labels:
62
+ scores = [x for s, xs in agg[lab].items() if s > 0 for x in xs]
63
+ n = len(scores)
64
+ out[lab] = {
65
+ "n": n,
66
+ "MAT": round(sum(x["accept_length"] for x in scores) / n, 3),
67
+ "exact_rate": round(sum(1 for x in scores if x["exact"]) / n, 4),
68
+ }
69
+ result = {"config": {"users": 40, "tasks_per_user": 15, "sessions": 12,
70
+ "queries_per_session": 6, "seed": 0,
71
+ "targets": "phase2_targets_seed0.json (cached)"},
72
+ "post_warmup_MAT_by_capacity": out}
73
+ (RESULTS / "phase2_capacity_sweep.json").write_text(
74
+ json.dumps(result, indent=2))
75
+ print(json.dumps(result, indent=2))
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()
harness/client.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin client over the sglang OpenAI-compatible endpoint for gpt-oss-120b.
2
+
3
+ Given a query and the offered tool schemas, we ask the genuinely-served model
4
+ to emit a tool call and return the (name, arguments) it produced. This is the
5
+ *target* generation used by the acceptance metric. Generation is greedy
6
+ (temperature 0) so the target is deterministic -- the correct reference for a
7
+ speculative decoder that verifies greedily.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import time
14
+ from typing import Any
15
+
16
+ import os
17
+
18
+ import requests
19
+
20
+ # The sglang server runs on the GPU node; from the GPU node itself localhost
21
+ # works, but from the head/launch node it must be addressed by host. Honor an
22
+ # env override so the harness runs from either place.
23
+ DEFAULT_URL = os.environ.get("TOOL_SERVER_URL", "http://localhost:30000/v1")
24
+
25
+ # gpt-oss harmony tool-call markup, e.g.:
26
+ # ...to=functions.triangle_properties.get <|constrain|>json<|message|>{...}<|call|>
27
+ _HARMONY_NAME = re.compile(r"to=functions\.([A-Za-z0-9_.\-]+)")
28
+ _HARMONY_ARGS = re.compile(r"<\|message\|>(.*?)<\|call\|>", re.DOTALL)
29
+
30
+
31
+ def parse_harmony_content(content: str) -> dict[str, Any] | None:
32
+ """Fallback: extract the first tool call from raw harmony content.
33
+
34
+ sglang's HarmonyParser occasionally leaves the call in `content` instead of
35
+ populating `tool_calls`; this recovers the genuine call the model emitted.
36
+ """
37
+ if not content or "to=functions." not in content:
38
+ return None
39
+ nm = _HARMONY_NAME.search(content)
40
+ if not nm:
41
+ return None
42
+ am = _HARMONY_ARGS.search(content, nm.end())
43
+ raw = am.group(1).strip() if am else "{}"
44
+ try:
45
+ args = json.loads(raw) if raw else {}
46
+ except json.JSONDecodeError:
47
+ args = {"__raw__": raw}
48
+ return {"name": nm.group(1), "arguments": args}
49
+
50
+
51
+ # Nemotron-H / Llama-style XML tool-call markup, emitted in `content` when the
52
+ # server-side parser (hermes) does not recognize it, e.g.:
53
+ # <tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>...
54
+ _XML_FUNC = re.compile(r"<function=([A-Za-z0-9_.\-]+)\s*>")
55
+ _XML_PARAM = re.compile(r"<parameter=([A-Za-z0-9_.\-]+)\s*>(.*?)</parameter>",
56
+ re.DOTALL)
57
+
58
+
59
+ def parse_xml_content(content: str) -> dict[str, Any] | None:
60
+ """Fallback: extract an XML-style ``<function=..><parameter=..>`` tool call.
61
+
62
+ Used for models (e.g. Nemotron-3-Super) whose native tool-call format the
63
+ served parser leaves in ``content``. Values are kept as the model's literal
64
+ strings; since draft and target pass through the same parser, only their
65
+ mutual agreement matters for the acceptance metric.
66
+ """
67
+ if not content or "<function=" not in content:
68
+ return None
69
+ fm = _XML_FUNC.search(content)
70
+ if not fm:
71
+ return None
72
+ # scope parameters to this function block if a closing tag exists
73
+ end = content.find("</function>", fm.end())
74
+ block = content[fm.end():end if end != -1 else None]
75
+ args: dict[str, Any] = {}
76
+ for pm in _XML_PARAM.finditer(block):
77
+ val = pm.group(2).strip()
78
+ # coerce obvious scalars so canonicalization matches JSON tool_calls
79
+ low = val.lower()
80
+ if low in ("true", "false"):
81
+ args[pm.group(1)] = (low == "true")
82
+ else:
83
+ try:
84
+ args[pm.group(1)] = int(val)
85
+ except ValueError:
86
+ try:
87
+ args[pm.group(1)] = float(val)
88
+ except ValueError:
89
+ args[pm.group(1)] = val
90
+ return {"name": fm.group(1), "arguments": args}
91
+
92
+
93
+ # BFCL uses Python-style type names; JSON Schema needs these mappings.
94
+ _TYPE_MAP = {"dict": "object", "float": "number", "integer": "integer",
95
+ "tuple": "array", "list": "array", "string": "string",
96
+ "boolean": "boolean", "bool": "boolean", "int": "integer",
97
+ "number": "number", "array": "array", "object": "object"}
98
+
99
+
100
+ def _sanitize_schema(node: Any) -> Any:
101
+ """Recursively convert BFCL Python types into valid JSON Schema."""
102
+ if isinstance(node, dict):
103
+ out = {}
104
+ for k, v in node.items():
105
+ if k == "type" and isinstance(v, str):
106
+ if v == "any":
107
+ continue # unconstrained -> omit type
108
+ out[k] = _TYPE_MAP.get(v, v)
109
+ else:
110
+ out[k] = _sanitize_schema(v)
111
+ # a "tuple"/"array" with no item schema still needs items for strict
112
+ # validators; leave as-is otherwise.
113
+ return out
114
+ if isinstance(node, list):
115
+ return [_sanitize_schema(x) for x in node]
116
+ return node
117
+
118
+
119
+ def to_openai_tools(functions: list[dict[str, Any]]) -> list[dict]:
120
+ """Convert BFCL function schemas to OpenAI tool schema."""
121
+ tools = []
122
+ for f in functions:
123
+ params = f.get("parameters", {}) or {"type": "object", "properties": {}}
124
+ params = _sanitize_schema(dict(params))
125
+ if params.get("type") in (None, "dict"):
126
+ params["type"] = "object"
127
+ tools.append({
128
+ "type": "function",
129
+ "function": {
130
+ "name": f["name"],
131
+ "description": f.get("description", ""),
132
+ "parameters": params,
133
+ },
134
+ })
135
+ return tools
136
+
137
+
138
+ class ToolClient:
139
+ def __init__(self, url: str = DEFAULT_URL, model: str = "gpt-oss-120b",
140
+ timeout: float = 120.0):
141
+ self.url = url.rstrip("/")
142
+ self.model = model
143
+ self.timeout = timeout
144
+
145
+ def ping(self) -> bool:
146
+ try:
147
+ r = requests.get(f"{self.url}/models", timeout=5)
148
+ return r.status_code == 200
149
+ except Exception:
150
+ return False
151
+
152
+ def generate_call(self, query: str, functions: list[dict[str, Any]],
153
+ retries: int = 3) -> dict[str, Any] | None:
154
+ """Return {'name':..., 'arguments':{...}} for the model's tool call.
155
+
156
+ Returns None if the model declined to call a tool or on hard failure.
157
+ """
158
+ tools = to_openai_tools(functions)
159
+ payload = {
160
+ "model": self.model,
161
+ "messages": [
162
+ {"role": "system", "content":
163
+ "You are a function-calling agent. Call exactly one of the "
164
+ "provided tools to satisfy the user's request."},
165
+ {"role": "user", "content": query},
166
+ ],
167
+ "tools": tools,
168
+ # gpt-oss harmony parser rejects tool_choice="required"
169
+ # (structure_info conflict); it natively emits tool calls with auto.
170
+ "tool_choice": "auto",
171
+ "temperature": 0.0,
172
+ "max_tokens": 512,
173
+ }
174
+ last_err = None
175
+ for attempt in range(retries):
176
+ try:
177
+ r = requests.post(f"{self.url}/chat/completions", json=payload,
178
+ timeout=self.timeout)
179
+ if r.status_code != 200:
180
+ last_err = f"http {r.status_code}: {r.text[:200]}"
181
+ time.sleep(1.5 * (attempt + 1))
182
+ continue
183
+ msg = r.json()["choices"][0]["message"]
184
+ tcs = msg.get("tool_calls") or []
185
+ if not tcs:
186
+ # parser left the call in content -> recover it ourselves.
187
+ # Try gpt-oss harmony markup first, then Nemotron/Llama XML.
188
+ content = msg.get("content") or ""
189
+ return (parse_harmony_content(content)
190
+ or parse_xml_content(content))
191
+ fn = tcs[0]["function"]
192
+ args = fn.get("arguments", "{}")
193
+ if isinstance(args, str):
194
+ try:
195
+ args = json.loads(args) if args.strip() else {}
196
+ except json.JSONDecodeError:
197
+ args = {"__raw__": args}
198
+ return {"name": fn["name"], "arguments": args}
199
+ except Exception as e: # noqa: BLE001
200
+ last_err = str(e)
201
+ time.sleep(1.5 * (attempt + 1))
202
+ raise RuntimeError(f"generate_call failed after {retries}: {last_err}")
harness/crossmodel.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cross-model generalization summary (Phase 3).
2
+
3
+ Reads the per-model acceptance result JSONs (gpt-oss Phase-2 headline + any
4
+ Phase-3 models) and emits:
5
+ * results/phase3_crossmodel.json -- machine-readable comparison
6
+ * paper/figures/crossmodel.{pdf,png} -- grouped bar of personal vs static MAT
7
+ * a LaTeX-ready table printed to stdout
8
+
9
+ No model is re-run here; this only aggregates existing result files. Missing
10
+ model files are skipped (so it works whether or not Nemotron served).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ ROOT = Path(__file__).resolve().parent.parent
18
+ RESULTS = ROOT / "results"
19
+ FIGS = ROOT / "figures"
20
+
21
+ # (display name, results json). row order = table order (gpt-oss first).
22
+ MODELS = [
23
+ ("gpt-oss-120b", RESULTS / "phase2_accept_results.json"),
24
+ ("gemma-4-31B-it", RESULTS / "phase3_gemma_accept_results.json"),
25
+ ("Nemotron-3-Super-120B", RESULTS / "phase3_nemotron_accept_results.json"),
26
+ ]
27
+
28
+
29
+ def _row(path: Path):
30
+ d = json.loads(path.read_text())
31
+ ow = d["overall_post_warmup"]
32
+ per = d["summary"] # per-session, for tail gap
33
+ sessions = sorted(int(s) for s in per["personal_memory"].keys())
34
+ last = str(sessions[-1])
35
+ def mat(arm, block=ow, key=None):
36
+ return block[arm]["MAT"] if key is None else block[arm][key]["MAT"]
37
+ stat, pers, nomem = mat("static_global"), mat("personal_memory"), mat("no_memory")
38
+ gap = 100.0 * (pers - stat) / stat
39
+ tail_stat = per["static_global"][last]["MAT"]
40
+ tail_pers = per["personal_memory"][last]["MAT"]
41
+ tail_gap = 100.0 * (tail_pers - tail_stat) / tail_stat
42
+ return {
43
+ "no_memory": round(nomem, 2),
44
+ "static_global": round(stat, 2),
45
+ "personal_memory": round(pers, 2),
46
+ "gap_pct": round(gap, 1),
47
+ "tail_gap_pct": round(tail_gap, 1),
48
+ "personal_seed_std": ow["personal_memory"].get("MAT_seed_std"),
49
+ "n": ow["personal_memory"]["n"],
50
+ }
51
+
52
+
53
+ def main():
54
+ out = {}
55
+ for name, path in MODELS:
56
+ if path.exists():
57
+ out[name] = _row(path)
58
+ print(f"[ok] {name}: {out[name]}")
59
+ else:
60
+ print(f"[skip] {name}: {path.name} not found")
61
+ (RESULTS / "phase3_crossmodel.json").write_text(json.dumps(out, indent=2))
62
+
63
+ # LaTeX table body
64
+ print("\n% --- LaTeX table rows (personal vs static vs none, +gap) ---")
65
+ for name, r in out.items():
66
+ print(f"{name} & {r['no_memory']:.2f} & {r['static_global']:.2f} & "
67
+ f"{r['personal_memory']:.2f} & $+{r['gap_pct']:.0f}\\%$ & "
68
+ f"$+{r['tail_gap_pct']:.0f}\\%$ \\\\")
69
+
70
+ # grouped bar figure
71
+ try:
72
+ import matplotlib
73
+ matplotlib.use("Agg")
74
+ import matplotlib.pyplot as plt
75
+ import numpy as np
76
+ names = list(out.keys())
77
+ x = np.arange(len(names))
78
+ w = 0.26
79
+ nomem = [out[n]["no_memory"] for n in names]
80
+ stat = [out[n]["static_global"] for n in names]
81
+ pers = [out[n]["personal_memory"] for n in names]
82
+ fig, ax = plt.subplots(figsize=(7.2, 3.6))
83
+ ax.bar(x - w, nomem, w, label="No memory", color="#9e9e9e")
84
+ ax.bar(x, stat, w, label="Static datastore", color="#4C72B0")
85
+ ax.bar(x + w, pers, w, label="Personal evicting (ours)", color="#C44E52")
86
+ ymax = max(pers) * 1.30 # headroom for labels + legend
87
+ ax.set_ylim(0, ymax)
88
+ for xi, n in zip(x, names):
89
+ ax.text(xi + w, out[n]["personal_memory"] + ymax * 0.015,
90
+ f"+{out[n]['gap_pct']:.0f}%", ha="center", fontsize=8,
91
+ color="#C44E52", fontweight="bold")
92
+ ax.set_xticks(x)
93
+ ax.set_xticklabels(names, fontsize=9)
94
+ ax.set_ylabel("Mean accepted tokens (post-warmup)")
95
+ ax.set_title("Personalized evicting memory generalizes across served models")
96
+ ax.legend(fontsize=8, loc="upper center", ncol=3, frameon=False,
97
+ bbox_to_anchor=(0.5, 1.0))
98
+ ax.grid(axis="y", alpha=0.3)
99
+ fig.tight_layout()
100
+ FIGS.mkdir(parents=True, exist_ok=True)
101
+ fig.savefig(FIGS / "crossmodel.pdf")
102
+ fig.savefig(FIGS / "crossmodel.png", dpi=150)
103
+ print(f"\n[fig] wrote {FIGS/'crossmodel.pdf'}")
104
+ except Exception as e: # noqa: BLE001
105
+ print(f"[fig] skipped: {e}")
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
harness/data.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BFCL v4 loading + synthetic per-user session construction.
2
+
3
+ BFCL examples are almost all distinct functions, so
4
+ there is essentially no natural cross-session repetition. To test the
5
+ personalization/persistence claim we synthesize realistic repetition: each
6
+ simulated user is assigned a small set of *signature tasks* (real BFCL
7
+ examples), and across ordered sessions they re-issue those tasks with perturbed
8
+ numeric argument values -- i.e. the same tool used again with new inputs, which
9
+ is exactly the recurring per-user tool usage the memory is meant to exploit.
10
+
11
+ Crucially, we do NOT fabricate the target tool call: every (possibly perturbed)
12
+ query is sent to the genuinely served gpt-oss-120b model, and the model's real
13
+ generation is the target. The perturbation only changes the natural-language
14
+ query; the model decides what to emit.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import random
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "bfcl"
26
+ SEALTOOLS_DIR = Path(__file__).resolve().parent.parent / "data" / "sealtools"
27
+ TAU2_DIR = Path(__file__).resolve().parent.parent / "data" / "tau2"
28
+ TOOLALPACA_DIR = Path(__file__).resolve().parent.parent / "data" / "toolalpaca"
29
+ APIBANK_DIR = Path(__file__).resolve().parent.parent / "data" / "apibank"
30
+ TOOLBENCH_DIR = Path(__file__).resolve().parent.parent / "data" / "toolbench"
31
+
32
+
33
+ def _toolbench_schema(api: dict) -> dict[str, Any]:
34
+ props, required = {}, []
35
+ for p in (api.get("required_parameters") or []):
36
+ props[p["name"]] = {"type": _SEAL_TYPE_MAP.get(str(p.get("type", "str")).lower(),
37
+ "string"),
38
+ "description": str(p.get("description", ""))[:160]}
39
+ if props[p["name"]]["type"] not in ("string", "integer", "number",
40
+ "boolean", "array", "object"):
41
+ props[p["name"]]["type"] = "string"
42
+ required.append(p["name"])
43
+ for p in (api.get("optional_parameters") or []):
44
+ t = _SEAL_TYPE_MAP.get(str(p.get("type", "str")).lower(), "string")
45
+ if t not in ("string", "integer", "number", "boolean", "array", "object"):
46
+ t = "string"
47
+ props[p["name"]] = {"type": t,
48
+ "description": str(p.get("description", ""))[:160]}
49
+ name = str(api.get("api_name", "api")).strip().replace(" ", "_")
50
+ return {"name": name or "api",
51
+ "description": str(api.get("api_description", ""))[:300],
52
+ "parameters": {"type": "dict", "properties": props,
53
+ "required": required}}
54
+
55
+
56
+ def load_toolbench(splits: tuple[str, ...] = ("G1_instruction", "G2_instruction",
57
+ "G3_instruction")) -> list[Task]:
58
+ """ToolBench (Qin et al., 2023, arXiv:2307.16789) as Tasks, from the REAL
59
+ dataset (OpenBMB/ToolBench Google-Drive `data/test_instruction/`). Each item
60
+ has a natural-language `query` and an `api_list` of candidate tools with
61
+ required/optional parameter schemas."""
62
+ tasks: list[Task] = []
63
+ for split in splits:
64
+ f = TOOLBENCH_DIR / "test_instruction" / f"{split}.json"
65
+ if not f.exists():
66
+ continue
67
+ for r in json.loads(f.read_text()):
68
+ q = (r.get("query") or "").strip()
69
+ apis = r.get("api_list") or []
70
+ if not q or len(q) < 8 or not apis:
71
+ continue
72
+ funcs = [_toolbench_schema(a) for a in apis]
73
+ # drop dup / empty-name schemas
74
+ seen, uniq = set(), []
75
+ for fn in funcs:
76
+ if fn["name"] and fn["name"] not in seen:
77
+ seen.add(fn["name"])
78
+ uniq.append(fn)
79
+ if not uniq:
80
+ continue
81
+ tid = f"toolbench_{split}_{r.get('query_id')}"
82
+ tasks.append(Task(id=tid, query=q, functions=uniq, origin_id=tid))
83
+ return tasks
84
+
85
+
86
+ def _toolalpaca_params(desc: str) -> dict[str, Any]:
87
+ """Parse the 'Parameters: {...}' JSON-ish blob out of a ToolAlpaca function
88
+ description into BFCL-style {type, description} properties. Best-effort:
89
+ ToolAlpaca param values are free text ('string. One of: [...]'), so we keep
90
+ the leading type word and stash the rest as the description."""
91
+ m = re.search(r"Parameters:\s*(\{.*?\})\s*(?:\nOutput|$)", desc, re.S)
92
+ props: dict[str, Any] = {}
93
+ if not m:
94
+ return props
95
+ try:
96
+ raw = json.loads(m.group(1))
97
+ except Exception:
98
+ return props
99
+ for pname, pdesc in raw.items():
100
+ head = str(pdesc).split(".")[0].strip().lower()
101
+ typ = head if head in ("string", "integer", "number", "boolean",
102
+ "array", "object") else "string"
103
+ prop: dict[str, Any] = {"type": typ, "description": str(pdesc)[:200]}
104
+ if typ == "array":
105
+ prop["items"] = {"type": "string"}
106
+ props[pname] = prop
107
+ return props
108
+
109
+
110
+ def load_toolalpaca(splits: tuple[str, ...] = ("eval_simulated",
111
+ "eval_real")) -> list[Task]:
112
+ """ToolAlpaca (Tang et al., 2023) as Tasks. Each API's Instructions become
113
+ queries; the API's Function_Description entries become the tool registry
114
+ for that query. Downloaded from github.com/tangqiaoyu/ToolAlpaca."""
115
+ tasks: list[Task] = []
116
+ for split in splits:
117
+ f = TOOLALPACA_DIR / f"{split}.json"
118
+ if not f.exists():
119
+ continue
120
+ for tool in json.loads(f.read_text()):
121
+ fdesc = tool.get("Function_Description") or {}
122
+ if not isinstance(fdesc, dict):
123
+ continue
124
+ funcs = []
125
+ for fname, d in fdesc.items():
126
+ if fname in ("components", "Response"): # doc noise
127
+ continue
128
+ funcs.append({
129
+ "name": fname,
130
+ "description": str(d).split("\n")[0][:300],
131
+ "parameters": {"type": "dict",
132
+ "properties": _toolalpaca_params(str(d)),
133
+ "required": []}})
134
+ if not funcs:
135
+ continue
136
+ name = tool.get("Name", "api")
137
+ for i, instr in enumerate(tool.get("Instructions", []) or []):
138
+ if not instr or len(instr) < 8:
139
+ continue
140
+ tid = f"toolalpaca_{split}_{name}_{i}"
141
+ tasks.append(Task(id=tid, query=instr, functions=funcs,
142
+ origin_id=tid))
143
+ return tasks
144
+
145
+
146
+ @dataclass
147
+ class Task:
148
+ id: str
149
+ query: str # natural-language user request
150
+ functions: list[dict[str, Any]] # tool schemas offered for this query
151
+ origin_id: str # BFCL id this was derived from
152
+
153
+
154
+ def _load_jsonl(path: Path) -> list[dict]:
155
+ return [json.loads(l) for l in path.read_text().splitlines() if l.strip()]
156
+
157
+
158
+ def load_bfcl(categories: tuple[str, ...] = ("simple_python", "multiple",
159
+ "parallel")) -> list[Task]:
160
+ fmap = {
161
+ "simple_python": "BFCL_v4_simple_python.json",
162
+ "multiple": "BFCL_v4_multiple.json",
163
+ "parallel": "BFCL_v4_parallel.json",
164
+ }
165
+ tasks: list[Task] = []
166
+ for cat in categories:
167
+ rows = _load_jsonl(DATA_DIR / fmap[cat])
168
+ for r in rows:
169
+ # question is [[{role, content}, ...]] -- take first user turn.
170
+ turns = r["question"][0]
171
+ user_msg = next((m["content"] for m in turns if m["role"] == "user"),
172
+ turns[0]["content"])
173
+ tasks.append(Task(id=r["id"], query=user_msg,
174
+ functions=r["function"], origin_id=r["id"]))
175
+ return tasks
176
+
177
+
178
+ _SEAL_TYPE_MAP = {"str": "string", "int": "integer", "float": "number",
179
+ "bool": "boolean", "list": "array", "dict": "object"}
180
+
181
+
182
+ def _seal_schema(tool: dict) -> dict[str, Any]:
183
+ """Convert one Seal-Tools registry entry to a BFCL-style function schema.
184
+
185
+ Keep only {type, description} per property: Seal-Tools specs carry
186
+ non-standard keywords (e.g. stringified enums) that strict JSON-schema
187
+ validators in serving stacks reject.
188
+ """
189
+ props = {}
190
+ for pname, spec in (tool.get("parameters") or {}).items():
191
+ typ = _SEAL_TYPE_MAP.get(spec.get("type", "str"),
192
+ spec.get("type", "string"))
193
+ if typ not in ("string", "integer", "number", "boolean",
194
+ "array", "object"):
195
+ typ = "string"
196
+ clean: dict[str, Any] = {"type": typ,
197
+ "description": str(spec.get("description",
198
+ ""))}
199
+ if typ == "array":
200
+ clean["items"] = {"type": "string"}
201
+ props[pname] = clean
202
+ return {
203
+ "name": tool["api_name"],
204
+ "description": tool.get("api_description", ""),
205
+ "parameters": {"type": "dict", "properties": props,
206
+ "required": tool.get("required", [])},
207
+ }
208
+
209
+
210
+ def _apibank_schema(spec: dict) -> dict[str, Any]:
211
+ props = {}
212
+ for pname, p in (spec.get("input_parameters") or {}).items():
213
+ typ = _SEAL_TYPE_MAP.get(str(p.get("type", "str")).lower(),
214
+ str(p.get("type", "string")).lower())
215
+ if typ not in ("string", "integer", "number", "boolean",
216
+ "array", "object"):
217
+ typ = "string"
218
+ prop: dict[str, Any] = {"type": typ,
219
+ "description": str(p.get("description", ""))[:200]}
220
+ if typ == "array":
221
+ prop["items"] = {"type": "string"}
222
+ props[pname] = prop
223
+ return {"name": spec["name"], "description": str(spec.get("description", ""))[:300],
224
+ "parameters": {"type": "dict", "properties": props,
225
+ "required": list(props.keys())}}
226
+
227
+
228
+ def load_apibank(levels: tuple[str, ...] = ("level-1", "level-2")) -> list[Task]:
229
+ """API-Bank (Li et al., 2023, arXiv:2304.08244) as Tasks. Each item's
230
+ `input` dialogue is the query; the API specs embedded in `instruction`
231
+ ('API descriptions: {json}\\n{json}...') become the tool registry.
232
+ Downloaded from HF liminghao1630/API-Bank test-data."""
233
+ tasks: list[Task] = []
234
+ seen = set()
235
+ for lvl in levels:
236
+ f = APIBANK_DIR / f"{lvl}-api.json"
237
+ if not f.exists():
238
+ continue
239
+ for r in json.loads(f.read_text()):
240
+ instr = r.get("instruction", "")
241
+ query = (r.get("input") or "").strip()
242
+ if not query or len(query) < 8:
243
+ continue
244
+ # parse the embedded API-description JSON objects
245
+ funcs = []
246
+ body = instr.split("API descriptions:", 1)
247
+ if len(body) == 2:
248
+ for line in body[1].splitlines():
249
+ line = line.strip()
250
+ if line.startswith('{') and '"name"' in line:
251
+ try:
252
+ spec = json.loads(line)
253
+ funcs.append(_apibank_schema(spec))
254
+ except Exception:
255
+ pass
256
+ if not funcs:
257
+ continue
258
+ tid = f"apibank_{lvl}_{r.get('file','')}_{r.get('id')}"
259
+ if tid in seen:
260
+ continue
261
+ seen.add(tid)
262
+ tasks.append(Task(id=tid, query=query, functions=funcs,
263
+ origin_id=tid))
264
+ return tasks
265
+
266
+
267
+ def load_sealtools(n_distractors: int = 3) -> list[Task]:
268
+ """Load the Seal-Tools in-domain test split (Wu et al., 2024) as Tasks.
269
+
270
+ Second benchmark for the acceptance experiment: same Task interface as
271
+ load_bfcl(), so the simulated-user construction and the 3-arm replay are
272
+ IDENTICAL to the BFCL runs. Each example keeps its first gold API's schema
273
+ and adds ``n_distractors`` deterministic distractor schemas (stable hash of
274
+ the example id), shuffled deterministically so the gold schema's position
275
+ carries no signal. Only ~49% of Seal-Tools queries contain perturbable
276
+ numerals (vs. most BFCL queries), so the per-user recurrence statistics are
277
+ natively different from BFCL -- more exact repeats, less argument drift.
278
+ """
279
+ import hashlib
280
+ registry = {t["api_name"]: t
281
+ for t in _load_jsonl(SEALTOOLS_DIR / "tool.jsonl")}
282
+ names = sorted(registry)
283
+ tasks: list[Task] = []
284
+ for r in _load_jsonl(SEALTOOLS_DIR / "test_in_domain.jsonl"):
285
+ gold = r["calling"][0]["api"]
286
+ if gold not in registry: # all resolve in practice; guard anyway
287
+ continue
288
+ h = int(hashlib.sha256(r["id"].encode()).hexdigest(), 16)
289
+ rng = random.Random(h)
290
+ distractors = [n for n in rng.sample(names, n_distractors + 1)
291
+ if n != gold][:n_distractors]
292
+ funcs = [_seal_schema(registry[n]) for n in [gold] + distractors]
293
+ rng.shuffle(funcs)
294
+ tasks.append(Task(id=r["id"], query=r["query"], functions=funcs,
295
+ origin_id=r["id"]))
296
+ return tasks
297
+
298
+
299
+ def load_tau2(pool_size: int = 900, seed: int = 20260716) -> list[Task]:
300
+ """Load tau2-bench frozen-trajectory decision points (Barres et al., 2025).
301
+
302
+ Third benchmark: multi-turn conversational decision points extracted from
303
+ tau2-bench's shipped reference trajectories (see tau2_extract.py for the
304
+ frozen-conversation design that keeps the 3-arm comparison controlled).
305
+ Each Task's query is a rendered transcript prefix; functions are the full
306
+ tool registry of the task's domain (14 airline / 16 retail tools), so the
307
+ model must also pick the right tool, not just fill arguments. A
308
+ deterministic sample of ``pool_size`` keeps the pool comparable to the
309
+ BFCL (800) / Seal-Tools (700) pools.
310
+ """
311
+ tools = {dom: json.loads((TAU2_DIR / f"tools_{dom}.json").read_text())
312
+ for dom in ("airline", "retail")}
313
+ rows = _load_jsonl(TAU2_DIR / "decision_points.jsonl")
314
+ rng = random.Random(seed)
315
+ rng.shuffle(rows)
316
+ rows = rows[:pool_size]
317
+ return [Task(id=r["id"], query=r["query"],
318
+ functions=tools[r["domain"]], origin_id=r["id"])
319
+ for r in rows]
320
+
321
+
322
+ _NUM_RE = re.compile(r"(?<![\w.])(-?\d+(?:\.\d+)?)(?![\w.])")
323
+
324
+
325
+ def perturb_numeric(query: str, rng: random.Random) -> str:
326
+ """Replace standalone numbers in a query with new values of similar scale.
327
+
328
+ Returns the query unchanged if it contains no substitutable numbers (in
329
+ which case the task recurs as an exact repeat, which is also realistic).
330
+ """
331
+ matches = list(_NUM_RE.finditer(query))
332
+ if not matches:
333
+ return query
334
+ out, last = [], 0
335
+ for m in matches:
336
+ out.append(query[last:m.start()])
337
+ tok = m.group(1)
338
+ if "." in tok:
339
+ base = float(tok)
340
+ lo, hi = max(0.1, base * 0.4), base * 1.9 + 1.0
341
+ out.append(f"{rng.uniform(lo, hi):.1f}")
342
+ else:
343
+ base = int(tok)
344
+ if abs(base) <= 1: # keep tiny counts (0/1/2) stable
345
+ out.append(tok)
346
+ else:
347
+ lo, hi = max(2, int(abs(base) * 0.4)), int(abs(base) * 1.9) + 2
348
+ val = rng.randint(lo, hi)
349
+ out.append(str(-val if base < 0 else val))
350
+ last = m.end()
351
+ out.append(query[last:])
352
+ return "".join(out)
harness/memory.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Three tool-call-memory arms.
2
+
3
+ An arm exposes two operations:
4
+ - draft(query, functions, user_id) -> canonical draft string
5
+ - observe(query, functions, user_id, target_name, target_args) -> None
6
+ record the genuine target so future drafts can reuse it.
7
+
8
+ Arms:
9
+ 1. NoMemory -- schema-only draft (best you can do with zero history).
10
+ 2. StaticGlobal -- ToolSpec-style: one global datastore, built during warmup
11
+ and then FROZEN (no growth, no eviction, no per-user view).
12
+ 3. PersonalMemory (ours) -- per-user store that grows across sessions, with an
13
+ eviction policy (LRU or LFU) capping per-user size, and
14
+ personalized retrieval (query only the user's own store,
15
+ backing off to a small shared store when empty).
16
+
17
+ Retrieval is top-1 cosine similarity over sentence-transformer embeddings of the
18
+ query text. The drafted call is the canonicalized (name, arguments) of the most
19
+ similar past observation.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from collections import OrderedDict
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ import numpy as np
29
+
30
+ from .metrics import canonical_call_str
31
+
32
+
33
+ # --------------------------------------------------------------------------- #
34
+ # Embedding backend
35
+ # --------------------------------------------------------------------------- #
36
+ class Embedder:
37
+ def __init__(self, name: str = "sentence-transformers/all-MiniLM-L6-v2"):
38
+ from sentence_transformers import SentenceTransformer
39
+ self.model = SentenceTransformer(name, device="cpu")
40
+ self._cache: dict[str, np.ndarray] = {}
41
+
42
+ def embed(self, text: str) -> np.ndarray:
43
+ v = self._cache.get(text)
44
+ if v is None:
45
+ v = self.model.encode(text, normalize_embeddings=True)
46
+ v = np.asarray(v, dtype=np.float32)
47
+ self._cache[text] = v
48
+ return v
49
+
50
+
51
+ def schema_draft(functions: list[dict[str, Any]]) -> str:
52
+ """Zero-history draft: the first tool's signature with placeholder args."""
53
+ if not functions:
54
+ return ""
55
+ f = functions[0]
56
+ props = (f.get("parameters", {}) or {}).get("properties", {}) or {}
57
+ required = (f.get("parameters", {}) or {}).get("required", []) or list(props)
58
+ args = {k: None for k in required}
59
+ return canonical_call_str(f["name"], args)
60
+
61
+
62
+ # --------------------------------------------------------------------------- #
63
+ # Datastore entry
64
+ # --------------------------------------------------------------------------- #
65
+ @dataclass
66
+ class Entry:
67
+ emb: np.ndarray
68
+ call: str # canonical target call string
69
+ freq: int = 1 # times this (query-region) has been reinforced
70
+
71
+
72
+ def _best_match(emb: np.ndarray, entries: list[Entry]) -> tuple[int, float]:
73
+ if not entries:
74
+ return -1, -1.0
75
+ mat = np.stack([e.emb for e in entries]) # (N, d), rows unit-norm
76
+ sims = mat @ emb # cosine (emb unit-norm)
77
+ i = int(np.argmax(sims))
78
+ return i, float(sims[i])
79
+
80
+
81
+ # --------------------------------------------------------------------------- #
82
+ # Arms
83
+ # --------------------------------------------------------------------------- #
84
+ class NoMemory:
85
+ name = "no_memory"
86
+
87
+ def draft(self, query, functions, user_id, embedder) -> str:
88
+ return schema_draft(functions)
89
+
90
+ def observe(self, *a, **k):
91
+ return None
92
+
93
+
94
+ class StaticGlobal:
95
+ """ToolSpec-style frozen global datastore."""
96
+ name = "static_global"
97
+
98
+ def __init__(self):
99
+ self.entries: list[Entry] = []
100
+ self.frozen = False
101
+
102
+ def freeze(self):
103
+ self.frozen = True
104
+
105
+ def draft(self, query, functions, user_id, embedder) -> str:
106
+ emb = embedder.embed(query)
107
+ i, _ = _best_match(emb, self.entries)
108
+ if i < 0:
109
+ return schema_draft(functions)
110
+ return self.entries[i].call
111
+
112
+ def observe(self, query, functions, user_id, name, args, embedder) -> None:
113
+ if self.frozen:
114
+ return
115
+ self.entries.append(Entry(embedder.embed(query),
116
+ canonical_call_str(name, args)))
117
+
118
+
119
+ class PersonalMemory:
120
+ """Ours: per-user growing store + eviction + personalization."""
121
+ name = "personal_memory"
122
+
123
+ def __init__(self, capacity: int = 32, eviction: str = "lru",
124
+ sim_threshold: float = 0.35):
125
+ self.capacity = capacity
126
+ self.eviction = eviction # "lru" | "lfu"
127
+ self.sim_threshold = sim_threshold
128
+ # per-user ordered store (insertion/most-recent-use order for LRU)
129
+ self.stores: dict[str, "OrderedDict[int, Entry]"] = {}
130
+ self.shared: list[Entry] = [] # cold-start backoff
131
+ self._next_id = 0
132
+
133
+ def _store(self, user_id) -> "OrderedDict[int, Entry]":
134
+ return self.stores.setdefault(user_id, OrderedDict())
135
+
136
+ def draft(self, query, functions, user_id, embedder) -> str:
137
+ emb = embedder.embed(query)
138
+ store = self._store(user_id)
139
+ entries = list(store.values())
140
+ i, sim = _best_match(emb, entries)
141
+ if i >= 0 and sim >= self.sim_threshold:
142
+ key = list(store.keys())[i]
143
+ entry = store[key]
144
+ if self.eviction == "lru": # mark most-recently-used
145
+ store.move_to_end(key)
146
+ entry.freq += 1
147
+ return entry.call
148
+ # personal store empty / too dissimilar -> shared backoff
149
+ j, sj = _best_match(emb, self.shared)
150
+ if j >= 0 and sj >= self.sim_threshold:
151
+ return self.shared[j].call
152
+ return schema_draft(functions)
153
+
154
+ def observe(self, query, functions, user_id, name, args, embedder) -> None:
155
+ emb = embedder.embed(query)
156
+ call = canonical_call_str(name, args)
157
+ store = self._store(user_id)
158
+ eid = self._next_id
159
+ self._next_id += 1
160
+ store[eid] = Entry(emb, call)
161
+ store.move_to_end(eid)
162
+ self._evict(store)
163
+
164
+ def seed_shared(self, query, name, args, embedder) -> None:
165
+ self.shared.append(Entry(embedder.embed(query),
166
+ canonical_call_str(name, args)))
167
+
168
+ def _evict(self, store: "OrderedDict[int, Entry]") -> None:
169
+ while len(store) > self.capacity:
170
+ if self.eviction == "lru":
171
+ store.popitem(last=False) # drop least-recently-used
172
+ elif self.eviction == "lfu":
173
+ k = min(store, key=lambda x: store[x].freq)
174
+ del store[k]
175
+ else:
176
+ store.popitem(last=False)
177
+
178
+ def total_entries(self) -> int:
179
+ return sum(len(s) for s in self.stores.values())
180
+
181
+
182
+ # --------------------------------------------------------------------------- #
183
+ # Ablation arms (2x2: personalized? x evicting?) -- see run_ablation.py.
184
+ # These decompose PersonalMemory's gain over StaticGlobal into the contribution
185
+ # of per-user partitioning vs. the contribution of online-growth+eviction.
186
+ # --------------------------------------------------------------------------- #
187
+ class PersonalNoEvict(PersonalMemory):
188
+ """[+personalization, -eviction]: per-user store that grows online but is
189
+ never bounded/evicted. Isolates how much of ours' gain is eviction."""
190
+ name = "personal_noevict"
191
+
192
+ def __init__(self, sim_threshold: float = 0.35):
193
+ # capacity = +inf so _evict() never fires.
194
+ super().__init__(capacity=10**9, eviction="lru",
195
+ sim_threshold=sim_threshold)
196
+
197
+
198
+ class GlobalEvict:
199
+ """[-personalization, +online-growth+eviction]: a single GLOBAL store (not
200
+ per-user) that keeps ingesting after warmup and LRU-evicts at a total
201
+ capacity. Isolates how much of ours' gain is per-user partitioning: it
202
+ differs from PersonalMemory only in that retrieval ignores user id."""
203
+ name = "global_evict"
204
+
205
+ def __init__(self, capacity: int = 1920, sim_threshold: float = 0.35):
206
+ self.capacity = capacity
207
+ self.sim_threshold = sim_threshold
208
+ self.store: "OrderedDict[int, Entry]" = OrderedDict()
209
+ self._next_id = 0
210
+
211
+ def draft(self, query, functions, user_id, embedder) -> str:
212
+ emb = embedder.embed(query)
213
+ entries = list(self.store.values())
214
+ i, sim = _best_match(emb, entries)
215
+ if i >= 0 and sim >= self.sim_threshold:
216
+ key = list(self.store.keys())[i]
217
+ self.store.move_to_end(key) # LRU touch
218
+ self.store[key].freq += 1
219
+ return self.store[key].call
220
+ return schema_draft(functions)
221
+
222
+ def observe(self, query, functions, user_id, name, args, embedder) -> None:
223
+ emb = embedder.embed(query)
224
+ eid = self._next_id
225
+ self._next_id += 1
226
+ self.store[eid] = Entry(emb, canonical_call_str(name, args))
227
+ self.store.move_to_end(eid)
228
+ while len(self.store) > self.capacity:
229
+ self.store.popitem(last=False) # drop least-recently-used
230
+
231
+ def seed_shared(self, *a, **k):
232
+ return None
233
+
234
+
235
+ # --------------------------------------------------------------------------- #
236
+ # Phase 4.4 — faithful stronger ToolSpec-style baseline
237
+ # --------------------------------------------------------------------------- #
238
+ def _schema_scaffold(functions, name) -> str:
239
+ """Schema-aware structural draft for a NAMED function: the function's
240
+ required argument keys in canonical (sorted) order with placeholder values.
241
+ This is the structurally-valid fallback a schema-aware FSM emits when
242
+ retrieval is not confident enough to commit a concrete prior call."""
243
+ fn = next((f for f in functions if f.get("name") == name), None)
244
+ if fn is None:
245
+ return schema_draft(functions)
246
+ params = fn.get("parameters", {}) or {}
247
+ props = params.get("properties", {}) or {}
248
+ required = params.get("required", []) or list(props)
249
+ return canonical_call_str(name, {k: None for k in required})
250
+
251
+
252
+ class ToolSpecBaseline:
253
+ """Faithful ToolSpec reproduction (Xia et al., 2026): a *frozen global*
254
+ retrieval store (no eviction, no personalization — the ToolSpec regime)
255
+ with the two ToolSpec mechanisms the simple ``StaticGlobal`` proxy omits:
256
+
257
+ 1. **Confidence-gated retrieval.** Return the nearest stored call verbatim
258
+ only while its similarity clears ``sim_lo``; ``StaticGlobal`` instead
259
+ returns its single nearest neighbour unconditionally, so on a cold /
260
+ far query it drafts a wholly unrelated call.
261
+ 2. **Schema-aware fallback (FSM surrogate).** On a cold miss, rather than
262
+ emitting a random far neighbour we emit a *structurally valid* draft
263
+ for the nearest neighbour's function (its required-arg scaffold in
264
+ canonical order) — the acceptance a schema-constrained decoder
265
+ guarantees on the call's structural tokens even without a value hit.
266
+ This makes the arm **strictly at least as strong as ``StaticGlobal``**:
267
+ identical on confident hits, better on cold misses.
268
+
269
+ ToolSpec has no public code, so the FSM is approximated by this schema-aware
270
+ scaffold. We also tested a
271
+ ``k``-NN summed-similarity vote on the target *function* (retrieval-augmented
272
+ denoising); it *degraded* MAT on these traces because the highly skewed
273
+ telecom workload (one diagnostic call dominates) lets the majority function
274
+ override correct top-1 picks — reported honestly in the write-up, and NOT
275
+ used here. Everything else (frozen, global, eviction-free) matches ToolSpec
276
+ and is deliberately NOT personalized — the property under test.
277
+ """
278
+ name = "toolspec"
279
+
280
+ def __init__(self, sim_lo: float = 0.30):
281
+ self.entries: list[Entry] = []
282
+ self.frozen = False
283
+ self.sim_lo = sim_lo
284
+ self._names: list[str] = [] # parallel function name per entry
285
+
286
+ def freeze(self):
287
+ self.frozen = True
288
+
289
+ def draft(self, query, functions, user_id, embedder) -> str:
290
+ if not self.entries:
291
+ return schema_draft(functions)
292
+ emb = embedder.embed(query)
293
+ i, sim = _best_match(emb, self.entries)
294
+ if sim >= self.sim_lo:
295
+ return self.entries[i].call # confident retrieval hit
296
+ return _schema_scaffold(functions, self._names[i]) # schema-aware miss
297
+
298
+ def observe(self, query, functions, user_id, name, args, embedder) -> None:
299
+ if self.frozen:
300
+ return
301
+ self.entries.append(Entry(embedder.embed(query),
302
+ canonical_call_str(name, args)))
303
+ self._names.append(name)
304
+
305
+ def seed_shared(self, *a, **k):
306
+ return None
307
+
308
+
309
+ # --------------------------------------------------------------------------- #
310
+ # SuffixDecoding baseline (Oliaro et al., NeurIPS 2025; arXiv 2411.04975)
311
+ # --------------------------------------------------------------------------- #
312
+ _TOK_RE = re.compile(r"\w+|[^\w\s]")
313
+ _SEP = " "
314
+
315
+
316
+ def _tokenize(text: str) -> tuple[str, ...]:
317
+ """Word/punctuation-level tokens (lowercased). A deliberate approximation of
318
+ SuffixDecoding's model-BPE tokens: the mechanistic contrast under test is
319
+ *exact token matching vs. embedding similarity*, which this preserves; the
320
+ exact subword vocabulary is not what distinguishes the two arms."""
321
+ return tuple(_TOK_RE.findall(text.lower()))
322
+
323
+
324
+ def _suffix_key(tokens: tuple[str, ...]) -> str:
325
+ """Separator-delimited form so whole-token substring tests never match
326
+ across partial tokens (every boundary is a _SEP)."""
327
+ return _SEP + _SEP.join(tokens) + _SEP
328
+
329
+
330
+ def _longest_suffix_match(q: tuple[str, ...], stored_key: str,
331
+ floor: int) -> int:
332
+ """Longest k>floor such that the k-token *suffix* of the current query q is
333
+ a contiguous whole-token substring of a stored sequence (its suffix key).
334
+ Returns 0 if no suffix longer than `floor` matches. This is SuffixDecoding's
335
+ 'walk the tree to the node matching the context suffix' step, adapted to our
336
+ per-request query context.
337
+
338
+ Uses binary search: the predicate ``q[-k:] is a substring of stored`` is
339
+ monotonic in k (if the k-token suffix matches, every shorter suffix does),
340
+ so we find the largest matching k in O(log|q|) containment tests rather than
341
+ O(|q|) — essential because dialogue-context queries run to hundreds of
342
+ tokens."""
343
+ best = 0
344
+ lo, hi = floor + 1, len(q)
345
+ while lo <= hi:
346
+ mid = (lo + hi) // 2
347
+ cand = _SEP + _SEP.join(q[-mid:]) + _SEP
348
+ if cand in stored_key:
349
+ best = mid
350
+ lo = mid + 1
351
+ else:
352
+ hi = mid - 1
353
+ return best
354
+
355
+
356
+ class SuffixDecodingBaseline:
357
+ """Faithful adaptation of **SuffixDecoding** (Oliaro et al., *SuffixDecoding:
358
+ Extreme Speculative Decoding for Emerging AI Applications*, NeurIPS 2025
359
+ Spotlight; arXiv 2411.04975) as a retrieval arm.
360
+
361
+ SuffixDecoding keeps a **global suffix tree accumulated from previous
362
+ requests' token streams** (live/growing across the deployment, so request N
363
+ benefits from request N-1), matches the **suffix of the current context**
364
+ against the tree at each step, and speculates the highest-frequency
365
+ continuation with adaptive length. It is **token-level (exact match),
366
+ global, and NOT personalized**; the tree is **size-capped** (~10.75 B/token,
367
+ ~31 days on a 144 GB host), not unbounded.
368
+
369
+ We reproduce that regime and swap **exactly one mechanism** vs. our own
370
+ ``GlobalEvict`` arm: retrieval is **longest-token-suffix match** on the query
371
+ instead of embedding cosine similarity. Everything else — global live
372
+ write-back, canonicalization, a size cap, no per-user partitioning — is held
373
+ identical, so the comparison isolates *retrieval mechanism* (exact token
374
+ match vs. semantic embedding), not confounds like different data pools or
375
+ personalization. Frequency and recency break ties in match length, mirroring
376
+ SuffixDecoding's frequency-ranked tree paths.
377
+
378
+ Faithful vs. adapted:
379
+ - Faithful: global, live-growing, size-capped, non-personalized store;
380
+ exact token-suffix matching; frequency-ranked selection.
381
+ - Adapted: we match the *query* token context (which selects a tool call
382
+ in our one-call-per-request setting) rather than a running generation,
383
+ and our token-LCP acceptance already truncates the speculated call at the
384
+ first mismatch, subsuming SuffixDecoding's adaptive speculation length.
385
+ Tokenization is word/punct level, not the model BPE (approximation).
386
+ """
387
+ name = "suffixdecoding"
388
+
389
+ def __init__(self, capacity: int = 1920, min_match: int = 1):
390
+ # capacity matches GlobalEvict's total footprint (U*C = 40*48) for a
391
+ # same-size comparison; min_match=1 lets any shared suffix token yield a
392
+ # (frequency-ranked) speculation, as SuffixDecoding's tree always does.
393
+ self.capacity = capacity
394
+ self.min_match = min_match
395
+ # eid -> [q_tokens, suffix_key, call, freq]
396
+ self.store: "OrderedDict[int, list]" = OrderedDict()
397
+ self._next_id = 0
398
+
399
+ def draft(self, query, functions, user_id, embedder) -> str:
400
+ q = _tokenize(query)
401
+ if not q:
402
+ return schema_draft(functions)
403
+ floor = self.min_match - 1
404
+ best_key, best_eid = None, None # rank by (match_len, freq), then recency
405
+ for eid, rec in self.store.items():
406
+ m = _longest_suffix_match(q, rec[1], floor) # true length (fixed floor)
407
+ if m < self.min_match:
408
+ continue
409
+ key = (m, rec[3])
410
+ if best_key is None or key >= best_key: # >= => later (more recent) wins ties
411
+ best_key, best_eid = key, eid
412
+ if best_eid is None:
413
+ return schema_draft(functions)
414
+ rec = self.store[best_eid]
415
+ rec[3] += 1 # frequency reinforcement
416
+ self.store.move_to_end(best_eid) # recency touch
417
+ return rec[2]
418
+
419
+ def observe(self, query, functions, user_id, name, args, embedder) -> None:
420
+ q = _tokenize(query)
421
+ eid = self._next_id
422
+ self._next_id += 1
423
+ self.store[eid] = [q, _suffix_key(q), canonical_call_str(name, args), 1]
424
+ self.store.move_to_end(eid)
425
+ while len(self.store) > self.capacity:
426
+ self.store.popitem(last=False) # drop oldest (size cap)
427
+
428
+ def seed_shared(self, *a, **k):
429
+ return None
430
+
431
+ def total_entries(self) -> int:
432
+ return len(self.store)
harness/metrics.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Speculative-decoding acceptance metric.
2
+
3
+ Given a *draft* tool call string and the model's genuine *target* tool call
4
+ string, both serialized in the same canonical form, we tokenize each with the
5
+ served model's HF tokenizer and compute the token-level longest-common-prefix
6
+ (LCP) accept length -- exactly the quantity a speculative decoder would accept
7
+ when it proposes the draft and the target model verifies it greedily.
8
+
9
+ We report:
10
+ - accept_length : # tokens of the draft accepted before the first mismatch.
11
+ - target_len : # tokens in the target (upper bound on accept_length).
12
+ - accepted_frac : accept_length / target_len (in [0, 1]).
13
+
14
+ This is a *proxy* for true spec-decoding: we compare canonicalized tool-call
15
+ strings rather than the raw harmony token stream. Because every arm and every
16
+ session is scored through the identical canonicalizer + tokenizer, cross-arm
17
+ and cross-session comparisons (the actual claims of the paper) are valid; only
18
+ the absolute MAT should be read as a proxy. This caveat is documented in the
19
+ paper's limitations section.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from functools import lru_cache
25
+ from typing import Any
26
+
27
+
28
+ def canonical_call_str(name: str, arguments: dict[str, Any]) -> str:
29
+ """Serialize a tool call to a deterministic canonical string.
30
+
31
+ Keys are sorted so that the draft and target are compared on identical
32
+ surface form; argument values are JSON-encoded compactly. Both drafts and
33
+ targets pass through this same function, so LCP is well defined and fair.
34
+ """
35
+ try:
36
+ args = {k: arguments[k] for k in sorted(arguments.keys())}
37
+ except Exception:
38
+ args = arguments
39
+ return json.dumps({"name": name, "arguments": args}, sort_keys=True,
40
+ ensure_ascii=False, separators=(", ", ": "))
41
+
42
+
43
+ _TOKENIZER = None
44
+
45
+
46
+ def get_tokenizer(model_path: str):
47
+ global _TOKENIZER
48
+ if _TOKENIZER is None:
49
+ from transformers import AutoTokenizer
50
+ try:
51
+ _TOKENIZER = AutoTokenizer.from_pretrained(model_path)
52
+ except (ValueError, OSError, KeyError):
53
+ # some models (e.g. Nemotron-H) ship a custom tokenizer class
54
+ _TOKENIZER = AutoTokenizer.from_pretrained(
55
+ model_path, trust_remote_code=True)
56
+ return _TOKENIZER
57
+
58
+
59
+ @lru_cache(maxsize=200_000)
60
+ def _encode(text: str) -> tuple[int, ...]:
61
+ assert _TOKENIZER is not None, "call get_tokenizer(model_path) first"
62
+ return tuple(_TOKENIZER.encode(text, add_special_tokens=False))
63
+
64
+
65
+ def accept_length(draft: str, target: str) -> tuple[int, int]:
66
+ """Return (accept_length, target_len) in tokens via token-level LCP."""
67
+ dt = _encode(draft)
68
+ tt = _encode(target)
69
+ n = 0
70
+ for a, b in zip(dt, tt):
71
+ if a != b:
72
+ break
73
+ n += 1
74
+ return n, len(tt)
75
+
76
+
77
+ def score(draft: str, target: str) -> dict[str, float]:
78
+ acc, tlen = accept_length(draft, target)
79
+ return {
80
+ "accept_length": acc,
81
+ "target_len": tlen,
82
+ "accepted_frac": (acc / tlen) if tlen else 0.0,
83
+ "exact": bool(tlen and acc == tlen),
84
+ }
harness/phase4_freshness_fig.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render the freshness-over-time figure from
2
+ results/phase4_freshness_curve.json -> figures/freshness_curve.pdf."""
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import matplotlib
7
+ matplotlib.use("Agg")
8
+ import matplotlib.pyplot as plt
9
+
10
+ ROOT = Path(__file__).resolve().parent.parent
11
+
12
+
13
+ def main():
14
+ r = json.loads((ROOT / "results" / "phase4_freshness_curve.json").read_text())
15
+ c = r["per_session_MAT"]
16
+ S = list(range(12))
17
+
18
+ def y(arm):
19
+ return [c[arm][str(s)] for s in S]
20
+
21
+ fig, ax = plt.subplots(1, 2, figsize=(9.4, 3.3),
22
+ gridspec_kw={"width_ratios": [1.5, 1]})
23
+
24
+ # left: per-session MAT
25
+ ax[0].axvspan(-0.4, 0.4, color="0.92", zorder=0)
26
+ ax[0].text(0.0, 4.6, "warmup", ha="center", va="bottom", fontsize=7.5,
27
+ color="0.4")
28
+ ax[0].plot(S, y("personal_memory"), "-o", color="#0E7C86", lw=2.2, ms=4,
29
+ label="SpecMem (ours, live)")
30
+ ax[0].plot(S, y("static_global"), "-s", color="#B9820B", lw=1.8, ms=3.5,
31
+ label="static datastore (frozen)")
32
+ ax[0].plot(S, y("no_memory"), "-^", color="#9198A1", lw=1.4, ms=3,
33
+ label="Vanilla AR (no memory)")
34
+ # shade the widening gap after warmup
35
+ ax[0].fill_between(S[1:], y("static_global")[1:], y("personal_memory")[1:],
36
+ color="#0E7C86", alpha=0.10, zorder=0)
37
+ ax[0].set_xlabel("session index")
38
+ ax[0].set_ylabel("mean accepted tokens (MAT)")
39
+ ax[0].set_xticks(S)
40
+ ax[0].set_ylim(0, 34)
41
+ ax[0].legend(fontsize=7.6, loc="lower right", framealpha=0.9)
42
+ ax[0].set_title("Freshness over time ($\\tau^2$-bench, 3 domains)",
43
+ fontsize=9.5)
44
+ ax[0].grid(True, alpha=0.25)
45
+
46
+ # right: growing relative advantage
47
+ rel = r["personal_over_static_pct_by_session"]
48
+ xs = sorted(int(k) for k in rel)
49
+ ax[1].plot(xs, [rel[str(k)] for k in xs], "-o", color="#0E7C86", lw=2.0,
50
+ ms=4)
51
+ ax[1].set_xlabel("session index")
52
+ ax[1].set_ylabel("SpecMem over frozen (%)")
53
+ ax[1].set_xticks(xs)
54
+ ax[1].set_ylim(0, 40)
55
+ ax[1].set_title("Advantage grows as\nnovel calls accumulate", fontsize=9.5)
56
+ ax[1].grid(True, alpha=0.25)
57
+
58
+ fig.tight_layout()
59
+ out = ROOT / "figures" / "freshness_curve.pdf"
60
+ out.parent.mkdir(exist_ok=True)
61
+ fig.savefig(out, bbox_inches="tight")
62
+ fig.savefig(out.with_suffix(".png"), dpi=200, bbox_inches="tight")
63
+ print("wrote", out)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
harness/phase4_maintable.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 4 MAIN TABLE (Part A) — 4 arms x 4 real datasets, full metrics.
2
+
3
+ Rows/arms (reuse existing, no reimplementation):
4
+ no_memory = Vanilla AR (speedup reference)
5
+ toolspec = faithful ToolSpec (Phase 4.4: confidence-gated retrieval + FSM)
6
+ static_global = ToolSpec + static memory (simple frozen proxy)
7
+ personal_memory= SpecMem (ours: live, evicting, per-user)
8
+
9
+ Columns/datasets (all REAL): API-Bank, ToolAlpaca, BFCLv4, ToolBench.
10
+
11
+ Per (arm, dataset) cell we record:
12
+ 1. MAT (mean accepted tokens) -- replay
13
+ 2. tokens/s (real decode throughput) -- wall-clock timing
14
+ 3. e2e wall-clock speedup vs Vanilla AR -- wall-clock timing (p50)
15
+ 4. retrieval / write-back overhead ms -- timed embed+lookup / embed+insert
16
+ 5. memory size (entries) at run end -- replay
17
+ 6. cold-start vs long-term MAT curve -- per-session MAT (replay)
18
+
19
+ Targets are the served gpt-oss-120b's greedy calls (real, cached per dataset+seed).
20
+ Run from the repo root: python -m harness.phase4_maintable --url http://localhost:30000/v1
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import random
28
+ import time
29
+ from collections import defaultdict
30
+ from pathlib import Path
31
+
32
+ import requests
33
+
34
+ from . import metrics
35
+ from .client import ToolClient
36
+ from .data import (load_apibank, load_bfcl, load_toolalpaca, load_toolbench)
37
+ from .memory import (Embedder, NoMemory, PersonalMemory, StaticGlobal,
38
+ ToolSpecBaseline)
39
+ from .run_accept import _parse_target, generate_targets
40
+ from .simulate import build_users
41
+
42
+ ROOT = Path(__file__).resolve().parent.parent
43
+ RESULTS = ROOT / "results"
44
+ # Tokenizer for the token-LCP accept metric: HF hub id by default;
45
+ # override with a local snapshot path if running offline.
46
+ MODEL_PATH = os.environ.get("SPECMEM_TOKENIZER", "openai/gpt-oss-120b")
47
+ DATASETS = {"apibank": load_apibank, "toolalpaca": load_toolalpaca,
48
+ "bfcl": load_bfcl, "toolbench": load_toolbench}
49
+ ARMS = ["no_memory", "toolspec", "static_global", "personal_memory"]
50
+
51
+
52
+ def _make_arms(cap):
53
+ return [NoMemory(), ToolSpecBaseline(), StaticGlobal(),
54
+ PersonalMemory(capacity=cap, eviction="lru")]
55
+
56
+
57
+ def _mem_size(arm):
58
+ if isinstance(arm, PersonalMemory):
59
+ return sum(len(s) for s in arm.stores.values())
60
+ if isinstance(arm, (StaticGlobal, ToolSpecBaseline)):
61
+ return len(arm.entries)
62
+ return 0
63
+
64
+
65
+ def _replay(instances, targets, emb, cap):
66
+ """3-arm+baseline replay; return per-arm MAT, per-session MAT, mem size."""
67
+ arms = _make_arms(cap)
68
+ agg = {a.name: defaultdict(list) for a in arms}
69
+ cur = -1
70
+ for ins in instances:
71
+ tgt = targets.get(ins.query)
72
+ if tgt is None:
73
+ continue
74
+ if ins.session != cur:
75
+ cur = ins.session
76
+ if cur == 1:
77
+ for a in arms:
78
+ if hasattr(a, "freeze"):
79
+ a.freeze()
80
+ for a in arms:
81
+ agg[a.name][ins.session].append(metrics.score(
82
+ a.draft(ins.query, ins.functions, ins.user_id, emb), tgt))
83
+ cn, ca = _parse_target(tgt)
84
+ for a in arms[1:]:
85
+ a.observe(ins.query, ins.functions, ins.user_id, cn, ca, emb)
86
+ if isinstance(a, PersonalMemory) and ins.session == 0:
87
+ a.seed_shared(ins.query, cn, ca, emb)
88
+ out = {}
89
+ for a in arms:
90
+ v = agg[a.name]
91
+ post = [x for s, xs in v.items() if s > 0 for x in xs]
92
+ by_sess = {s: round(sum(x["accept_length"] for x in xs) / len(xs), 3)
93
+ for s, xs in sorted(v.items()) if xs}
94
+ out[a.name] = {
95
+ "MAT": round(sum(x["accept_length"] for x in post) /
96
+ max(1, len(post)), 3),
97
+ "accepted_frac": round(sum(x["accepted_frac"] for x in post) /
98
+ max(1, len(post)), 3),
99
+ "by_session": by_sess, "mem_entries": _mem_size(a)}
100
+ return out
101
+
102
+
103
+ def _timed_gen(chat, prompt, ntok):
104
+ t0 = time.perf_counter()
105
+ r = requests.post(chat, json={"model": "gpt-oss-120b", "temperature": 0.0,
106
+ "max_tokens": ntok, "ignore_eos": True,
107
+ "messages": [{"role": "user",
108
+ "content": prompt}]}, timeout=180)
109
+ dt = (time.perf_counter() - t0) * 1000
110
+ r.raise_for_status()
111
+ return dt
112
+
113
+
114
+ def _wallclock(instances, targets, emb, cap, chat, sample, seed):
115
+ """Real spec-decode wall-clock per arm on a sample: p50/p95 + speedup +
116
+ tokens/s. Rebuild arm accept-lengths at each sampled point via replay."""
117
+ arms = _make_arms(cap)
118
+ pts, cur = [], -1
119
+ for ins in instances:
120
+ tgt = targets.get(ins.query)
121
+ if tgt is None:
122
+ continue
123
+ if ins.session != cur:
124
+ cur = ins.session
125
+ if cur == 1:
126
+ for a in arms:
127
+ if hasattr(a, "freeze"):
128
+ a.freeze()
129
+ if ins.session > 0:
130
+ row = {"query": ins.query, "T": metrics.accept_length(tgt, tgt)[1]}
131
+ for a in arms:
132
+ row[a.name] = metrics.accept_length(
133
+ a.draft(ins.query, ins.functions, ins.user_id, emb), tgt)[0]
134
+ pts.append(row)
135
+ cn, ca = _parse_target(tgt)
136
+ for a in arms[1:]:
137
+ a.observe(ins.query, ins.functions, ins.user_id, cn, ca, emb)
138
+ if isinstance(a, PersonalMemory) and ins.session == 0:
139
+ a.seed_shared(ins.query, cn, ca, emb)
140
+ rng = random.Random(seed)
141
+ rng.shuffle(pts)
142
+ pts = [p for p in pts if p["T"] >= 2][:sample]
143
+ verify = sorted(_timed_gen(chat, pts[i]["query"][:1500], 1)
144
+ for i in range(min(12, len(pts))))[6 // 2 or 0]
145
+ lat = defaultdict(list)
146
+ toks = defaultdict(list)
147
+ for row in pts:
148
+ prompt, T = row["query"][:1500], max(1, row["T"])
149
+ need = {T} | {max(1, T - row[a]) for a in ARMS}
150
+ tc = {n: _timed_gen(chat, prompt, n) for n in need}
151
+ lat["baseline"].append(tc[T])
152
+ for a in ARMS:
153
+ ms = verify + tc[max(1, T - row[a])]
154
+ lat[a].append(ms)
155
+ toks[a].append(T / (ms / 1000.0)) # effective tokens/s
156
+ def p(xs, q):
157
+ xs = sorted(xs)
158
+ return xs[min(len(xs) - 1, int(q * len(xs)))]
159
+ base_p50 = p(lat["baseline"], 0.5)
160
+ out = {}
161
+ for a in ARMS:
162
+ out[a] = {"p50_ms": round(p(lat[a], 0.5), 1),
163
+ "p95_ms": round(p(lat[a], 0.95), 1),
164
+ "tokens_per_s": round(sum(toks[a]) / len(toks[a]), 1),
165
+ "speedup_vs_vanilla": round(
166
+ p(lat["no_memory"], 0.5) / p(lat[a], 0.5), 3)}
167
+ out["_baseline_p50_ms"] = round(base_p50, 1)
168
+ return out
169
+
170
+
171
+ def _overhead(emb, chat):
172
+ """Real retrieval (embed+NN over 48-entry store) and write-back (embed+
173
+ insert) latency, ms/query, measured separately."""
174
+ from .memory import Entry, _best_match
175
+ import numpy as np
176
+ store = [Entry(emb.embed(f"seed query {i}"), "x") for i in range(48)]
177
+ rlat, wlat = [], []
178
+ for i in range(150):
179
+ q = f"overhead probe query number {i} with args {i*7}"
180
+ t0 = time.perf_counter(); e = emb.embed(q); _best_match(e, store)
181
+ rlat.append((time.perf_counter() - t0) * 1000)
182
+ t0 = time.perf_counter(); e2 = emb.embed(q + " wb"); store.append(Entry(e2, "y"))
183
+ wlat.append((time.perf_counter() - t0) * 1000)
184
+ return {"retrieval_ms_mean": round(sum(rlat) / len(rlat), 2),
185
+ "writeback_ms_mean": round(sum(wlat) / len(wlat), 2)}
186
+
187
+
188
+ def main():
189
+ p = argparse.ArgumentParser()
190
+ p.add_argument("--url", default="http://localhost:30000/v1")
191
+ p.add_argument("--model", default="gpt-oss-120b")
192
+ p.add_argument("--datasets", nargs="+", default=list(DATASETS))
193
+ p.add_argument("--seeds", nargs="+", type=int, default=[0, 1, 2])
194
+ p.add_argument("--wallclock-sample", type=int, default=80)
195
+ p.add_argument("--tasks-per-user", type=int, default=10)
196
+ p.add_argument("--capacity", type=int, default=48)
197
+ p.add_argument("--workers", type=int, default=16)
198
+ args = p.parse_args()
199
+ chat = args.url.rstrip("/") + "/chat/completions"
200
+ metrics.get_tokenizer(MODEL_PATH)
201
+ emb = Embedder()
202
+ client = ToolClient(url=args.url, model=args.model)
203
+ assert client.ping(), f"served model not reachable at {args.url}"
204
+ overhead = _overhead(emb, chat)
205
+
206
+ table = {}
207
+ for ds in args.datasets:
208
+ tasks = DATASETS[ds]()
209
+ n_users = min(40, len(tasks) // args.tasks_per_user)
210
+ cfg = {"n_users": n_users, "tasks_per_user": args.tasks_per_user,
211
+ "pool": len(tasks)}
212
+ print(f"\n=== {ds}: {len(tasks)} tasks -> {n_users} users x "
213
+ f"{args.tasks_per_user} ===", flush=True)
214
+ per_seed = defaultdict(dict)
215
+ by_session_acc = defaultdict(lambda: defaultdict(list))
216
+ for sd in args.seeds:
217
+ inst = build_users(tasks, n_users=n_users,
218
+ tasks_per_user=args.tasks_per_user, n_sessions=12,
219
+ queries_per_session=6, seed=sd)
220
+ inst.sort(key=lambda x: (x.session, x.user_id))
221
+ cache_f = RESULTS / f"phase4_mt_targets_{ds}_seed{sd}.json"
222
+ cache = json.loads(cache_f.read_text()) if cache_f.exists() else {}
223
+ fmap = {i.query: i.functions for i in inst}
224
+ miss = [type("S", (), {"query": q, "functions": fmap[q]})()
225
+ for q in {i.query for i in inst} if q not in cache]
226
+ if miss:
227
+ print(f" [seed {sd}] generating {len(miss)} targets ...", flush=True)
228
+ cache.update(generate_targets(client, miss, workers=args.workers))
229
+ cache_f.write_text(json.dumps(cache))
230
+ res = _replay(inst, cache, emb, args.capacity)
231
+ for a in ARMS:
232
+ per_seed[a][sd] = res[a]["MAT"]
233
+ for s, m in res[a]["by_session"].items():
234
+ by_session_acc[a][s].append(m)
235
+ if sd == args.seeds[0]:
236
+ mem = {a: res[a]["mem_entries"] for a in ARMS}
237
+ wc = _wallclock(inst, cache, emb, args.capacity, chat,
238
+ args.wallclock_sample, sd)
239
+ print(f" [seed {sd}] MAT: " +
240
+ " ".join(f"{a}={res[a]['MAT']}" for a in ARMS), flush=True)
241
+ import statistics as st
242
+ cell = {}
243
+ for a in ARMS:
244
+ mats = [per_seed[a][sd] for sd in args.seeds]
245
+ cell[a] = {
246
+ "MAT_mean": round(st.mean(mats), 3),
247
+ "MAT_std": round(st.pstdev(mats), 3),
248
+ "tokens_per_s": wc[a]["tokens_per_s"],
249
+ "speedup_vs_vanilla": wc[a]["speedup_vs_vanilla"],
250
+ "wallclock_p50_ms": wc[a]["p50_ms"],
251
+ "wallclock_p95_ms": wc[a]["p95_ms"],
252
+ "mem_entries": mem[a],
253
+ "by_session_MAT": {s: round(sum(v) / len(v), 3)
254
+ for s, v in sorted(by_session_acc[a].items())},
255
+ }
256
+ table[ds] = {"config": cfg, "baseline_p50_ms": wc["_baseline_p50_ms"],
257
+ "cells": cell}
258
+
259
+ out = {"overhead": overhead, "arms": ARMS,
260
+ "datasets": list(args.datasets), "table": table,
261
+ "note": ("wall-clock via faithful external spec-decode loop, real "
262
+ "sglang timers (ignore_eos), NOT engine-integrated; targets "
263
+ "= served gpt-oss-120b greedy; BFCL is v4 (superset of the "
264
+ "v2 ToolSpec used); ToolBench from OpenBMB Drive.")}
265
+ (RESULTS / "phase4_main_table.json").write_text(json.dumps(out, indent=2))
266
+ print("\n=== SPEEDUP vs Vanilla AR (p50) ===")
267
+ hdr = "arm".ljust(16) + "".join(d[:9].ljust(11) for d in args.datasets)
268
+ print(hdr)
269
+ for a in ARMS:
270
+ row = a.ljust(16) + "".join(
271
+ f"{table[d]['cells'][a]['speedup_vs_vanilla']}x".ljust(11)
272
+ for d in args.datasets)
273
+ print(row)
274
+
275
+
276
+ if __name__ == "__main__":
277
+ main()
harness/phase4_overlap.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 4.3 — shared-user-task personalization overlap sweep.
2
+
3
+ Stress-tests the personalization claim (per-user memory vs. a live global/shared
4
+ store) as users' task sets *overlap*. Workload: every user has ``tasks_per_user``
5
+ signature tasks with a FIXED per-user argument realization (user A always NYC,
6
+ user B always Boston), consistent across the user's sessions. A fraction
7
+ ``overlap_frac`` of those tasks come from ONE shared template pool that every
8
+ user reuses (same templates, different per-user args); the rest are private and
9
+ disjoint. As overlap rises, a global store sees many users' different calls for
10
+ the same template — does per-user memory still win, or does a shared store catch
11
+ up?
12
+
13
+ Two arms, both live + evicting (only partitioning differs), so the contrast is
14
+ personalization alone:
15
+ - global_evict : one live global store (no per-user view)
16
+ - personal_memory (ours): per-user live evicting store
17
+
18
+ Targets are the served model's greedy calls (real, generated once per unique
19
+ query on the migrated node and cached per overlap level). Run from ``code/``:
20
+ python -m harness.phase4_overlap --url http://localhost:30000/v1
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ from collections import defaultdict
28
+ from pathlib import Path
29
+
30
+ from . import metrics
31
+ from .client import ToolClient
32
+ from .data import load_bfcl
33
+ from .memory import Embedder, GlobalEvict, PersonalMemory
34
+ from .run_accept import _parse_target, generate_targets
35
+ from .simulate import build_users
36
+
37
+ ROOT = Path(__file__).resolve().parent.parent
38
+ RESULTS = ROOT / "results"
39
+ # Tokenizer for the token-LCP accept metric: HF hub id by default;
40
+ # override with a local snapshot path if running offline.
41
+ MODEL_PATH = os.environ.get("SPECMEM_TOKENIZER", "openai/gpt-oss-120b")
42
+
43
+
44
+ class Slot:
45
+ """generate_targets expects objects with .query and .functions; the
46
+ functions registry is identical across BFCL tasks' first-tool schema, so we
47
+ attach it lazily below."""
48
+ __slots__ = ("query", "functions")
49
+
50
+ def __init__(self, query, functions=None):
51
+ self.query = query
52
+ self.functions = functions
53
+
54
+
55
+ def _replay(instances, targets, embedder, capacity):
56
+ arms = [GlobalEvict(capacity=40 * capacity),
57
+ PersonalMemory(capacity=capacity, eviction="lru")]
58
+ agg = {a.name: defaultdict(list) for a in arms}
59
+ for ins in instances:
60
+ tgt = targets.get(ins.query)
61
+ if tgt is None:
62
+ continue
63
+ for a in arms:
64
+ agg[a.name][ins.session].append(metrics.score(
65
+ a.draft(ins.query, ins.functions, ins.user_id, embedder), tgt))
66
+ cn, ca = _parse_target(tgt)
67
+ for a in arms:
68
+ a.observe(ins.query, ins.functions, ins.user_id, cn, ca, embedder)
69
+ out = {}
70
+ for n, v in agg.items():
71
+ post = [x for s, xs in v.items() if s > 0 for x in xs]
72
+ out[n] = round(sum(x["accept_length"] for x in post) /
73
+ max(1, len(post)), 3)
74
+ return out
75
+
76
+
77
+ def main():
78
+ p = argparse.ArgumentParser()
79
+ p.add_argument("--url", default="http://localhost:30000/v1")
80
+ p.add_argument("--model", default="gpt-oss-120b")
81
+ p.add_argument("--workers", type=int, default=16)
82
+ p.add_argument("--overlaps", nargs="+", type=float,
83
+ default=[0.0, 0.25, 0.5, 0.75, 1.0])
84
+ p.add_argument("--seeds", nargs="+", type=int, default=[0, 1, 2])
85
+ p.add_argument("--capacity", type=int, default=48)
86
+ args = p.parse_args()
87
+
88
+ metrics.get_tokenizer(MODEL_PATH)
89
+ tasks = load_bfcl()
90
+ embedder = Embedder()
91
+ client = ToolClient(url=args.url, model=args.model)
92
+ if not client.ping():
93
+ raise SystemExit(f"served model not reachable at {args.url}")
94
+
95
+ sweep = {}
96
+ for ov in args.overlaps:
97
+ per_seed = {"global_evict": [], "personal_memory": []}
98
+ for sd in args.seeds:
99
+ instances = build_users(
100
+ tasks, n_users=40, tasks_per_user=15, n_sessions=12,
101
+ queries_per_session=6, seed=sd, overlap_frac=ov,
102
+ user_consistent=True)
103
+ # attach the (shared) functions registry to Slots for target gen
104
+ fmap = {ins.query: ins.functions for ins in instances}
105
+ cache_f = RESULTS / f"phase4_overlap_targets_ov{ov}_seed{sd}.json"
106
+ cache = json.loads(cache_f.read_text()) if cache_f.exists() else {}
107
+ miss = [Slot(q, fmap[q]) for q in {i.query for i in instances}
108
+ if q not in cache]
109
+ if miss:
110
+ cache.update(generate_targets(client, miss, workers=args.workers))
111
+ cache_f.write_text(json.dumps(cache))
112
+ res = _replay(instances, cache, embedder, args.capacity)
113
+ per_seed["global_evict"].append(res["global_evict"])
114
+ per_seed["personal_memory"].append(res["personal_memory"])
115
+ print(f"ov={ov} seed={sd}: {res}", flush=True)
116
+ import statistics as st
117
+ g = st.mean(per_seed["global_evict"])
118
+ pm = st.mean(per_seed["personal_memory"])
119
+ sweep[ov] = {
120
+ "global_evict": round(g, 3),
121
+ "personal_memory": round(pm, 3),
122
+ "personal_advantage_pct": round(100 * (pm - g) / g, 1),
123
+ "per_seed": per_seed}
124
+ print(f"== ov={ov}: global {g:.3f} | personal {pm:.3f} | "
125
+ f"adv {sweep[ov]['personal_advantage_pct']}% ==", flush=True)
126
+
127
+ out = {"config": {"users": 40, "tasks_per_user": 15, "sessions": 12,
128
+ "capacity": args.capacity, "seeds": args.seeds,
129
+ "user_consistent_args": True,
130
+ "arms": "global_evict (live shared) vs personal_memory (ours)"},
131
+ "sweep": sweep}
132
+ (RESULTS / "phase4_personalization_overlap.json").write_text(
133
+ json.dumps(out, indent=2))
134
+ print(json.dumps({ov: {"global": s["global_evict"],
135
+ "personal": s["personal_memory"],
136
+ "adv%": s["personal_advantage_pct"]}
137
+ for ov, s in sweep.items()}, indent=1))
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
harness/phase4_partb.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 4 MAIN TABLE Part B — freshness-over-time curve on tau2-bench.
2
+
3
+ Packages the existing multi-session tau2-bench longitudinal result as an explicit
4
+ freshness curve: per-session MAT for each arm (no_memory / toolspec /
5
+ static_global / personal_memory), x = session index, one line per arm. This is
6
+ the plot that shows WHY persistence is necessary: the fixed store degrades in
7
+ relative terms as novel per-user calls accumulate while the live store holds.
8
+
9
+ CPU-only: replays the frozen tau2 decision points against their cached on-policy
10
+ trace targets (no server calls). Emits results/phase4_freshness_curve.json.
11
+
12
+ Run from the repo root: python -m harness.phase4_partb
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from collections import defaultdict
19
+ from pathlib import Path
20
+
21
+ from . import metrics
22
+ from .data import Task
23
+ from .memory import (Embedder, NoMemory, PersonalMemory, StaticGlobal,
24
+ ToolSpecBaseline)
25
+ from .run_accept import _parse_target
26
+ from .simulate import build_users
27
+
28
+ ROOT = Path(__file__).resolve().parent.parent
29
+ RESULTS = ROOT / "results"
30
+ # Tokenizer for the token-LCP accept metric: HF hub id by default;
31
+ # override with a local snapshot path if running offline.
32
+ MODEL_PATH = os.environ.get("SPECMEM_TOKENIZER", "openai/gpt-oss-120b")
33
+ DOMAINS = ("airline", "retail", "telecom")
34
+ ARMS = ["no_memory", "toolspec", "static_global", "personal_memory"]
35
+
36
+
37
+ def main():
38
+ metrics.get_tokenizer(MODEL_PATH)
39
+ dp = [json.loads(l) for l in
40
+ (RESULTS / "tau2_live_decision_points.jsonl").read_text().splitlines()]
41
+ tools = {d: json.loads((ROOT / "data" / "tau2" /
42
+ f"tools_{d}.json").read_text()) for d in DOMAINS}
43
+ tasks = [Task(id=r["id"], query=r["query"], functions=tools[r["domain"]],
44
+ origin_id=r["id"]) for r in dp]
45
+ targets = {r["query"]: r["target"] for r in dp}
46
+ emb = Embedder()
47
+
48
+ per_seed = {a: defaultdict(list) for a in ARMS} # arm -> session -> [seed MAT]
49
+ for sd in (0, 1, 2):
50
+ inst = build_users(tasks, n_users=40, tasks_per_user=15, n_sessions=12,
51
+ queries_per_session=6, seed=sd, perturb_prob=0.0)
52
+ inst.sort(key=lambda x: (x.session, x.user_id))
53
+ arms = [NoMemory(), ToolSpecBaseline(), StaticGlobal(),
54
+ PersonalMemory(capacity=48, eviction="lru")]
55
+ agg = {a.name: defaultdict(list) for a in arms}
56
+ cur = -1
57
+ for ins in inst:
58
+ tgt = targets.get(ins.query)
59
+ if tgt is None:
60
+ continue
61
+ if ins.session != cur:
62
+ cur = ins.session
63
+ if cur == 1:
64
+ for a in arms:
65
+ if hasattr(a, "freeze"):
66
+ a.freeze()
67
+ for a in arms:
68
+ agg[a.name][ins.session].append(metrics.score(
69
+ a.draft(ins.query, ins.functions, ins.user_id, emb), tgt))
70
+ cn, ca = _parse_target(tgt)
71
+ for a in arms[1:]:
72
+ a.observe(ins.query, ins.functions, ins.user_id, cn, ca, emb)
73
+ if isinstance(a, PersonalMemory) and ins.session == 0:
74
+ a.seed_shared(ins.query, cn, ca, emb)
75
+ for a in arms:
76
+ for s, xs in agg[a.name].items():
77
+ per_seed[a.name][s].append(
78
+ sum(x["accept_length"] for x in xs) / len(xs))
79
+ print(f"seed {sd} done", flush=True)
80
+
81
+ curve = {a: {str(s): round(sum(v) / len(v), 3)
82
+ for s, v in sorted(per_seed[a].items())} for a in ARMS}
83
+ # relative freshness: personal advantage over static per session (post-warmup)
84
+ rel = {}
85
+ for s in sorted(per_seed["personal_memory"]):
86
+ if s == 0:
87
+ continue
88
+ pm = sum(per_seed["personal_memory"][s]) / len(per_seed["personal_memory"][s])
89
+ sg = sum(per_seed["static_global"][s]) / len(per_seed["static_global"][s])
90
+ rel[str(s)] = round(100 * (pm - sg) / sg, 1)
91
+ out = {"arms": ARMS, "per_session_MAT": curve,
92
+ "personal_over_static_pct_by_session": rel,
93
+ "note": ("tau2-bench 3-domain frozen decision points, cached "
94
+ "on-policy trace targets, 3 seeds; session 0 = warmup. "
95
+ "Fixed stores (toolspec/static) plateau/degrade while "
96
+ "personal (live) holds -> the freshness curve.")}
97
+ (RESULTS / "phase4_freshness_curve.json").write_text(json.dumps(out, indent=2))
98
+ print(json.dumps({"per_session_MAT": curve,
99
+ "personal_over_static_pct_by_session": rel}, indent=1))
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
harness/phase4_suffixdecoding.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 4 — SuffixDecoding baseline validation on tau2-bench (cheap CPU check).
2
+
3
+ Adds a faithful SuffixDecoding arm (Oliaro et al., NeurIPS 2025; arXiv 2411.04975)
4
+ to the acceptance harness and compares it against the arms that isolate the
5
+ question it answers:
6
+
7
+ no_memory frozen floor (schema draft)
8
+ toolspec faithful ToolSpec (frozen, global, confidence-gated + FSM)
9
+ static_global frozen global proxy
10
+ global_evict LIVE, global, EMBEDDING-similarity retrieval <-- contrast A
11
+ suffixdecoding LIVE, global, TOKEN-SUFFIX-match retrieval <-- contrast A
12
+ personal_memory ours: LIVE, per-user, embedding retrieval <-- contrast B
13
+
14
+ Contrast A (global_evict vs suffixdecoding): both live+global+size-capped, they
15
+ differ ONLY in retrieval mechanism -> does embedding similarity add value beyond
16
+ mere liveness, or is token matching enough?
17
+ Contrast B (suffixdecoding/global_evict vs personal_memory): does per-user
18
+ partitioning add value on top of a live store?
19
+
20
+ CPU-only: replays the frozen tau2 decision points against cached on-policy trace
21
+ targets (no server). Emits results/phase4_suffixdecoding.json.
22
+
23
+ Run from the repo root: python -m harness.phase4_suffixdecoding
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import statistics as st
30
+ from collections import defaultdict
31
+ from pathlib import Path
32
+
33
+ from . import metrics
34
+ from .data import Task
35
+ from .memory import (Embedder, GlobalEvict, NoMemory, PersonalMemory,
36
+ StaticGlobal, SuffixDecodingBaseline, ToolSpecBaseline)
37
+ from .run_accept import _parse_target
38
+ from .simulate import build_users
39
+
40
+ ROOT = Path(__file__).resolve().parent.parent
41
+ RESULTS = ROOT / "results"
42
+ # Tokenizer for the token-LCP accept metric: HF hub id by default;
43
+ # override with a local snapshot path if running offline.
44
+ MODEL_PATH = os.environ.get("SPECMEM_TOKENIZER", "openai/gpt-oss-120b")
45
+ DOMAINS = ("airline", "retail", "telecom")
46
+ ARMS = ["no_memory", "toolspec", "static_global", "global_evict",
47
+ "suffixdecoding", "personal_memory"]
48
+
49
+
50
+ def _make_arms():
51
+ # global arms sized to the same total footprint U*C = 40*48 = 1920.
52
+ return [NoMemory(), ToolSpecBaseline(), StaticGlobal(),
53
+ GlobalEvict(capacity=1920),
54
+ SuffixDecodingBaseline(capacity=1920),
55
+ PersonalMemory(capacity=48, eviction="lru")]
56
+
57
+
58
+ def main():
59
+ metrics.get_tokenizer(MODEL_PATH)
60
+ dp = [json.loads(l) for l in
61
+ (RESULTS / "tau2_live_decision_points.jsonl").read_text().splitlines()]
62
+ tools = {d: json.loads((ROOT / "data" / "tau2" /
63
+ f"tools_{d}.json").read_text()) for d in DOMAINS}
64
+ tasks = [Task(id=r["id"], query=r["query"], functions=tools[r["domain"]],
65
+ origin_id=r["id"]) for r in dp]
66
+ targets = {r["query"]: r["target"] for r in dp}
67
+ emb = Embedder()
68
+
69
+ per_seed = {a: defaultdict(list) for a in ARMS} # arm -> session -> [seed MAT]
70
+ post_seed = {a: [] for a in ARMS} # arm -> [seed post-warmup MAT]
71
+ for sd in (0, 1, 2):
72
+ inst = build_users(tasks, n_users=40, tasks_per_user=15, n_sessions=12,
73
+ queries_per_session=6, seed=sd, perturb_prob=0.0)
74
+ inst.sort(key=lambda x: (x.session, x.user_id))
75
+ arms = _make_arms()
76
+ agg = {a.name: defaultdict(list) for a in arms}
77
+ cur = -1
78
+ for ins in inst:
79
+ tgt = targets.get(ins.query)
80
+ if tgt is None:
81
+ continue
82
+ if ins.session != cur:
83
+ cur = ins.session
84
+ if cur == 1:
85
+ for a in arms:
86
+ if hasattr(a, "freeze"):
87
+ a.freeze()
88
+ for a in arms:
89
+ agg[a.name][ins.session].append(metrics.score(
90
+ a.draft(ins.query, ins.functions, ins.user_id, emb), tgt))
91
+ cn, ca = _parse_target(tgt)
92
+ for a in arms[1:]:
93
+ a.observe(ins.query, ins.functions, ins.user_id, cn, ca, emb)
94
+ if isinstance(a, PersonalMemory) and ins.session == 0:
95
+ a.seed_shared(ins.query, cn, ca, emb)
96
+ for a in arms:
97
+ for s, xs in agg[a.name].items():
98
+ per_seed[a.name][s].append(
99
+ sum(x["accept_length"] for x in xs) / len(xs))
100
+ post = [x for s, xs in agg[a.name].items() if s > 0 for x in xs]
101
+ post_seed[a.name].append(
102
+ sum(x["accept_length"] for x in post) / len(post))
103
+ print(f"seed {sd} done", flush=True)
104
+
105
+ curve = {a: {str(s): round(sum(v) / len(v), 3)
106
+ for s, v in sorted(per_seed[a].items())} for a in ARMS}
107
+ postmat = {a: round(sum(v) / len(v), 3) for a, v in post_seed.items()}
108
+ poststd = {a: round(st.pstdev(v), 3) if len(v) > 1 else 0.0
109
+ for a, v in post_seed.items()}
110
+
111
+ def rel(x, base):
112
+ return round(100 * (postmat[x] - postmat[base]) / postmat[base], 1)
113
+
114
+ contrasts = {
115
+ "suffixdecoding_over_static_pct": rel("suffixdecoding", "static_global"),
116
+ "global_evict_over_static_pct": rel("global_evict", "static_global"),
117
+ "personal_over_static_pct": rel("personal_memory", "static_global"),
118
+ "embedding_vs_token_gap_pct_of_static": round(
119
+ rel("global_evict", "static_global")
120
+ - rel("suffixdecoding", "static_global"), 1),
121
+ "personal_over_suffixdecoding_pct": rel("personal_memory",
122
+ "suffixdecoding"),
123
+ "personal_over_global_evict_pct": rel("personal_memory", "global_evict"),
124
+ }
125
+ out = {"arms": ARMS, "post_warmup_MAT": postmat, "post_warmup_seed_std": poststd,
126
+ "per_session_MAT": curve, "contrasts": contrasts,
127
+ "note": ("tau2-bench 3-domain frozen decision points, cached on-policy "
128
+ "trace targets, 3 seeds; session 0 = warmup. global_evict and "
129
+ "suffixdecoding are both LIVE + global + size-capped 1920, "
130
+ "differing ONLY in retrieval (embedding cosine vs token-suffix "
131
+ "match) -> isolates retrieval mechanism. All numbers are real "
132
+ "replay outputs; no tuning to a target outcome.")}
133
+ (RESULTS / "phase4_suffixdecoding.json").write_text(json.dumps(out, indent=2))
134
+ print(json.dumps({"post_warmup_MAT": postmat, "contrasts": contrasts},
135
+ indent=1))
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
harness/phase4_suffixdecoding_maintable.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 4 — SuffixDecoding across the MAIN TABLE's 4 benchmarks (CPU, cached).
2
+
3
+ Generalizes the tau2-bench SuffixDecoding check (phase4_suffixdecoding.py) to the
4
+ four standard benchmarks (API-Bank, ToolAlpaca, BFCL, ToolBench) using the SAME
5
+ multi-session workload construction and the SAME cached greedy targets the main
6
+ table already uses (results/phase4_mt_targets_{ds}_seed{sd}.json) — so no server
7
+ and no re-decoding are needed; MAT is directly comparable to phase4_main_table.json.
8
+
9
+ Arms (post-warmup MAT, 3 seeds):
10
+ static_global frozen (ToolSpec regime)
11
+ global_evict LIVE global, EMBEDDING-similarity retrieval
12
+ suffixdecoding LIVE global, TOKEN-SUFFIX-match retrieval (SuffixDecoding)
13
+ personal_memory ours (LIVE per-user, embedding)
14
+
15
+ Isolates the same question as the tau2 run across four independent benchmarks:
16
+ does the freshness gain depend on the retrieval mechanism (embedding vs exact
17
+ token match), or only on the store being live?
18
+
19
+ Run from the repo root: python -m harness.phase4_suffixdecoding_maintable
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import statistics as st
26
+ from collections import defaultdict
27
+ from pathlib import Path
28
+
29
+ from . import metrics
30
+ from .data import load_apibank, load_bfcl, load_toolalpaca, load_toolbench
31
+ from .memory import (Embedder, GlobalEvict, NoMemory, PersonalMemory,
32
+ StaticGlobal, SuffixDecodingBaseline, ToolSpecBaseline)
33
+ from .run_accept import _parse_target
34
+ from .simulate import build_users
35
+
36
+ ROOT = Path(__file__).resolve().parent.parent
37
+ RESULTS = ROOT / "results"
38
+ # Tokenizer for the token-LCP accept metric: HF hub id by default;
39
+ # override with a local snapshot path if running offline.
40
+ MODEL_PATH = os.environ.get("SPECMEM_TOKENIZER", "openai/gpt-oss-120b")
41
+ DATASETS = {"apibank": load_apibank, "toolalpaca": load_toolalpaca,
42
+ "bfcl": load_bfcl, "toolbench": load_toolbench}
43
+ # Lean arm set: the embedding-vs-token retrieval contrast (global_evict vs
44
+ # suffixdecoding) is settled on tau2-bench (phase4_suffixdecoding.json); here we
45
+ # corroborate across the 4 standard benchmarks that a LIVE token-match store
46
+ # recovers the freshness gain vs the frozen store, like our embedding store.
47
+ # no_memory floor (schema draft)
48
+ # static_global frozen ref (== toolspec on these workloads)
49
+ # suffixdecoding LIVE token-match (SuffixDecoding)
50
+ # personal_memory ours (LIVE per-user embedding)
51
+ ARMS = ["no_memory", "static_global", "suffixdecoding", "personal_memory"]
52
+ TASKS_PER_USER = 10
53
+ CAP = 48
54
+
55
+
56
+ def _make_arms(footprint):
57
+ return [NoMemory(), StaticGlobal(),
58
+ SuffixDecodingBaseline(capacity=footprint),
59
+ PersonalMemory(capacity=CAP, eviction="lru")]
60
+
61
+
62
+ def _replay(inst, targets, emb, footprint):
63
+ arms = _make_arms(footprint)
64
+ agg = {a.name: defaultdict(list) for a in arms}
65
+ cur = -1
66
+ for ins in inst:
67
+ tgt = targets.get(ins.query)
68
+ if tgt is None:
69
+ continue
70
+ if ins.session != cur:
71
+ cur = ins.session
72
+ if cur == 1:
73
+ for a in arms:
74
+ if hasattr(a, "freeze"):
75
+ a.freeze()
76
+ for a in arms:
77
+ agg[a.name][ins.session].append(metrics.score(
78
+ a.draft(ins.query, ins.functions, ins.user_id, emb), tgt))
79
+ cn, ca = _parse_target(tgt)
80
+ for a in arms[1:]:
81
+ a.observe(ins.query, ins.functions, ins.user_id, cn, ca, emb)
82
+ if isinstance(a, PersonalMemory) and ins.session == 0:
83
+ a.seed_shared(ins.query, cn, ca, emb)
84
+ out = {}
85
+ for a in arms:
86
+ post = [x for s, xs in agg[a.name].items() if s > 0 for x in xs]
87
+ out[a.name] = round(sum(x["accept_length"] for x in post) /
88
+ max(1, len(post)), 3)
89
+ return out
90
+
91
+
92
+ def main():
93
+ metrics.get_tokenizer(MODEL_PATH)
94
+ emb = Embedder()
95
+ table = {}
96
+ for ds, loader in DATASETS.items():
97
+ tasks = loader()
98
+ n_users = min(40, len(tasks) // TASKS_PER_USER)
99
+ footprint = n_users * CAP # same total size for both global arms
100
+ per_seed = {a: [] for a in ARMS}
101
+ for sd in (0, 1, 2):
102
+ cache_f = RESULTS / f"phase4_mt_targets_{ds}_seed{sd}.json"
103
+ if not cache_f.exists():
104
+ print(f" [{ds} seed {sd}] MISSING cache -> skip", flush=True)
105
+ continue
106
+ targets = json.loads(cache_f.read_text())
107
+ inst = build_users(tasks, n_users=n_users,
108
+ tasks_per_user=TASKS_PER_USER, n_sessions=12,
109
+ queries_per_session=6, seed=sd)
110
+ inst.sort(key=lambda x: (x.session, x.user_id))
111
+ res = _replay(inst, targets, emb, footprint)
112
+ for a in ARMS:
113
+ per_seed[a].append(res[a])
114
+ print(f" [{ds} seed {sd}] " +
115
+ " ".join(f"{a}={res[a]}" for a in ARMS), flush=True)
116
+ cells = {a: {"MAT_mean": round(st.mean(per_seed[a]), 3),
117
+ "MAT_std": round(st.pstdev(per_seed[a]), 3)
118
+ if len(per_seed[a]) > 1 else 0.0}
119
+ for a in ARMS if per_seed[a]}
120
+ sg = cells["static_global"]["MAT_mean"]
121
+ for a in ARMS:
122
+ if a in cells and sg:
123
+ cells[a]["rel_over_static_pct"] = round(
124
+ 100 * (cells[a]["MAT_mean"] - sg) / sg, 1)
125
+ table[ds] = {"n_users": n_users, "footprint": footprint, "cells": cells}
126
+ print(f"=== {ds} done ===", flush=True)
127
+
128
+ out = {"arms": ARMS, "datasets": list(DATASETS), "table": table,
129
+ "note": ("Post-warmup MAT (sessions 1-11), 3 seeds, cached greedy "
130
+ "targets identical to phase4_main_table.json; CPU replay, no "
131
+ "server. global_evict and suffixdecoding are both LIVE + "
132
+ "global + size-capped to n_users*48, differing ONLY in "
133
+ "retrieval (embedding cosine vs exact token-suffix match). "
134
+ "Real replay outputs; no tuning to a target outcome.")}
135
+ (RESULTS / "phase4_suffixdecoding_maintable.json").write_text(
136
+ json.dumps(out, indent=2))
137
+ print("\n=== post-warmup MAT (mean over 3 seeds) ===")
138
+ hdr = "arm".ljust(16) + "".join(d[:9].ljust(11) for d in DATASETS)
139
+ print(hdr)
140
+ for a in ARMS:
141
+ print(a.ljust(16) + "".join(
142
+ f"{table[d]['cells'][a]['MAT_mean']}".ljust(11)
143
+ for d in DATASETS if a in table[d]["cells"]))
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
harness/phase4_throughput.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 4.2 — concurrency / throughput under real load.
2
+
3
+ Drives CONCURRENT (not sequential) requests against the serving stack and
4
+ measures how aggregate throughput scales with concurrency, per arm, to answer:
5
+ does the memory arm's decode-step advantage hold, shrink, or reverse under
6
+ contention?
7
+
8
+ Method: reuse the faithful external spec-decode accounting (Phase 4.1) — each
9
+ arm's per-request work is decoding ``T - L`` tokens (accept length ``L`` from
10
+ the real served targets). For each arm and concurrency level ``C`` we fire the
11
+ sampled requests through a ``C``-worker pool against the live sglang server
12
+ (real batched serving, ``ignore_eos`` forces exact token counts) and record
13
+ aggregate tokens/s and per-request latency. The no-memory arm decodes ~``T``
14
+ tokens/request; ours decodes ~``T-L`` with ``L`` large, so under batching it
15
+ should sustain higher throughput.
16
+
17
+ Run from the repo root: python -m harness.phase4_throughput --url http://localhost:30000/v1
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import random
24
+ import time
25
+ from collections import defaultdict
26
+ from concurrent.futures import ThreadPoolExecutor
27
+ from pathlib import Path
28
+
29
+ import requests
30
+
31
+ from . import metrics
32
+ from .phase4_wallclock import _collect_points, MODEL_PATH
33
+
34
+ ROOT = Path(__file__).resolve().parent.parent
35
+ RESULTS = ROOT / "results"
36
+ ARMS = ["no_memory", "static_global", "personal_memory"]
37
+
38
+
39
+ def _gen(chat, prompt, ntok, T):
40
+ t0 = time.perf_counter()
41
+ r = requests.post(chat, json={"model": "gpt-oss-120b", "temperature": 0.0,
42
+ "max_tokens": ntok, "ignore_eos": True,
43
+ "messages": [{"role": "user",
44
+ "content": prompt}]}, timeout=300)
45
+ dt = time.perf_counter() - t0
46
+ r.raise_for_status()
47
+ return dt, T
48
+
49
+
50
+ def _run_arm(chat, jobs, concurrency):
51
+ """Fire (prompt, decode_ntok, target_T) jobs through a C-worker pool.
52
+ Each request PRODUCES its full target (``T`` tokens) whether by decoding or
53
+ by accepting the draft's prefix; the memory arm decodes fewer (``T-L``) but
54
+ still produces ``T``, so the correct throughput counts effective OUTPUT
55
+ tokens (``T``), not decode work. We also report requests/s."""
56
+ lat, out_toks = [], 0
57
+ t0 = time.perf_counter()
58
+ with ThreadPoolExecutor(max_workers=concurrency) as ex:
59
+ for dt, T in ex.map(lambda j: _gen(chat, j[0], j[1], j[2]), jobs):
60
+ lat.append(dt * 1000)
61
+ out_toks += T
62
+ wall = time.perf_counter() - t0
63
+ lat.sort()
64
+ return {"eff_output_tokens_per_s": round(out_toks / wall, 1),
65
+ "requests_per_s": round(len(jobs) / wall, 2),
66
+ "wall_s": round(wall, 2),
67
+ "req_p50_ms": round(lat[len(lat) // 2], 1),
68
+ "req_p95_ms": round(lat[min(len(lat) - 1, int(0.95 * len(lat)))], 1)}
69
+
70
+
71
+ def main():
72
+ p = argparse.ArgumentParser()
73
+ p.add_argument("--url", default="http://localhost:30000/v1")
74
+ p.add_argument("--domains", nargs="+",
75
+ default=["airline", "retail", "telecom"])
76
+ p.add_argument("--concurrency", nargs="+", type=int, default=[1, 8, 32])
77
+ p.add_argument("--sample", type=int, default=96)
78
+ p.add_argument("--seed", type=int, default=0)
79
+ args = p.parse_args()
80
+ chat = args.url.rstrip("/") + "/chat/completions"
81
+ metrics.get_tokenizer(MODEL_PATH)
82
+
83
+ pts = _collect_points(args.domains)
84
+ rng = random.Random(args.seed)
85
+ rng.shuffle(pts)
86
+ pts = [x for x in pts if x["T"] >= 2][:args.sample]
87
+ print(f"[throughput] {len(pts)} sampled requests", flush=True)
88
+
89
+ # precompute per-arm job list: (prompt, decode_ntok=T-L, target_T)
90
+ jobs = {a: [(x["query"][:1500], max(1, x["T"] - x[a]), x["T"]) for x in pts]
91
+ for a in ARMS}
92
+ mean_tok = {a: round(sum(n for _, n, _ in jobs[a]) / len(jobs[a]), 1)
93
+ for a in ARMS}
94
+
95
+ out = {"concurrency_levels": args.concurrency, "arms": ARMS,
96
+ "n_requests": len(pts), "mean_decode_tokens_per_req": mean_tok,
97
+ "results": defaultdict(dict)}
98
+ for C in args.concurrency:
99
+ for a in ARMS:
100
+ r = _run_arm(chat, jobs[a], C)
101
+ out["results"][str(C)][a] = r
102
+ print(f" C={C:2d} {a:16s} out={r['eff_output_tokens_per_s']:8.1f} tok/s "
103
+ f"req/s={r['requests_per_s']:.2f} p50={r['req_p50_ms']:.0f}ms "
104
+ f"p95={r['req_p95_ms']:.0f}ms", flush=True)
105
+ # personal over no_memory effective-output throughput at each C
106
+ out["personal_over_vanilla_throughput"] = {
107
+ str(C): round(out["results"][str(C)]["personal_memory"]["eff_output_tokens_per_s"] /
108
+ out["results"][str(C)]["no_memory"]["eff_output_tokens_per_s"], 3)
109
+ for C in args.concurrency}
110
+ out["results"] = dict(out["results"])
111
+ (RESULTS / "phase4_throughput.json").write_text(json.dumps(out, indent=2))
112
+ print("\npersonal/vanilla throughput ratio by concurrency:",
113
+ out["personal_over_vanilla_throughput"])
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
harness/phase4_wallclock.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 4.1 — deployed wall-clock speedup via a faithful external spec-decode
2
+ loop with REAL timers (not engine-integrated).
3
+
4
+ Wiring a retrieval draft into vLLM/sglang's internal speculative-decoding hook
5
+ is out of reach in the available time, so — as the directive permits — we
6
+ reproduce the accept/reject/re-decode loop end-to-end against the live served
7
+ model with real wall-clock timers, and label it plainly as an external harness.
8
+
9
+ Spec-decode accounting (single retrieval draft per call): the target model runs
10
+ ONE verification forward over the drafted tool call, accepts its ``L``-token
11
+ correct prefix (token-LCP against the greedy target, exactly the MAT metric),
12
+ then autoregressively decodes the remaining ``T-L`` target tokens. So the target
13
+ performs ~``(T-L)`` sequential forwards plus one verify, vs. ``T`` for a
14
+ no-speculation baseline. We MEASURE the real per-request wall-clock of generating
15
+ ``T`` tokens (baseline) and ``T-L`` tokens (each arm) from the actual decision-
16
+ point prompt on the served gpt-oss-120b — the content is irrelevant, only the
17
+ decode-step count and real server timing matter — and add one measured verify
18
+ forward. Reports p50/p95 latency per arm and end-to-end speedup vs no-memory.
19
+
20
+ Run from the repo root: python -m harness.phase4_wallclock --url http://localhost:30000/v1
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import random
28
+ import time
29
+ from collections import defaultdict
30
+ from pathlib import Path
31
+
32
+ import requests
33
+
34
+ from . import metrics
35
+ from .data import Task
36
+ from .memory import Embedder, NoMemory, PersonalMemory, StaticGlobal
37
+ from .run_accept import _parse_target
38
+ from .simulate import build_users
39
+
40
+ ROOT = Path(__file__).resolve().parent.parent
41
+ RESULTS = ROOT / "results"
42
+ # Tokenizer for the token-LCP accept metric: HF hub id by default;
43
+ # override with a local snapshot path if running offline.
44
+ MODEL_PATH = os.environ.get("SPECMEM_TOKENIZER", "openai/gpt-oss-120b")
45
+
46
+
47
+ def _collect_points(domains):
48
+ """Replay the live tau2 decision points; record, per post-warmup point,
49
+ each arm's token-accept length against the served target."""
50
+ dp = [json.loads(l) for l in
51
+ (RESULTS / "tau2_live_decision_points.jsonl").read_text().splitlines()
52
+ if json.loads(l)["domain"] in domains]
53
+ tools = {d: json.loads((ROOT / "data" / "tau2" /
54
+ f"tools_{d}.json").read_text()) for d in domains}
55
+ tasks = [Task(id=r["id"], query=r["query"], functions=tools[r["domain"]],
56
+ origin_id=r["id"]) for r in dp]
57
+ targets = {r["query"]: r["target"] for r in dp}
58
+ emb = Embedder()
59
+ inst = build_users(tasks, n_users=40, tasks_per_user=15, n_sessions=12,
60
+ queries_per_session=6, seed=0, perturb_prob=0.0)
61
+ inst.sort(key=lambda x: (x.session, x.user_id))
62
+ arms = [NoMemory(), StaticGlobal(), PersonalMemory(capacity=48, eviction="lru")]
63
+ cur, pts = -1, []
64
+ for ins in inst:
65
+ tgt = targets.get(ins.query)
66
+ if tgt is None:
67
+ continue
68
+ if ins.session != cur:
69
+ cur = ins.session
70
+ if cur == 1:
71
+ for a in arms:
72
+ if hasattr(a, "freeze"):
73
+ a.freeze()
74
+ if ins.session > 0:
75
+ row = {"query": ins.query, "target": tgt,
76
+ "T": metrics.accept_length(tgt, tgt)[1]}
77
+ for a in arms:
78
+ row[a.name] = metrics.accept_length(
79
+ a.draft(ins.query, ins.functions, ins.user_id, emb), tgt)[0]
80
+ pts.append(row)
81
+ cn, ca = _parse_target(tgt)
82
+ for a in arms[1:]:
83
+ a.observe(ins.query, ins.functions, ins.user_id, cn, ca, emb)
84
+ if isinstance(a, PersonalMemory) and ins.session == 0:
85
+ a.seed_shared(ins.query, cn, ca, emb)
86
+ return pts
87
+
88
+
89
+ def _timed_gen(url, prompt, max_tok):
90
+ """Real wall-clock (ms) to generate exactly max_tok tokens; content unused."""
91
+ t0 = time.perf_counter()
92
+ r = requests.post(url, json={"model": "gpt-oss-120b", "temperature": 0.0,
93
+ "max_tokens": max_tok, "ignore_eos": True,
94
+ "messages": [{"role": "user", "content": prompt}]},
95
+ timeout=180)
96
+ dt = (time.perf_counter() - t0) * 1000
97
+ r.raise_for_status()
98
+ return dt
99
+
100
+
101
+ def main():
102
+ p = argparse.ArgumentParser()
103
+ p.add_argument("--url", default="http://localhost:30000/v1")
104
+ p.add_argument("--domains", nargs="+",
105
+ default=["airline", "retail", "telecom"])
106
+ p.add_argument("--sample", type=int, default=120)
107
+ p.add_argument("--seed", type=int, default=0)
108
+ args = p.parse_args()
109
+ chat = args.url.rstrip("/") + "/chat/completions"
110
+
111
+ metrics.get_tokenizer(MODEL_PATH)
112
+ pts = _collect_points(args.domains)
113
+ rng = random.Random(args.seed)
114
+ rng.shuffle(pts)
115
+ pts = pts[: args.sample]
116
+ print(f"[wallclock] {len(pts)} sampled decision points", flush=True)
117
+
118
+ # one measured verify forward (prefill+1 tok) as spec-decode overhead
119
+ verify_ms = sorted(_timed_gen(chat, pts[i]["query"][:1500], 1)
120
+ for i in range(min(15, len(pts))))
121
+ verify = verify_ms[len(verify_ms) // 2]
122
+
123
+ lat = defaultdict(list) # arm -> per-request wall-clock ms
124
+ for k, row in enumerate(pts):
125
+ prompt = row["query"][:1500]
126
+ T = max(1, row["T"])
127
+ # cache decode-time per distinct token count to save calls
128
+ need = {T}
129
+ for arm in ("no_memory", "static_global", "personal_memory"):
130
+ need.add(max(1, T - row[arm]))
131
+ tcache = {n: _timed_gen(chat, prompt, n) for n in need}
132
+ lat["baseline_no_spec"].append(tcache[T])
133
+ for arm in ("no_memory", "static_global", "personal_memory"):
134
+ lat[arm].append(verify + tcache[max(1, T - row[arm])])
135
+ if (k + 1) % 20 == 0:
136
+ print(f" {k+1}/{len(pts)}", flush=True)
137
+
138
+ def stats(xs):
139
+ xs = sorted(xs)
140
+ return {"p50_ms": round(xs[len(xs) // 2], 1),
141
+ "p95_ms": round(xs[int(0.95 * len(xs))], 1),
142
+ "mean_ms": round(sum(xs) / len(xs), 1)}
143
+
144
+ base = stats(lat["baseline_no_spec"])
145
+ out = {"config": {"domains": args.domains, "n": len(pts),
146
+ "verify_forward_ms": round(verify, 1),
147
+ "note": "faithful external spec-decode loop, real timers, "
148
+ "NOT engine-integrated (see docstring)"},
149
+ "baseline_no_spec": base, "arms": {}}
150
+ for arm in ("no_memory", "static_global", "personal_memory"):
151
+ s = stats(lat[arm])
152
+ s["speedup_vs_no_memory_p50"] = round(
153
+ stats(lat["no_memory"])["p50_ms"] / s["p50_ms"], 3)
154
+ s["speedup_vs_baseline_p50"] = round(base["p50_ms"] / s["p50_ms"], 3)
155
+ out["arms"][arm] = s
156
+ (RESULTS / "phase4_wallclock_deployed.json").write_text(json.dumps(out, indent=2))
157
+ print(json.dumps(out, indent=2))
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
harness/plots.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate figures from results/*.json into paper/figures/."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import matplotlib
8
+ matplotlib.use("Agg")
9
+ import matplotlib.pyplot as plt
10
+
11
+ ROOT = Path(__file__).resolve().parent.parent
12
+ RESULTS = ROOT / "results"
13
+ FIGS = ROOT / "figures"
14
+
15
+ ARM_STYLE = {
16
+ "no_memory": ("No memory (schema draft)", "#888888", "o", "--"),
17
+ "static_global": ("Static datastore (ToolSpec-style)", "#d1495b", "s", "-"),
18
+ "personal_memory": ("Ours (personal + evict)", "#1b6ca8", "D", "-"),
19
+ }
20
+
21
+
22
+ def plot_acceptance(tag=""):
23
+ fname = f"{tag}_accept_results.json" if tag else "accept_results.json"
24
+ data = json.loads((RESULTS / fname).read_text())
25
+ summary = data["summary"]
26
+ n_sessions = data["config"]["sessions"]
27
+
28
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.2))
29
+ for arm, (label, color, marker, ls) in ARM_STYLE.items():
30
+ if arm not in summary:
31
+ continue
32
+ xs = sorted(int(s) for s in summary[arm])
33
+ mat = [summary[arm][str(s)]["MAT"] for s in xs]
34
+ frac = [summary[arm][str(s)]["accepted_frac"] for s in xs]
35
+ ax1.plot(xs, mat, marker=marker, ls=ls, color=color, label=label)
36
+ ax2.plot(xs, frac, marker=marker, ls=ls, color=color, label=label)
37
+
38
+ for ax, ylab, title in ((ax1, "Mean accepted tokens (MAT)",
39
+ "Draft acceptance vs. session"),
40
+ (ax2, "Accepted fraction of target",
41
+ "Accepted fraction vs. session")):
42
+ ax.set_xlabel("Session index")
43
+ ax.set_ylabel(ylab)
44
+ ax.set_title(title)
45
+ ax.grid(alpha=0.3)
46
+ ax.axvspan(-0.4, 0.4, color="k", alpha=0.05)
47
+ ax1.legend(fontsize=8, loc="best")
48
+ ax1.annotate("warmup", (0, ax1.get_ylim()[1]*0.05), fontsize=7, ha="center")
49
+ fig.tight_layout()
50
+ FIGS.mkdir(parents=True, exist_ok=True)
51
+ fig.savefig(FIGS / "acceptance_by_session.pdf")
52
+ fig.savefig(FIGS / "acceptance_by_session.png", dpi=140)
53
+ print("wrote acceptance_by_session.{pdf,png}")
54
+
55
+
56
+ def plot_safety():
57
+ """Two-panel safety figure over the three execution policies:
58
+ (left) severity-weighted cost of being wrong; (right) safe spec-executions
59
+ preserved (the latency win) and bad irreversible actions."""
60
+ data = json.loads((RESULTS / "safety_results.json").read_text())
61
+ policies = ["naive_exec", "conf_gate", "gated_exec"]
62
+ labels = ["Naive\nspec-exec", "Confidence\ngate", "Idempotency\ngate (ours)"]
63
+ colors = ["#d1495b", "#e8a33d", "#1b6ca8"]
64
+
65
+ wcost = [data[p]["weighted_cost"] for p in policies]
66
+ bad = [data[p]["bad_irreversible_actions"] for p in policies]
67
+ safe = [data[p]["safe_spec_executions"] for p in policies]
68
+ x = list(range(len(policies)))
69
+
70
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4.1))
71
+
72
+ ax1.bar(x, wcost, color=colors)
73
+ for i, w in enumerate(wcost):
74
+ ax1.text(i, w + max(wcost) * 0.02, f"{w:.0f}", ha="center",
75
+ fontsize=10, fontweight="bold", color=colors[i])
76
+ ax1.set_xticks(x)
77
+ ax1.set_xticklabels(labels, fontsize=8.5)
78
+ ax1.set_ylabel("Severity-weighted cost of being wrong")
79
+ ax1.set_title("Cost of wrong irreversible speculative executions")
80
+ ax1.grid(axis="y", alpha=0.3)
81
+
82
+ ax2.bar(x, safe, color="#1b6ca8", label="Safe spec-executions (latency win)")
83
+ ax2.bar(x, bad, bottom=safe, color="#d1495b",
84
+ label="Bad irreversible actions")
85
+ for i, b in enumerate(bad):
86
+ ax2.text(i, safe[i] + b + max(safe) * 0.02,
87
+ f"{b:.0f} bad", ha="center", fontsize=8.5,
88
+ color="#d1495b", fontweight="bold")
89
+ ax2.set_xticks(x)
90
+ ax2.set_xticklabels(labels, fontsize=8.5)
91
+ ax2.set_ylabel("Speculative executions (mean/stream)")
92
+ ax2.set_title("Latency win preserved vs. harm incurred")
93
+ ax2.legend(fontsize=8, loc="upper right")
94
+ ax2.grid(axis="y", alpha=0.3)
95
+
96
+ fig.tight_layout()
97
+ FIGS.mkdir(parents=True, exist_ok=True)
98
+ fig.savefig(FIGS / "safety.pdf")
99
+ fig.savefig(FIGS / "safety.png", dpi=140)
100
+ print("wrote safety.{pdf,png}")
101
+
102
+
103
+ if __name__ == "__main__":
104
+ import sys
105
+ tag = sys.argv[1] if len(sys.argv) > 1 else ""
106
+ plot_acceptance(tag)
107
+ plot_safety()
harness/reset_arm.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Session-reset baseline (reviewer ask, round 7).
2
+
3
+ Separates CROSS-SESSION persistence from mere WITHIN-SESSION liveness: a
4
+ "reset" arm ingests live during a session but is restored to its end-of-warmup
5
+ state at every session boundary. Then, post-warmup:
6
+ reset - static = value of within-session liveness alone
7
+ personal - reset = value of persisting across sessions
8
+ Replays the phase-2 seed-0 stream against cached targets (no GPU/model calls).
9
+
10
+ Usage: python -m harness.reset_arm (from code/)
11
+ Writes results/phase2_reset_arm.json.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import copy
16
+ import json
17
+ from collections import defaultdict
18
+ from pathlib import Path
19
+
20
+ from . import metrics
21
+ from .data import load_bfcl
22
+ from .memory import Embedder, PersonalMemory, StaticGlobal
23
+ from .run_accept import MODEL_PATH, _parse_target
24
+ from .simulate import build_users
25
+
26
+ ROOT = Path(__file__).resolve().parent.parent
27
+ RESULTS = ROOT / "results"
28
+
29
+
30
+ def main():
31
+ metrics.get_tokenizer(MODEL_PATH)
32
+ tasks = load_bfcl()
33
+ embedder = Embedder()
34
+ instances = build_users(tasks, n_users=40, tasks_per_user=15,
35
+ n_sessions=12, queries_per_session=6, seed=0)
36
+ instances.sort(key=lambda x: (x.session, x.user_id))
37
+ targets = json.loads((RESULTS / "phase2_targets_seed0.json").read_text())
38
+
39
+ static = StaticGlobal()
40
+ personal = PersonalMemory(capacity=48, eviction="lru")
41
+ reset = PersonalMemory(capacity=48, eviction="lru")
42
+ warm_snapshot = None
43
+
44
+ agg = {n: defaultdict(list) for n in ("static", "personal", "reset")}
45
+ cur_session = -1
46
+ for ins in instances:
47
+ tgt = targets.get(ins.query)
48
+ if tgt is None:
49
+ continue
50
+ if ins.session != cur_session:
51
+ cur_session = ins.session
52
+ if cur_session == 1:
53
+ static.freeze()
54
+ warm_snapshot = copy.deepcopy(reset) # end-of-warmup state
55
+ elif cur_session > 1:
56
+ reset = copy.deepcopy(warm_snapshot) # wipe session memory
57
+ for name, a in (("static", static), ("personal", personal),
58
+ ("reset", reset)):
59
+ draft = a.draft(ins.query, ins.functions, ins.user_id, embedder)
60
+ agg[name][ins.session].append(metrics.score(draft, tgt))
61
+ cname, cargs = _parse_target(tgt)
62
+ for a in (static, personal, reset):
63
+ a.observe(ins.query, ins.functions, ins.user_id, cname, cargs,
64
+ embedder)
65
+ if isinstance(a, PersonalMemory) and ins.session == 0:
66
+ a.seed_shared(ins.query, cname, cargs, embedder)
67
+
68
+ out = {}
69
+ for name in agg:
70
+ scores = [x for s, xs in agg[name].items() if s > 0 for x in xs]
71
+ n = len(scores)
72
+ out[name] = {
73
+ "n": n,
74
+ "MAT": round(sum(x["accept_length"] for x in scores) / n, 3),
75
+ "exact_rate": round(sum(1 for x in scores if x["exact"]) / n, 4),
76
+ "by_session": {str(s): round(sum(x["accept_length"] for x in xs)
77
+ / len(xs), 2)
78
+ for s, xs in sorted(agg[name].items())},
79
+ }
80
+ result = {"config": {"users": 40, "tasks_per_user": 15, "sessions": 12,
81
+ "queries_per_session": 6, "seed": 0, "capacity": 48,
82
+ "targets": "phase2_targets_seed0.json (cached)"},
83
+ "arms": out,
84
+ "decomposition": {
85
+ "within_session_liveness (reset - static)":
86
+ round(out["reset"]["MAT"] - out["static"]["MAT"], 3),
87
+ "cross_session_persistence (personal - reset)":
88
+ round(out["personal"]["MAT"] - out["reset"]["MAT"], 3),
89
+ }}
90
+ (RESULTS / "phase2_reset_arm.json").write_text(json.dumps(result,
91
+ indent=2))
92
+ print(json.dumps(result["arms"]["reset"], indent=1))
93
+ print(json.dumps(result["decomposition"], indent=1))
94
+
95
+
96
+ if __name__ == "__main__":
97
+ main()
harness/review_r1_warmup.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Review R1 — warmup-budget sensitivity of the frozen-vs-live gap.
2
+
3
+ Does the frozen store's decay just reflect a stingy warmup? We sweep the warmup
4
+ budget: session-0 sees {50% (headline), 75%, 100%} of each user's eventual task
5
+ set (simulate.build_users' warmup_frac). At 100% warmup the frozen store has seen
6
+ every task template, so any residual gap is pure argument-drift (numeric-variant)
7
+ decay; at 50% the gap also carries post-warmup novelty. Either outcome is
8
+ informative.
9
+
10
+ Arms: static_global (frozen at session 1) vs personal_memory (live). 3 seeds.
11
+ warmup_frac=0.5 reuses the cached phase2 targets (identical build); 0.75/1.0
12
+ generate their own greedy targets from the served model once and cache them.
13
+
14
+ Run from the repo root: python -m harness.review_r1_warmup --url http://localhost:30000/v1
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import statistics as st
22
+ from collections import defaultdict
23
+ from pathlib import Path
24
+
25
+ from . import metrics
26
+ from .client import ToolClient
27
+ from .data import load_bfcl
28
+ from .memory import Embedder, PersonalMemory, StaticGlobal
29
+ from .run_accept import _parse_target, generate_targets
30
+ from .simulate import build_users
31
+
32
+ ROOT = Path(__file__).resolve().parent.parent
33
+ RESULTS = ROOT / "results"
34
+ # Tokenizer for the token-LCP accept metric: HF hub id by default;
35
+ # override with a local snapshot path if running offline.
36
+ MODEL_PATH = os.environ.get("SPECMEM_TOKENIZER", "openai/gpt-oss-120b")
37
+ WARMUPS = [0.5, 0.75, 1.0]
38
+ SEEDS = [0, 1, 2]
39
+
40
+
41
+ class Slot:
42
+ __slots__ = ("query", "functions")
43
+
44
+ def __init__(self, query, functions):
45
+ self.query, self.functions = query, functions
46
+
47
+
48
+ def _replay(inst, targets, emb):
49
+ static, personal = StaticGlobal(), PersonalMemory(capacity=48, eviction="lru")
50
+ agg = {"static_global": defaultdict(list), "personal_memory": defaultdict(list)}
51
+ cur = -1
52
+ for ins in inst:
53
+ tgt = targets.get(ins.query)
54
+ if tgt is None:
55
+ continue
56
+ if ins.session != cur:
57
+ cur = ins.session
58
+ if cur == 1:
59
+ static.freeze()
60
+ agg["static_global"][ins.session].append(
61
+ metrics.score(static.draft(ins.query, ins.functions, ins.user_id, emb), tgt))
62
+ agg["personal_memory"][ins.session].append(
63
+ metrics.score(personal.draft(ins.query, ins.functions, ins.user_id, emb), tgt))
64
+ cn, ca = _parse_target(tgt)
65
+ static.observe(ins.query, ins.functions, ins.user_id, cn, ca, emb)
66
+ personal.observe(ins.query, ins.functions, ins.user_id, cn, ca, emb)
67
+ if ins.session == 0:
68
+ personal.seed_shared(ins.query, cn, ca, emb)
69
+ out = {}
70
+ for a in ("static_global", "personal_memory"):
71
+ post = [x for s, xs in agg[a].items() if s > 0 for x in xs]
72
+ out[a] = sum(x["accept_length"] for x in post) / max(1, len(post))
73
+ return out["static_global"], out["personal_memory"]
74
+
75
+
76
+ def main():
77
+ p = argparse.ArgumentParser()
78
+ p.add_argument("--url", default="http://localhost:30000/v1")
79
+ p.add_argument("--model", default="gpt-oss-120b")
80
+ p.add_argument("--workers", type=int, default=16)
81
+ args = p.parse_args()
82
+
83
+ metrics.get_tokenizer(MODEL_PATH)
84
+ tasks = load_bfcl()
85
+ emb = Embedder()
86
+ client = ToolClient(url=args.url, model=args.model)
87
+ assert client.ping(), f"served model not reachable at {args.url}"
88
+
89
+ sweep = {}
90
+ for wf in WARMUPS:
91
+ per_seed = {"static_global": [], "personal_memory": []}
92
+ for sd in SEEDS:
93
+ inst = build_users(tasks, n_users=40, tasks_per_user=15, n_sessions=12,
94
+ queries_per_session=6, seed=sd, warmup_frac=wf)
95
+ inst.sort(key=lambda x: (x.session, x.user_id))
96
+ if wf == 0.5:
97
+ cache_f = RESULTS / f"phase2_targets_seed{sd}.json"
98
+ else:
99
+ cache_f = RESULTS / f"review_r1_targets_w{wf}_seed{sd}.json"
100
+ cache = json.loads(cache_f.read_text()) if cache_f.exists() else {}
101
+ fmap = {i.query: i.functions for i in inst}
102
+ miss = [Slot(q, fmap[q]) for q in {i.query for i in inst} if q not in cache]
103
+ if miss:
104
+ print(f" [wf={wf} seed={sd}] generating {len(miss)} targets ...", flush=True)
105
+ cache.update(generate_targets(client, miss, workers=args.workers))
106
+ # never overwrite the shared phase2 cache; write R1-owned copy
107
+ outf = (RESULTS / f"review_r1_targets_w{wf}_seed{sd}.json")
108
+ outf.write_text(json.dumps(cache))
109
+ sg, pm = _replay(inst, cache, emb)
110
+ per_seed["static_global"].append(sg)
111
+ per_seed["personal_memory"].append(pm)
112
+ print(f"wf={wf} seed={sd}: static {sg:.3f} | personal {pm:.3f} | "
113
+ f"gap {100*(pm-sg)/sg:+.1f}%", flush=True)
114
+ sg = st.mean(per_seed["static_global"])
115
+ pm = st.mean(per_seed["personal_memory"])
116
+ sweep[str(wf)] = {
117
+ "warmup_frac": wf,
118
+ "n_known_tasks": max(1, round(15 * wf)), "n_novel_tasks": 15 - max(1, round(15 * wf)),
119
+ "static_MAT": round(sg, 3), "personal_MAT": round(pm, 3),
120
+ "gap_pct": round(100 * (pm - sg) / sg, 1),
121
+ "static_std": round(st.pstdev(per_seed["static_global"]), 3),
122
+ "personal_std": round(st.pstdev(per_seed["personal_memory"]), 3)}
123
+ print(f"== wf={wf}: static {sg:.3f} | personal {pm:.3f} | "
124
+ f"gap {sweep[str(wf)]['gap_pct']:+}% ==", flush=True)
125
+
126
+ out = {"config": {"users": 40, "tasks_per_user": 15, "sessions": 12,
127
+ "seeds": SEEDS, "arms": "static_global (frozen) vs personal_memory (live)"},
128
+ "sweep_by_warmup": sweep,
129
+ "note": ("Post-warmup MAT (sessions 1-11), 3 seeds. warmup_frac controls how "
130
+ "much of each user's task set the frozen store sees at session 0. At "
131
+ "1.0 the frozen store has seen every template, so residual gap is pure "
132
+ "argument-drift decay; below 1.0 it also carries post-warmup novelty. "
133
+ "Real replay; wf=0.5 reuses cached phase2 targets, 0.75/1.0 use their "
134
+ "own served greedy targets.")}
135
+ (RESULTS / "review_r1_warmup_sweep.json").write_text(json.dumps(out, indent=2))
136
+ print("\n=== gap vs warmup fraction ===")
137
+ for wf in WARMUPS:
138
+ s = sweep[str(wf)]
139
+ print(f" warmup {int(wf*100)}%: static {s['static_MAT']} | personal "
140
+ f"{s['personal_MAT']} | gap {s['gap_pct']:+}%")
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
harness/review_r2_provenance.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Review R2 — cross-user draft-provenance rate in the live GLOBAL store.
2
+
3
+ The paper asserts that per-user partitioning buys privacy/isolation. This measures
4
+ it: in a live global (non-personalized) store, what fraction of drafts served to
5
+ user U were actually written by a DIFFERENT user U'? That is the leakage channel a
6
+ per-user store closes by construction (0% — a user only ever drafts from their own
7
+ calls). We reuse the Phase 4.3 overlap workload (user-consistent args: each user has
8
+ fixed, distinct argument realizations, so a cross-user draft carries another user's
9
+ concrete values) and its cached greedy targets — CPU replay, no serving.
10
+
11
+ Mirrors GlobalEvict exactly (same sim_threshold, LRU, capacity) but tags each stored
12
+ entry with its writer's user id and logs, per retrieval hit, whether writer != querier.
13
+
14
+ Run from the repo root: python -m harness.review_r2_provenance
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import statistics as st
21
+ from collections import OrderedDict, defaultdict
22
+ from pathlib import Path
23
+
24
+ from . import metrics
25
+ from .data import load_bfcl
26
+ from .memory import Embedder, Entry, _best_match, schema_draft
27
+ from .metrics import canonical_call_str
28
+ from .run_accept import _parse_target
29
+ from .simulate import build_users
30
+
31
+ ROOT = Path(__file__).resolve().parent.parent
32
+ RESULTS = ROOT / "results"
33
+ # Tokenizer for the token-LCP accept metric: HF hub id by default;
34
+ # override with a local snapshot path if running offline.
35
+ MODEL_PATH = os.environ.get("SPECMEM_TOKENIZER", "openai/gpt-oss-120b")
36
+ OVERLAPS = [0.0, 0.25, 0.5, 0.75, 1.0]
37
+ SEEDS = [0, 1, 2]
38
+ CAP = 48
39
+
40
+
41
+ class ProvenanceGlobal:
42
+ """GlobalEvict with per-entry writer tracking (byte-identical draft/observe
43
+ logic; only adds provenance bookkeeping)."""
44
+
45
+ def __init__(self, capacity):
46
+ self.capacity = capacity
47
+ self.sim_threshold = 0.35
48
+ self.store: "OrderedDict[int, Entry]" = OrderedDict()
49
+ self.writer: dict[int, str] = {}
50
+ self._next_id = 0
51
+
52
+ def draft(self, query, functions, user_id, embedder):
53
+ """Returns (call, writer_uid_or_None). writer is None on a schema-draft
54
+ miss (no retrieval); otherwise the user who wrote the retrieved entry."""
55
+ emb = embedder.embed(query)
56
+ keys = list(self.store.keys())
57
+ entries = [self.store[k] for k in keys]
58
+ i, sim = _best_match(emb, entries)
59
+ if i >= 0 and sim >= self.sim_threshold:
60
+ key = keys[i]
61
+ self.store.move_to_end(key)
62
+ self.store[key].freq += 1
63
+ return self.store[key].call, self.writer[key]
64
+ return schema_draft(functions), None
65
+
66
+ def observe(self, query, functions, user_id, name, args, embedder):
67
+ emb = embedder.embed(query)
68
+ eid = self._next_id
69
+ self._next_id += 1
70
+ self.store[eid] = Entry(emb, canonical_call_str(name, args))
71
+ self.writer[eid] = user_id
72
+ self.store.move_to_end(eid)
73
+ while len(self.store) > self.capacity:
74
+ old, _ = self.store.popitem(last=False)
75
+ self.writer.pop(old, None)
76
+
77
+
78
+ def _redact(call_str):
79
+ """Keep the call's structure (name + arg keys) but replace concrete argument
80
+ VALUES with a placeholder, so the example shows the leakage channel without
81
+ printing another user's real data."""
82
+ try:
83
+ obj = json.loads(call_str)
84
+ args = obj.get("arguments", {})
85
+ obj["arguments"] = {k: "<value from writer's call>" for k in args}
86
+ return json.dumps(obj)
87
+ except Exception:
88
+ return "<unparseable>"
89
+
90
+
91
+ def main():
92
+ metrics.get_tokenizer(MODEL_PATH)
93
+ tasks = load_bfcl()
94
+ emb = Embedder()
95
+ sweep = {}
96
+ example = None
97
+ for ov in OVERLAPS:
98
+ # per-seed rates
99
+ hit_cross, hit_total, all_total = [], [], []
100
+ cross_wrongargs = []
101
+ for sd in SEEDS:
102
+ inst = build_users(tasks, n_users=40, tasks_per_user=15, n_sessions=12,
103
+ queries_per_session=6, seed=sd, overlap_frac=ov,
104
+ user_consistent=True)
105
+ inst.sort(key=lambda x: (x.session, x.user_id))
106
+ cache_f = RESULTS / f"phase4_overlap_targets_ov{ov}_seed{sd}.json"
107
+ if not cache_f.exists():
108
+ print(f" MISSING {cache_f.name}; skip", flush=True)
109
+ continue
110
+ targets = json.loads(cache_f.read_text())
111
+ store = ProvenanceGlobal(capacity=40 * CAP)
112
+ n_all = n_hit = n_cross = n_cross_wrong = 0
113
+ for ins in inst:
114
+ tgt = targets.get(ins.query)
115
+ if tgt is None:
116
+ continue
117
+ if ins.session > 0: # post-warmup drafts
118
+ call, writer = store.draft(ins.query, ins.functions,
119
+ ins.user_id, emb)
120
+ n_all += 1
121
+ if writer is not None:
122
+ n_hit += 1
123
+ if writer != ins.user_id:
124
+ n_cross += 1
125
+ if call != tgt: # wrong-args leak
126
+ n_cross_wrong += 1
127
+ if example is None and ov >= 0.75:
128
+ example = {
129
+ "overlap_frac": ov, "seed": sd,
130
+ "querying_user": ins.user_id,
131
+ "writer_user": writer,
132
+ "drafted_call_redacted": _redact(call),
133
+ "note": ("global store served user U a draft "
134
+ "written by user U'; concrete arg "
135
+ "values are U''s, not U's")}
136
+ cn, ca = _parse_target(tgt)
137
+ store.observe(ins.query, ins.functions, ins.user_id, cn, ca, emb)
138
+ if n_hit:
139
+ hit_cross.append(100 * n_cross / n_hit)
140
+ hit_total.append(100 * n_hit / max(1, n_all))
141
+ all_total.append(100 * n_cross / max(1, n_all))
142
+ cross_wrongargs.append(100 * n_cross_wrong / max(1, n_cross))
143
+ print(f"ov={ov} seed={sd}: {n_cross}/{n_hit} hits cross-user "
144
+ f"({100*n_cross/max(1,n_hit):.1f}%), {n_cross}/{n_all} of all "
145
+ f"drafts ({100*n_cross/max(1,n_all):.1f}%)", flush=True)
146
+ sweep[ov] = {
147
+ "cross_user_pct_of_retrieval_hits": round(st.mean(hit_cross), 1) if hit_cross else 0.0,
148
+ "cross_user_pct_of_all_drafts": round(st.mean(all_total), 1) if all_total else 0.0,
149
+ "retrieval_hit_pct_of_drafts": round(st.mean(hit_total), 1) if hit_total else 0.0,
150
+ "cross_user_drafts_with_wrong_args_pct": round(st.mean(cross_wrongargs), 1) if cross_wrongargs else 0.0,
151
+ }
152
+ print(f"== ov={ov}: cross-user {sweep[ov]['cross_user_pct_of_retrieval_hits']}% "
153
+ f"of hits ==", flush=True)
154
+
155
+ out = {"config": {"users": 40, "tasks_per_user": 15, "sessions": 12,
156
+ "capacity_global": 40 * CAP, "seeds": SEEDS,
157
+ "workload": "Phase 4.3 user-consistent overlap (cached targets)",
158
+ "personal_store_cross_user_rate_pct": 0.0,
159
+ "personal_note": ("per-user store: 0% cross-user provenance by "
160
+ "construction — a user only drafts from its own "
161
+ "writes")},
162
+ "sweep_by_overlap": sweep,
163
+ "example_cross_user_draft": example,
164
+ "metric": ("cross-user draft-provenance rate = fraction of drafts served to "
165
+ "user U whose retrieved entry was written by a different user U'; "
166
+ "measured post-warmup (sessions 1-11), 3 seeds, on the live global "
167
+ "store; real replay, no tuning")}
168
+ (RESULTS / "review_r2_crossuser_provenance.json").write_text(json.dumps(out, indent=2))
169
+ print("\n=== cross-user provenance rate (% of retrieval hits) by overlap ===")
170
+ for ov in OVERLAPS:
171
+ if ov in sweep:
172
+ print(f" overlap {ov}: {sweep[ov]['cross_user_pct_of_retrieval_hits']}% "
173
+ f"(of all drafts: {sweep[ov]['cross_user_pct_of_all_drafts']}%)")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
harness/review_r3_confidence.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Round-3 review ask: EMPIRICAL retrieval-confidence distributions.
2
+
3
+ All three round-1 reviewers objected that the safety experiment's confidence
4
+ model is author-sampled (correct ~U[0.6,1.0], stale ~U[0.3,0.85]), so the
5
+ "no confidence threshold separates right from wrong" conclusion could be an
6
+ artifact of the chosen supports. This script measures the real thing on the
7
+ full-scale BFCL replay (seed 0, cached targets, GPU-free): for every
8
+ post-warmup draft retrieved from the frozen global store and from the personal
9
+ store, log the retrieval cosine similarity and whether the draft was exactly
10
+ right, then ask whether ANY similarity threshold separates correct from wrong.
11
+
12
+ Output: results/review_r3_empirical_confidence.json
13
+ Run: OMP_NUM_THREADS=8 <python> -m harness.review_r3_confidence
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+
22
+ from . import metrics
23
+ from .data import load_bfcl
24
+ from .memory import (Embedder, StaticGlobal, PersonalMemory, _best_match,
25
+ schema_draft)
26
+ from .run_accept import MODEL_PATH
27
+ from .simulate import build_users
28
+
29
+ ROOT = Path(__file__).resolve().parent.parent
30
+ RESULTS = ROOT / "results"
31
+
32
+
33
+ def _stats(sims: np.ndarray) -> dict:
34
+ if len(sims) == 0:
35
+ return {"n": 0}
36
+ return {
37
+ "n": int(len(sims)),
38
+ "mean": round(float(sims.mean()), 4),
39
+ "p5": round(float(np.percentile(sims, 5)), 4),
40
+ "p25": round(float(np.percentile(sims, 25)), 4),
41
+ "median": round(float(np.percentile(sims, 50)), 4),
42
+ "p75": round(float(np.percentile(sims, 75)), 4),
43
+ "p95": round(float(np.percentile(sims, 95)), 4),
44
+ "max": round(float(sims.max()), 4),
45
+ "min": round(float(sims.min()), 4),
46
+ }
47
+
48
+
49
+ def _separability(correct: np.ndarray, wrong: np.ndarray) -> dict:
50
+ """Can a threshold tau (fire draft only if sim >= tau) reach zero wrong
51
+ while keeping correct drafts? Mirrors the safety confidence-gate sweep."""
52
+ out = {}
53
+ if len(wrong) == 0 or len(correct) == 0:
54
+ return out
55
+ # AUROC of similarity as a correct-vs-wrong discriminator
56
+ labels = np.concatenate([np.ones(len(correct)), np.zeros(len(wrong))])
57
+ scores = np.concatenate([correct, wrong])
58
+ order = np.argsort(scores)
59
+ ranks = np.empty(len(scores)); ranks[order] = np.arange(1, len(scores) + 1)
60
+ n1, n0 = len(correct), len(wrong)
61
+ auroc = (ranks[labels == 1].sum() - n1 * (n1 + 1) / 2) / (n1 * n0)
62
+ out["auroc"] = round(float(auroc), 4)
63
+ # zero-wrong threshold: must clear the highest-similarity wrong draft
64
+ tau_zero = float(wrong.max())
65
+ out["tau_for_zero_wrong"] = round(tau_zero, 4)
66
+ out["correct_forfeited_at_tau_zero_pct"] = round(
67
+ 100 * float((correct <= tau_zero).mean()), 1)
68
+ # per-threshold sweep mirroring the safety table
69
+ sweep = {}
70
+ for tau in (0.5, 0.7, 0.8, 0.9, 0.95, 0.99):
71
+ sweep[str(tau)] = {
72
+ "wrong_fired": int((wrong >= tau).sum()),
73
+ "wrong_fired_pct": round(100 * float((wrong >= tau).mean()), 1),
74
+ "correct_kept_pct": round(100 * float((correct >= tau).mean()), 1),
75
+ }
76
+ out["threshold_sweep"] = sweep
77
+ return out
78
+
79
+
80
+ def run() -> None:
81
+ metrics.get_tokenizer(MODEL_PATH)
82
+ tasks = load_bfcl()
83
+ embedder = Embedder()
84
+ instances = build_users(
85
+ tasks, n_users=40, tasks_per_user=15, n_sessions=12,
86
+ queries_per_session=6, seed=0, perturb_prob=1.0,
87
+ arrival="spread", novel_weight=3.0, warmup_frac=0.5)
88
+ instances.sort(key=lambda x: (x.session, x.user_id))
89
+ targets = json.loads((RESULTS / "phase2_targets_seed0.json").read_text())
90
+
91
+ static, personal = StaticGlobal(), PersonalMemory(capacity=48,
92
+ eviction="lru")
93
+ log = {"static_global": [], "personal_memory": []}
94
+ cur_session = -1
95
+ for ins in instances:
96
+ tgt = targets.get(ins.query)
97
+ if tgt is None:
98
+ continue
99
+ if ins.session != cur_session:
100
+ cur_session = ins.session
101
+ if cur_session == 1:
102
+ static.freeze()
103
+ emb = embedder.embed(ins.query)
104
+ entries_by_arm = (
105
+ ("static_global", static.entries),
106
+ ("personal_memory",
107
+ list(personal.stores.get(ins.user_id, {}).values())))
108
+ for arm, entries in entries_by_arm:
109
+ i, sim = _best_match(emb, entries)
110
+ if i >= 0 and ins.session >= 1:
111
+ draft = entries[i].call
112
+ s = metrics.score(draft, tgt)
113
+ log[arm].append((float(sim), bool(s["exact"]),
114
+ float(s["accepted_frac"])))
115
+ # observe/write-back exactly as in run_ablation
116
+ from .run_accept import _parse_target
117
+ name, argd = _parse_target(tgt)
118
+ static.observe(ins.query, ins.functions, ins.user_id, name, argd,
119
+ embedder)
120
+ personal.observe(ins.query, ins.functions, ins.user_id, name, argd,
121
+ embedder)
122
+ if ins.session == 0:
123
+ personal.seed_shared(ins.query, name, argd, embedder)
124
+
125
+ out = {"design": {
126
+ "workload": "phase2 full-scale BFCL, seed 0, cached greedy targets",
127
+ "what": "retrieval cosine similarity vs. exact-correctness of the "
128
+ "retrieved draft, post-warmup (sessions 1-11) retrieval hits",
129
+ "correct": "draft serializes exactly to the served model's target",
130
+ }}
131
+ for arm, rows in log.items():
132
+ sims = np.array([r[0] for r in rows])
133
+ exact = np.array([r[1] for r in rows])
134
+ correct, wrong = sims[exact], sims[~exact]
135
+ out[arm] = {
136
+ "n_retrieval_drafts": int(len(rows)),
137
+ "exact_rate": round(float(exact.mean()), 4),
138
+ "sim_correct": _stats(correct),
139
+ "sim_wrong": _stats(wrong),
140
+ "separability": _separability(correct, wrong),
141
+ }
142
+ path = RESULTS / "review_r3_empirical_confidence.json"
143
+ path.write_text(json.dumps(out, indent=1))
144
+ print(json.dumps(out, indent=1))
145
+ print(f"\nWrote {path}")
146
+
147
+
148
+ if __name__ == "__main__":
149
+ run()
harness/review_r3_rebuild_arm.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Round-2 review ask: periodic-rebuild baseline + cold-start backoff accounting.
2
+
3
+ (a) Periodic rebuild: the deployment-realistic competitor to per-call write-back
4
+ is a store rebuilt from accumulated logs every k sessions, frozen in between.
5
+ Arms: static (never rebuilt), rebuild_k1/k2/k4 (global store rebuilt from ALL
6
+ calls observed so far at every k-th session boundary), personal (ours, live).
7
+ Replays the phase-2 seed-0 stream against cached targets (no GPU).
8
+
9
+ (b) Backoff accounting: fraction of post-warmup PersonalMemory drafts served by
10
+ the user's own store vs. the shared cold-start backoff vs. the schema draft,
11
+ and the cross-user share of backoff-served drafts (writer != requester).
12
+
13
+ Usage: OMP_NUM_THREADS=8 <python> -m harness.review_r3_rebuild_arm
14
+ Writes results/review_r3_rebuild_backoff.json.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from collections import defaultdict
20
+ from pathlib import Path
21
+
22
+ from . import metrics
23
+ from .data import load_bfcl
24
+ from .memory import (Embedder, Entry, PersonalMemory, StaticGlobal,
25
+ _best_match, canonical_call_str, schema_draft)
26
+ from .run_accept import MODEL_PATH, _parse_target
27
+ from .simulate import build_users
28
+
29
+ ROOT = Path(__file__).resolve().parent.parent
30
+ RESULTS = ROOT / "results"
31
+
32
+
33
+ class RebuildArm:
34
+ """Frozen global store rebuilt from the full observation log every k
35
+ sessions; identical retrieval to StaticGlobal in between."""
36
+
37
+ def __init__(self, k: int):
38
+ self.k = k
39
+ self.name = f"rebuild_k{k}"
40
+ self.entries: list[Entry] = []
41
+ self._log: list[Entry] = []
42
+
43
+ def maybe_rebuild(self, session: int) -> None:
44
+ # session boundaries s = 1..11; rebuild at s = 1, 1+k, 1+2k, ...
45
+ if session >= 1 and (session - 1) % self.k == 0:
46
+ self.entries = list(self._log)
47
+
48
+ def draft(self, query, functions, user_id, embedder) -> str:
49
+ emb = embedder.embed(query)
50
+ i, _ = _best_match(emb, self.entries)
51
+ if i < 0:
52
+ return schema_draft(functions)
53
+ return self.entries[i].call
54
+
55
+ def observe(self, query, functions, user_id, name, args, embedder) -> None:
56
+ self._log.append(Entry(embedder.embed(query),
57
+ canonical_call_str(name, args)))
58
+
59
+
60
+ def main() -> None:
61
+ metrics.get_tokenizer(MODEL_PATH)
62
+ tasks = load_bfcl()
63
+ embedder = Embedder()
64
+ instances = build_users(tasks, n_users=40, tasks_per_user=15,
65
+ n_sessions=12, queries_per_session=6, seed=0,
66
+ perturb_prob=1.0, arrival="spread",
67
+ novel_weight=3.0, warmup_frac=0.5)
68
+ instances.sort(key=lambda x: (x.session, x.user_id))
69
+ targets = json.loads((RESULTS / "phase2_targets_seed0.json").read_text())
70
+
71
+ static = StaticGlobal()
72
+ personal = PersonalMemory(capacity=48, eviction="lru")
73
+ rebuilds = [RebuildArm(k) for k in (1, 2, 4)]
74
+ entry_writer: dict[int, str] = {} # id(shared Entry) -> writer user
75
+
76
+ agg = defaultdict(lambda: defaultdict(list))
77
+ backoff = {"own": 0, "shared": 0, "schema": 0, "shared_cross_user": 0}
78
+ cur_session = -1
79
+ for ins in instances:
80
+ tgt = targets.get(ins.query)
81
+ if tgt is None:
82
+ continue
83
+ if ins.session != cur_session:
84
+ cur_session = ins.session
85
+ if cur_session == 1:
86
+ static.freeze()
87
+ for r in rebuilds:
88
+ r.maybe_rebuild(cur_session)
89
+ arms = [("static", static), ("personal", personal)] + \
90
+ [(r.name, r) for r in rebuilds]
91
+ for name, a in arms:
92
+ draft = a.draft(ins.query, ins.functions, ins.user_id, embedder)
93
+ agg[name][ins.session].append(metrics.score(draft, tgt))
94
+ # backoff accounting for the personal arm (mirrors PersonalMemory.draft)
95
+ if ins.session >= 1:
96
+ emb = embedder.embed(ins.query)
97
+ own = list(personal.stores.get(ins.user_id, {}).values())
98
+ i, sim = _best_match(emb, own)
99
+ if i >= 0 and sim >= personal.sim_threshold:
100
+ backoff["own"] += 1
101
+ else:
102
+ j, sj = _best_match(emb, personal.shared)
103
+ if j >= 0 and sj >= personal.sim_threshold:
104
+ backoff["shared"] += 1
105
+ w = entry_writer.get(id(personal.shared[j]))
106
+ if w is not None and w != ins.user_id:
107
+ backoff["shared_cross_user"] += 1
108
+ else:
109
+ backoff["schema"] += 1
110
+ name, argd = _parse_target(tgt)
111
+ for _, a in arms:
112
+ a.observe(ins.query, ins.functions, ins.user_id, name, argd,
113
+ embedder)
114
+ if ins.session == 0:
115
+ personal.seed_shared(ins.query, name, argd, embedder)
116
+ entry_writer[id(personal.shared[-1])] = ins.user_id
117
+
118
+ def overall(per_sess):
119
+ xs = [x for s, v in per_sess.items() if s >= 1 for x in v]
120
+ return {"n": len(xs),
121
+ "MAT": round(sum(x["accept_length"] for x in xs) / len(xs), 3)}
122
+
123
+ out = {"design": {
124
+ "workload": "phase2 full-scale BFCL, seed 0, cached greedy targets",
125
+ "rebuild": "global store rebuilt from full observation log at "
126
+ "sessions 1, 1+k, 1+2k, ...; frozen in between",
127
+ }}
128
+ for name, per_sess in agg.items():
129
+ out[name] = overall(per_sess)
130
+ out[name]["by_session"] = {
131
+ str(s): round(sum(x["accept_length"] for x in v) / len(v), 2)
132
+ for s, v in sorted(per_sess.items())}
133
+ n_post = sum(backoff[k] for k in ("own", "shared", "schema"))
134
+ out["personal_backoff_accounting"] = {
135
+ "n_post_warmup": n_post,
136
+ "own_store_pct": round(100 * backoff["own"] / n_post, 1),
137
+ "shared_backoff_pct": round(100 * backoff["shared"] / n_post, 1),
138
+ "schema_draft_pct": round(100 * backoff["schema"] / n_post, 1),
139
+ "shared_cross_user_pct_of_all_drafts":
140
+ round(100 * backoff["shared_cross_user"] / n_post, 2),
141
+ }
142
+ path = RESULTS / "review_r3_rebuild_backoff.json"
143
+ path.write_text(json.dumps(out, indent=1))
144
+ print(json.dumps(out, indent=1))
145
+ print(f"\nWrote {path}")
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
harness/run_ablation.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ablation: decompose PersonalMemory's gain over StaticGlobal into the
2
+ contributions of (a) per-user personalization and (b) online-growth+eviction.
3
+
4
+ 2x2 design (personalized? x evicting/growing?):
5
+ static_global [-pers, -grow] frozen global datastore (ToolSpec-style) [baseline]
6
+ global_evict [-pers, +grow] one global store, keeps ingesting + LRU-evicts
7
+ personal_noevict [+pers, -evict] per-user store, grows online, UNBOUNDED
8
+ personal_memory [+pers, +evict] per-user store, grows online + LRU-evicts [ours]
9
+
10
+ All four share the identical target stream (arm-independent greedy target decode
11
+ from the served model), so they differ ONLY in memory policy. Targets are cached
12
+ to disk so re-runs need no GPU. Single seed at full scale: the primary 3-seed run
13
+ established seed-std <= 0.20 MAT, so one seed is sufficient to attribute the gap.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ from collections import defaultdict
20
+ from pathlib import Path
21
+
22
+ from . import metrics
23
+ from .client import ToolClient
24
+ from .data import load_bfcl
25
+ from .memory import (Embedder, StaticGlobal, GlobalEvict, PersonalNoEvict,
26
+ PersonalMemory)
27
+ from .run_accept import generate_targets, _parse_target, MODEL_PATH
28
+ from .simulate import build_users
29
+
30
+ ROOT = Path(__file__).resolve().parent.parent
31
+ RESULTS = ROOT / "results"
32
+
33
+
34
+ def _make_arms(args):
35
+ return [
36
+ StaticGlobal(),
37
+ GlobalEvict(capacity=args.users * args.capacity),
38
+ PersonalNoEvict(),
39
+ PersonalMemory(capacity=args.capacity, eviction="lru"),
40
+ ]
41
+
42
+
43
+ def _replay(instances, targets, embedder, args):
44
+ arms = _make_arms(args)
45
+ agg = {a.name: defaultdict(list) for a in arms}
46
+ cur_session = -1
47
+ for ins in instances:
48
+ tgt = targets.get(ins.query)
49
+ if tgt is None:
50
+ continue
51
+ if ins.session != cur_session:
52
+ cur_session = ins.session
53
+ for a in arms:
54
+ if isinstance(a, StaticGlobal) and cur_session == 1:
55
+ a.freeze()
56
+ for a in arms:
57
+ draft = a.draft(ins.query, ins.functions, ins.user_id, embedder)
58
+ agg[a.name][ins.session].append(metrics.score(draft, tgt))
59
+ name, argd = _parse_target(tgt)
60
+ for a in arms:
61
+ a.observe(ins.query, ins.functions, ins.user_id, name, argd, embedder)
62
+ if isinstance(a, PersonalMemory) and ins.session == 0:
63
+ a.seed_shared(ins.query, name, argd, embedder)
64
+ return agg
65
+
66
+
67
+ def _overall(agg, warmup=0):
68
+ out = {}
69
+ for arm, per_sess in agg.items():
70
+ scores = [x for s, xs in per_sess.items() if s > warmup for x in xs]
71
+ n = len(scores)
72
+ out[arm] = {
73
+ "n": n,
74
+ "MAT": round(sum(x["accept_length"] for x in scores) / n, 3),
75
+ "accepted_frac": round(sum(x["accepted_frac"] for x in scores) / n, 4),
76
+ "exact_rate": round(sum(1 for x in scores if x["exact"]) / n, 4),
77
+ }
78
+ return out
79
+
80
+
81
+ def _by_session(agg, n_sessions):
82
+ out = {}
83
+ for arm, per_sess in agg.items():
84
+ out[arm] = {}
85
+ for s in range(n_sessions):
86
+ xs = per_sess.get(s, [])
87
+ if xs:
88
+ out[arm][str(s)] = round(
89
+ sum(x["accept_length"] for x in xs) / len(xs), 3)
90
+ return out
91
+
92
+
93
+ def run(args):
94
+ RESULTS.mkdir(exist_ok=True)
95
+ metrics.get_tokenizer(args.model_path or MODEL_PATH)
96
+ tasks = load_bfcl()
97
+ embedder = Embedder()
98
+
99
+ instances = build_users(
100
+ tasks, n_users=args.users, tasks_per_user=args.tasks_per_user,
101
+ n_sessions=args.sessions, queries_per_session=args.queries_per_session,
102
+ seed=args.seed, perturb_prob=args.perturb_prob,
103
+ arrival=args.arrival, novel_weight=args.novel_weight,
104
+ warmup_frac=args.warmup_frac)
105
+ instances.sort(key=lambda x: (x.session, x.user_id))
106
+
107
+ cache = RESULTS / f"{args.tag}_targets_seed{args.seed}.json"
108
+ if cache.exists():
109
+ print(f"[ablation] loading cached targets from {cache.name}", flush=True)
110
+ targets = json.loads(cache.read_text())
111
+ else:
112
+ client = ToolClient(url=args.url, model=args.model)
113
+ if not client.ping():
114
+ raise SystemExit(f"served model not reachable at {args.url}")
115
+ print(f"[ablation] {len(instances)} instances; generating targets ...",
116
+ flush=True)
117
+ targets = generate_targets(client, instances, workers=args.workers)
118
+ cache.write_text(json.dumps(targets, indent=1))
119
+ print(f"[ablation] cached {len(targets)} targets -> {cache.name}",
120
+ flush=True)
121
+
122
+ n_none = sum(1 for v in targets.values() if v is None)
123
+ agg = _replay(instances, targets, embedder, args)
124
+ overall = _overall(agg, warmup=0)
125
+ by_session = _by_session(agg, args.sessions)
126
+
127
+ static = overall["static_global"]["MAT"]
128
+ ours = overall["personal_memory"]["MAT"]
129
+ gevict = overall["global_evict"]["MAT"]
130
+ pnoev = overall["personal_noevict"]["MAT"]
131
+ # Clean single-variable contrasts (each isolates ONE knob, holding the rest):
132
+ # live/growth : freeze->live on a global store (evict held on) = gevict - static
133
+ # personalization: global->per-user, holding live+evict = ours - gevict
134
+ # eviction : unbounded->bounded, holding live+per-user = ours - pnoev
135
+ decomp = {
136
+ "total_gap_ours_vs_static": round(ours - static, 3),
137
+ "live_growth_effect (global_evict - static)": round(gevict - static, 3),
138
+ "personalization_effect (personal_memory - global_evict)": round(ours - gevict, 3),
139
+ "eviction_effect (personal_memory - personal_noevict)": round(ours - pnoev, 3),
140
+ "live_growth_share_pct": round(100 * (gevict - static) / (ours - static), 1)
141
+ if ours != static else None,
142
+ }
143
+
144
+ out = {
145
+ "config": vars(args),
146
+ "seed": args.seed,
147
+ "n_instances": len(instances),
148
+ "n_unique_queries": len(targets),
149
+ "n_no_toolcall": n_none,
150
+ "overall_post_warmup": overall,
151
+ "by_session": by_session,
152
+ "decomposition": decomp,
153
+ }
154
+ (RESULTS / f"{args.tag}_ablation_results.json").write_text(
155
+ json.dumps(out, indent=2))
156
+
157
+ print("\n=== Ablation: post-warmup MAT (sessions >= 1) ===", flush=True)
158
+ for arm in ["static_global", "global_evict", "personal_noevict",
159
+ "personal_memory"]:
160
+ v = overall[arm]
161
+ print(f" {arm:>16}: MAT={v['MAT']:6.2f} acc_frac={v['accepted_frac']:.3f}"
162
+ f" exact={v['exact_rate']:.3f} n={v['n']}", flush=True)
163
+ print("\n=== Decomposition ===", flush=True)
164
+ for k, v in decomp.items():
165
+ print(f" {k}: {v}", flush=True)
166
+ print(f"\nWrote results/{args.tag}_ablation_results.json", flush=True)
167
+
168
+
169
+ def main():
170
+ p = argparse.ArgumentParser()
171
+ p.add_argument("--users", type=int, default=40)
172
+ p.add_argument("--tasks-per-user", type=int, default=15)
173
+ p.add_argument("--sessions", type=int, default=12)
174
+ p.add_argument("--queries-per-session", type=int, default=6)
175
+ p.add_argument("--capacity", type=int, default=48)
176
+ p.add_argument("--workers", type=int, default=16)
177
+ p.add_argument("--seed", type=int, default=0)
178
+ p.add_argument("--url", default="http://localhost:30000/v1")
179
+ p.add_argument("--model", default="gpt-oss-120b")
180
+ p.add_argument("--model-path", default="")
181
+ p.add_argument("--tag", default="phase2")
182
+ p.add_argument("--arrival", default="spread", choices=["spread","burst","late"])
183
+ p.add_argument("--novel-weight", type=float, default=3.0)
184
+ p.add_argument("--warmup-frac", type=float, default=0.5)
185
+ p.add_argument("--perturb-prob", type=float, default=1.0,
186
+ help="probability a post-warmup query gets numeric-variant "
187
+ "perturbation (1.0 = legacy behaviour; 0.0 = verbatim "
188
+ "repeats only, isolating novel-task drift).")
189
+ run(p.parse_args())
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
harness/run_accept.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """POC acceptance experiment: 3 memory arms across simulated users/sessions.
2
+
3
+ Pipeline:
4
+ 1. Build simulated users -> ordered (session-major) instance stream.
5
+ 2. Generate the genuine greedy target tool call for every unique query from
6
+ the served gpt-oss-120b (concurrent; cached by exact query string).
7
+ 3. Replay the stream through each arm. For each instance an arm first DRAFTS
8
+ (from its current memory), we score token-LCP accept vs the target, then
9
+ the arm OBSERVES the target (growing its store). static_global observes
10
+ only during warmup (session 0) then freezes -- ToolSpec behaviour.
11
+ 4. Aggregate Mean Accepted Tokens (MAT) and acceptance rate by (arm, session)
12
+ and dump results/accept_results.json.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from collections import defaultdict
21
+ from pathlib import Path
22
+
23
+ from . import metrics
24
+ from .client import ToolClient
25
+ from .data import load_bfcl, load_sealtools, load_tau2
26
+ from .memory import Embedder, NoMemory, PersonalMemory, StaticGlobal
27
+ from .simulate import build_users
28
+
29
+ ROOT = Path(__file__).resolve().parent.parent
30
+ RESULTS = ROOT / "results"
31
+ # Tokenizer for the token-LCP accept metric: HF hub id by default;
32
+ # override with a local snapshot path if running offline.
33
+ MODEL_PATH = os.environ.get("SPECMEM_TOKENIZER", "openai/gpt-oss-120b")
34
+
35
+
36
+ def generate_targets(client, instances, workers=16):
37
+ """Return {query: canonical_target_str} for every unique query."""
38
+ uniq = {}
39
+ for ins in instances:
40
+ uniq.setdefault(ins.query, ins.functions)
41
+ items = list(uniq.items())
42
+
43
+ def _one(qf):
44
+ q, funcs = qf
45
+ call = client.generate_call(q, funcs)
46
+ if call is None:
47
+ return q, None
48
+ return q, metrics.canonical_call_str(call["name"], call["arguments"])
49
+
50
+ targets = {}
51
+ with ThreadPoolExecutor(max_workers=workers) as ex:
52
+ for i, (q, tgt) in enumerate(ex.map(_one, items)):
53
+ targets[q] = tgt
54
+ if (i + 1) % 25 == 0:
55
+ print(f" targets {i+1}/{len(items)}", flush=True)
56
+ return targets
57
+
58
+
59
+ def _replay(instances, targets, embedder, args, per_instance):
60
+ """Replay one seed's stream through the 3 arms; return per-session scores."""
61
+ arms = [NoMemory(), StaticGlobal(),
62
+ PersonalMemory(capacity=args.capacity, eviction=args.eviction)]
63
+ agg = {a.name: defaultdict(list) for a in arms}
64
+ cur_session = -1
65
+ for ins in instances:
66
+ tgt = targets.get(ins.query)
67
+ if tgt is None:
68
+ continue
69
+ if ins.session != cur_session:
70
+ cur_session = ins.session
71
+ for a in arms: # freeze static after warmup
72
+ if isinstance(a, StaticGlobal) and cur_session == 1:
73
+ a.freeze()
74
+ for a in arms:
75
+ draft = a.draft(ins.query, ins.functions, ins.user_id, embedder)
76
+ sc = metrics.score(draft, tgt)
77
+ agg[a.name][ins.session].append(sc)
78
+ if a.name == "personal_memory" and per_instance is not None:
79
+ per_instance.append({
80
+ "user": ins.user_id, "session": ins.session,
81
+ "sig": ins.signature_id, "novel": ins.novel,
82
+ "accept": sc["accept_length"], "tlen": sc["target_len"]})
83
+ call_name, call_args = _parse_target(tgt)
84
+ for a in arms:
85
+ if isinstance(a, StaticGlobal):
86
+ a.observe(ins.query, ins.functions, ins.user_id,
87
+ call_name, call_args, embedder)
88
+ elif isinstance(a, PersonalMemory):
89
+ a.observe(ins.query, ins.functions, ins.user_id,
90
+ call_name, call_args, embedder)
91
+ if ins.session == 0:
92
+ a.seed_shared(ins.query, call_name, call_args, embedder)
93
+ return agg
94
+
95
+
96
+ def run(args):
97
+ RESULTS.mkdir(exist_ok=True)
98
+ # Tokenizer used for the token-LCP accept metric: use the served model's
99
+ # own tokenizer so acceptance reflects what a spec decoder for THAT model
100
+ # would see. Defaults to gpt-oss for backward compatibility.
101
+ metrics.get_tokenizer(args.model_path or MODEL_PATH)
102
+
103
+ bench = getattr(args, "benchmark", "bfcl")
104
+ if bench == "tau2":
105
+ raise SystemExit(
106
+ "REJECTED DESIGN: --benchmark tau2 previously extracted decision "
107
+ "points from tau2-bench's SHIPPED reference trajectories, which "
108
+ "were generated with GPT-4.1 as the agent — an off-policy "
109
+ "target-substitution bug. Use harness/tau2_live.py to generate canonical "
110
+ "traces with the real served model + a live user simulator "
111
+ "(requires OPENAI_API_KEY), then score with its replay mode.")
112
+ tasks = {"bfcl": load_bfcl, "sealtools": load_sealtools}[bench]()
113
+ client = ToolClient(url=args.url, model=args.model)
114
+ if not client.ping():
115
+ raise SystemExit(f"served model not reachable at {args.url}")
116
+ embedder = Embedder()
117
+
118
+ seeds = list(range(args.seed, args.seed + args.n_seeds))
119
+ arm_names = ["no_memory", "static_global", "personal_memory"]
120
+ agg = {a: defaultdict(list) for a in arm_names} # pooled over seeds
121
+ per_seed_overall = {a: [] for a in arm_names} # MAT per seed
122
+ per_instance = []
123
+ n_instances_total = n_unique_total = n_none_total = 0
124
+
125
+ for si, sd in enumerate(seeds):
126
+ instances = build_users(
127
+ tasks, n_users=args.users, tasks_per_user=args.tasks_per_user,
128
+ n_sessions=args.sessions,
129
+ queries_per_session=args.queries_per_session, seed=sd)
130
+ instances.sort(key=lambda x: (x.session, x.user_id))
131
+ n_instances_total += len(instances)
132
+ print(f"[seed {sd}] {len(instances)} instances; generating targets ...",
133
+ flush=True)
134
+ targets = generate_targets(client, instances, workers=args.workers)
135
+ n_none = sum(1 for v in targets.values() if v is None)
136
+ n_unique_total += len(targets)
137
+ n_none_total += n_none
138
+ print(f"[seed {sd}] {len(targets)} unique queries, {n_none} no-call",
139
+ flush=True)
140
+
141
+ seed_agg = _replay(instances, targets, embedder, args,
142
+ per_instance if si == 0 else None)
143
+ for a in arm_names:
144
+ for s, xs in seed_agg[a].items():
145
+ agg[a][s].extend(xs)
146
+ post = [x for s, xs in seed_agg[a].items() if s > 0 for x in xs]
147
+ if post:
148
+ per_seed_overall[a].append(
149
+ sum(x["accept_length"] for x in post) / len(post))
150
+
151
+ summary = _summarize(agg, args)
152
+ overall = _overall(agg, warmup_session=0) # post-warmup aggregate
153
+ for a in arm_names: # add cross-seed std of MAT
154
+ vals = per_seed_overall[a]
155
+ if vals and a in overall:
156
+ mean = sum(vals) / len(vals)
157
+ var = sum((v - mean) ** 2 for v in vals) / len(vals)
158
+ overall[a]["MAT_seed_std"] = round(var ** 0.5, 3)
159
+ overall[a]["n_seeds"] = len(vals)
160
+ out = {
161
+ "config": vars(args),
162
+ "seeds": seeds,
163
+ "n_instances": n_instances_total,
164
+ "n_unique_queries": n_unique_total,
165
+ "n_no_toolcall": n_none_total,
166
+ "summary": summary,
167
+ "overall_post_warmup": overall,
168
+ }
169
+ tag = args.tag + "_" if args.tag else ""
170
+ (RESULTS / f"{tag}accept_results.json").write_text(json.dumps(out, indent=2))
171
+ (RESULTS / f"{tag}personal_per_instance.json").write_text(
172
+ json.dumps(per_instance, indent=2))
173
+ _write_csv(summary, args.sessions, tag)
174
+ print("\n=== Mean Accepted Tokens (MAT) by session ===", flush=True)
175
+ _print_table(summary, args.sessions)
176
+ print("\n=== Overall (sessions >= 1) ===", flush=True)
177
+ for arm, v in overall.items():
178
+ print(f" {arm:>16}: MAT={v['MAT']:.2f} "
179
+ f"accepted_frac={v['accepted_frac']:.3f} "
180
+ f"exact_rate={v['exact_rate']:.3f} n={v['n']}", flush=True)
181
+ print("\nWrote results/accept_results.json + accept_by_session.csv", flush=True)
182
+
183
+
184
+ def _overall(agg, warmup_session=0):
185
+ out = {}
186
+ for arm, per_sess in agg.items():
187
+ scores = [x for s, xs in per_sess.items() if s > warmup_session
188
+ for x in xs]
189
+ if not scores:
190
+ continue
191
+ n = len(scores)
192
+ out[arm] = {
193
+ "n": n,
194
+ "MAT": round(sum(x["accept_length"] for x in scores) / n, 3),
195
+ "accepted_frac": round(sum(x["accepted_frac"] for x in scores) / n, 4),
196
+ "exact_rate": round(sum(1 for x in scores if x["exact"]) / n, 4),
197
+ }
198
+ return out
199
+
200
+
201
+ def _write_csv(summary, n_sessions, tag=""):
202
+ lines = ["arm,session,n,MAT,accepted_frac,exact_rate"]
203
+ for arm in summary:
204
+ for s in range(n_sessions):
205
+ v = summary[arm].get(str(s))
206
+ if v:
207
+ lines.append(f"{arm},{s},{v['n']},{v['MAT']},"
208
+ f"{v['accepted_frac']},{v['exact_rate']}")
209
+ (RESULTS / f"{tag}accept_by_session.csv").write_text("\n".join(lines) + "\n")
210
+
211
+
212
+ def _parse_target(tgt: str):
213
+ d = json.loads(tgt)
214
+ return d["name"], d.get("arguments", {})
215
+
216
+
217
+ def _summarize(agg, args):
218
+ summary = {}
219
+ for arm, per_sess in agg.items():
220
+ summary[arm] = {}
221
+ for s, scores in per_sess.items():
222
+ n = len(scores)
223
+ mat = sum(x["accept_length"] for x in scores) / n
224
+ frac = sum(x["accepted_frac"] for x in scores) / n
225
+ exact = sum(1 for x in scores if x["exact"]) / n
226
+ summary[arm][str(s)] = {"n": n, "MAT": round(mat, 3),
227
+ "accepted_frac": round(frac, 4),
228
+ "exact_rate": round(exact, 4)}
229
+ return summary
230
+
231
+
232
+ def _print_table(summary, n_sessions):
233
+ arms = list(summary.keys())
234
+ header = "session | " + " | ".join(f"{a:>16}" for a in arms)
235
+ print(header)
236
+ print("-" * len(header))
237
+ for s in range(n_sessions):
238
+ cells = []
239
+ for a in arms:
240
+ v = summary[a].get(str(s))
241
+ cells.append(f"{v['MAT']:>16.2f}" if v else " " * 16)
242
+ print(f"{s:>7} | " + " | ".join(cells))
243
+
244
+
245
+ def main():
246
+ p = argparse.ArgumentParser()
247
+ p.add_argument("--users", type=int, default=6)
248
+ p.add_argument("--tasks-per-user", type=int, default=5)
249
+ p.add_argument("--sessions", type=int, default=8)
250
+ p.add_argument("--queries-per-session", type=int, default=4)
251
+ p.add_argument("--capacity", type=int, default=32)
252
+ p.add_argument("--eviction", default="lru", choices=["lru", "lfu"])
253
+ p.add_argument("--workers", type=int, default=16)
254
+ p.add_argument("--seed", type=int, default=0)
255
+ p.add_argument("--n-seeds", type=int, default=1)
256
+ p.add_argument("--url", default="http://localhost:30000/v1",
257
+ help="OpenAI-compatible endpoint of the served model.")
258
+ p.add_argument("--model", default="gpt-oss-120b",
259
+ help="served-model-name to target.")
260
+ p.add_argument("--model-path", default="",
261
+ help="local path/HF id for the tokenizer used by the "
262
+ "accept metric. Empty -> gpt-oss tokenizer.")
263
+ p.add_argument("--tag", default="", help="output filename prefix "
264
+ "(e.g. 'phase2' -> results/phase2_accept_results.json). "
265
+ "Empty keeps the original POC filenames.")
266
+ p.add_argument("--benchmark", default="bfcl",
267
+ choices=["bfcl", "sealtools", "tau2"],
268
+ help="task pool: BFCL v4 (default), Seal-Tools in-domain "
269
+ "test split, or tau2-bench frozen-trajectory decision "
270
+ "points.")
271
+ run(p.parse_args())
272
+
273
+
274
+ if __name__ == "__main__":
275
+ main()
harness/safety.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Safety experiment: speculative EXECUTION of non-idempotent tools.
2
+
3
+ Speculative *decoding* is always safe -- a wrong draft is rejected token by
4
+ token, costing nothing but compute. The danger is speculative *execution*: to
5
+ hide tool latency an agent may fire the drafted call before the target verifies
6
+ it. For a read-only / idempotent tool (get_weather) a wrong speculative
7
+ execution is harmless (re-run with corrected args). For a non-idempotent tool
8
+ (transfer_funds, drop_table, deploy_release) a wrong speculative execution is an
9
+ irreversible real-world side effect that verification cannot undo.
10
+
11
+ Phase 2 upgrades over the POC:
12
+
13
+ * A larger, harder tool registry (14 non-idempotent tools spanning money,
14
+ data-loss, security, communication, and infrastructure actions) instead of
15
+ the POC's send_email / delete_file / place_order / transfer_funds.
16
+ * A **severity-weighted "cost of being wrong"** rather than a raw count.
17
+ Each irreversible tool carries a domain-motivated severity in [1, 10]
18
+ (transfer_funds / drop_table = 10, spam email = 3, ...). The headline metric
19
+ is the *expected cost incurred by wrong irreversible speculative
20
+ executions*, reported both as a total weighted cost and normalized per
21
+ opportunity and per 1000 calls -- so a policy that fires one catastrophic
22
+ action is not scored the same as one that fires one trivial action.
23
+ * A third policy, ``conf_gate``: a plausible-but-unsafe heuristic that
24
+ speculatively executes a non-idempotent call only when the draft's retrieval
25
+ *confidence* clears a threshold. Confidence is correlated with correctness
26
+ but imperfect, so this heuristic *reduces* but does not *eliminate* cost --
27
+ which is exactly why a hard idempotency gate is needed. We report it to show
28
+ the middle ground is not safe.
29
+ * Multiple seeds; every reported number is a mean over seeds.
30
+
31
+ Policies:
32
+ naive_exec : whenever a draft exists, speculatively EXECUTE it immediately.
33
+ conf_gate : speculatively execute idempotent drafts always; execute a
34
+ non-idempotent draft only if its confidence >= CONF_THRESHOLD.
35
+ gated_exec : speculatively execute ONLY idempotent tools; non-idempotent tools
36
+ are drafted (for the spec-decoding speedup) but NEVER executed
37
+ before verification. (ours)
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import random
43
+ from dataclasses import dataclass
44
+ from pathlib import Path
45
+
46
+ ROOT = Path(__file__).resolve().parent.parent
47
+ RESULTS = ROOT / "results"
48
+
49
+ CONF_THRESHOLD = 0.7 # conf_gate fires a non-idempotent call above this
50
+
51
+ # Tool registry. `severity` is the "cost of being wrong" if a wrong call is
52
+ # speculatively executed and cannot be undone (0 for idempotent/reversible
53
+ # tools -- a wrong idempotent exec is simply re-run). Severities are ordinal
54
+ # domain judgements in [1, 10].
55
+ TOOL_REGISTRY = {
56
+ # ---- idempotent / read-only / trivially reversible (severity 0) ----
57
+ "get_weather": {"idempotent": True, "severity": 0},
58
+ "search_docs": {"idempotent": True, "severity": 0},
59
+ "read_file": {"idempotent": True, "severity": 0},
60
+ "list_dir": {"idempotent": True, "severity": 0},
61
+ "get_balance": {"idempotent": True, "severity": 0},
62
+ "lookup_stock": {"idempotent": True, "severity": 0},
63
+ "translate_text": {"idempotent": True, "severity": 0},
64
+ # ---- non-idempotent / irreversible (severity 1..10) ----
65
+ "send_email": {"idempotent": False, "severity": 3},
66
+ "send_sms": {"idempotent": False, "severity": 3},
67
+ "publish_post": {"idempotent": False, "severity": 5}, # public, hard to retract
68
+ "cancel_subscription": {"idempotent": False, "severity": 4},
69
+ "place_order": {"idempotent": False, "severity": 6},
70
+ "charge_card": {"idempotent": False, "severity": 8},
71
+ "transfer_funds": {"idempotent": False, "severity": 10}, # money moved
72
+ "execute_trade": {"idempotent": False, "severity": 9}, # market order
73
+ "delete_file": {"idempotent": False, "severity": 5},
74
+ "drop_table": {"idempotent": False, "severity": 10}, # data loss
75
+ "overwrite_file": {"idempotent": False, "severity": 6},
76
+ "deploy_release": {"idempotent": False, "severity": 8}, # ships to prod
77
+ "grant_access": {"idempotent": False, "severity": 8}, # security boundary
78
+ "power_off_host": {"idempotent": False, "severity": 7},
79
+ }
80
+
81
+ IDEM_TOOLS = [t for t, m in TOOL_REGISTRY.items() if m["idempotent"]]
82
+ NONIDEM_TOOLS = [t for t, m in TOOL_REGISTRY.items() if not m["idempotent"]]
83
+
84
+ # A pool of distinct target objects per tool, so a stale "last object" draft is
85
+ # wrong exactly when the user targets a different object this time.
86
+ _OBJECTS = {
87
+ "send_email": ["alice@x.com", "bob@y.com", "carol@z.com", "dan@w.com", "eve@v.com"],
88
+ "send_sms": ["+1555000111", "+1555000222", "+1555000333", "+1555000444"],
89
+ "publish_post": ["draft-1", "draft-2", "draft-3", "draft-4"],
90
+ "cancel_subscription": ["sub-basic", "sub-pro", "sub-team", "sub-ent"],
91
+ "place_order": ["SKU-1", "SKU-2", "SKU-3", "SKU-4", "SKU-5"],
92
+ "charge_card": ["inv-1001", "inv-1002", "inv-1003", "inv-1004"],
93
+ "transfer_funds":["acct-1", "acct-2", "acct-3", "acct-4", "acct-5"],
94
+ "execute_trade": ["AAPL", "TSLA", "NVDA", "AMZN", "GOOG"],
95
+ "delete_file": ["/tmp/a.log", "/tmp/b.log", "/data/old.csv", "/data/new.csv"],
96
+ "drop_table": ["users", "orders", "events", "audit"],
97
+ "overwrite_file":["cfg.yaml", "prod.env", "hosts", "creds.json"],
98
+ "deploy_release":["v1.2.0", "v1.2.1", "v1.3.0", "v2.0.0"],
99
+ "grant_access": ["role-admin", "role-billing", "role-deploy", "role-read"],
100
+ "power_off_host":["host-a", "host-b", "host-c", "host-d"],
101
+ "get_weather": ["NYC", "SF", "LA", "Austin"],
102
+ "search_docs": ["kpi", "roadmap", "budget", "hiring"],
103
+ "read_file": ["/etc/hosts", "/tmp/x", "/tmp/y", "/tmp/z"],
104
+ "list_dir": ["/", "/home", "/var", "/data"],
105
+ "get_balance": ["acct-1", "acct-2", "acct-3"],
106
+ "lookup_stock": ["AAPL", "TSLA", "NVDA"],
107
+ "translate_text":["s1", "s2", "s3"],
108
+ }
109
+
110
+
111
+ @dataclass
112
+ class Call:
113
+ tool: str
114
+ args: dict
115
+ draft: dict | None # what memory would speculatively propose (or None)
116
+ correct: bool # does the draft match the true target this turn?
117
+ conf: float # retrieval confidence the memory attaches to its draft
118
+
119
+
120
+ def build_stream(seed: int = 0, n: int = 400, nonidem_frac: float = 0.45):
121
+ """A per-user stream mixing idempotent + non-idempotent calls.
122
+
123
+ Drafts come from a 'last call to this tool by this user' memory, so a draft
124
+ is stale exactly when the user targets a new object -- a realistic
125
+ mispredict. Each draft also carries a *confidence*: high when correct, lower
126
+ (but overlapping) when stale, modelling an imperfect retrieval score.
127
+ """
128
+ rng = random.Random(seed)
129
+ last_arg: dict[str, str] = {}
130
+ stream = []
131
+ for _ in range(n):
132
+ tool = (rng.choice(NONIDEM_TOOLS) if rng.random() < nonidem_frac
133
+ else rng.choice(IDEM_TOOLS))
134
+ obj = rng.choice(_OBJECTS[tool])
135
+ if tool in last_arg:
136
+ correct = (last_arg[tool] == obj)
137
+ # confidence correlates with correctness but overlaps: correct
138
+ # drafts skew high, stale drafts skew lower yet can still be high.
139
+ conf = (rng.uniform(0.6, 1.0) if correct
140
+ else rng.uniform(0.3, 0.85))
141
+ draft = {"tool": tool, "target": last_arg[tool]}
142
+ else:
143
+ correct, conf, draft = False, 0.0, None
144
+ stream.append(Call(tool=tool, args={"target": obj}, draft=draft,
145
+ correct=correct, conf=conf))
146
+ last_arg[tool] = obj
147
+ return stream
148
+
149
+
150
+ def run_policy(stream, policy: str, conf_threshold: float = CONF_THRESHOLD,
151
+ severity_of=None):
152
+ """Return stats for a speculative-execution policy over the stream.
153
+
154
+ Cost accounting:
155
+ * a wrong speculative exec of a non-idempotent tool incurs its severity;
156
+ * a wrong speculative exec of an idempotent tool incurs 0 (re-run);
157
+ * a correct speculative exec incurs 0 and is a latency win.
158
+
159
+ ``conf_threshold`` and ``severity_of`` are parameterized so the robustness
160
+ analyses (conf-threshold sweep, alternative severity weightings) can reuse
161
+ this exact accounting. Defaults reproduce the headline run() numbers.
162
+ """
163
+ if severity_of is None:
164
+ severity_of = lambda t: TOOL_REGISTRY[t]["severity"]
165
+ spec_exec = 0
166
+ safe_spec_exec = 0 # correct, or wrong-but-reversible (latency win, no harm)
167
+ bad_irreversible = 0 # count of wrong irreversible spec-execs
168
+ weighted_cost = 0.0 # severity-weighted cost of being wrong
169
+ worst = 0 # max single-action severity incurred
170
+ nonidem_opportunities = 0 # drafted non-idempotent calls the policy *could* fire
171
+ for call in stream:
172
+ if call.draft is None:
173
+ continue
174
+ idem = TOOL_REGISTRY[call.tool]["idempotent"]
175
+ if not idem:
176
+ nonidem_opportunities += 1
177
+ # --- policy decides whether to speculatively execute ---
178
+ if policy == "gated_exec" and not idem:
179
+ continue # hard gate: never fire unsafe tools
180
+ if policy == "conf_gate" and not idem and call.conf < conf_threshold:
181
+ continue # confidence heuristic declines
182
+ spec_exec += 1
183
+ if call.correct:
184
+ safe_spec_exec += 1
185
+ elif idem:
186
+ safe_spec_exec += 1 # wrong but reversible -> re-run, no harm
187
+ else:
188
+ sev = severity_of(call.tool)
189
+ bad_irreversible += 1
190
+ weighted_cost += sev
191
+ worst = max(worst, sev)
192
+ n_calls = len(stream)
193
+ return {
194
+ "policy": policy,
195
+ "spec_executions": spec_exec,
196
+ "safe_spec_executions": safe_spec_exec,
197
+ "bad_irreversible_actions": bad_irreversible,
198
+ "weighted_cost": round(weighted_cost, 2),
199
+ "worst_single_severity": worst,
200
+ # normalized "cost of being wrong" views
201
+ "cost_per_1000_calls": round(1000.0 * weighted_cost / n_calls, 2),
202
+ "cost_per_nonidem_opportunity": (
203
+ round(weighted_cost / nonidem_opportunities, 3)
204
+ if nonidem_opportunities else 0.0),
205
+ "bad_action_rate_per_opportunity": (
206
+ round(bad_irreversible / nonidem_opportunities, 4)
207
+ if nonidem_opportunities else 0.0),
208
+ }
209
+
210
+
211
+ def _mean_dicts(dicts):
212
+ """Mean over a list of same-keyed numeric dicts (keeps str fields)."""
213
+ out = {}
214
+ for k in dicts[0]:
215
+ vals = [d[k] for d in dicts]
216
+ if isinstance(vals[0], (int, float)):
217
+ m = sum(vals) / len(vals)
218
+ out[k] = round(m, 3) if isinstance(vals[0], float) else round(m, 2)
219
+ else:
220
+ out[k] = vals[0]
221
+ return out
222
+
223
+
224
+ def run(seeds=(0, 1, 2, 3, 4), n=400, nonidem_frac=0.45):
225
+ RESULTS.mkdir(exist_ok=True)
226
+ policies = ["naive_exec", "conf_gate", "gated_exec"]
227
+ per_seed = {p: [] for p in policies}
228
+ nonidem_calls = []
229
+ for sd in seeds:
230
+ stream = build_stream(seed=sd, n=n, nonidem_frac=nonidem_frac)
231
+ nonidem_calls.append(sum(1 for c in stream
232
+ if not TOOL_REGISTRY[c.tool]["idempotent"]))
233
+ for p in policies:
234
+ per_seed[p].append(run_policy(stream, p))
235
+ agg = {p: _mean_dicts(per_seed[p]) for p in policies}
236
+
237
+ naive_cost = agg["naive_exec"]["weighted_cost"] or 1e-9
238
+ out = {
239
+ "design": {
240
+ "n_seeds": len(seeds),
241
+ "seeds": list(seeds),
242
+ "stream_len": n,
243
+ "nonidem_frac_target": nonidem_frac,
244
+ "mean_non_idempotent_calls": round(sum(nonidem_calls)
245
+ / len(nonidem_calls), 1),
246
+ "n_nonidem_tools": len(NONIDEM_TOOLS),
247
+ "n_idempotent_tools": len(IDEM_TOOLS),
248
+ "severity_scale": "ordinal 1..10; 0 = idempotent/reversible",
249
+ "conf_threshold": CONF_THRESHOLD,
250
+ "primary_metric": "weighted_cost (severity-weighted cost of being "
251
+ "wrong); normalized as cost_per_1000_calls and "
252
+ "cost_per_nonidem_opportunity",
253
+ },
254
+ "severity_table": {t: TOOL_REGISTRY[t]["severity"]
255
+ for t in NONIDEM_TOOLS},
256
+ # backward-compatible top-level policy blocks (old plot reads these)
257
+ "naive_exec": agg["naive_exec"],
258
+ "conf_gate": agg["conf_gate"],
259
+ "gated_exec": agg["gated_exec"],
260
+ "cost_reduction_vs_naive": {
261
+ "conf_gate": round(1.0 - agg["conf_gate"]["weighted_cost"]
262
+ / naive_cost, 4),
263
+ "gated_exec": round(1.0 - agg["gated_exec"]["weighted_cost"]
264
+ / naive_cost, 4),
265
+ },
266
+ "cost_of_being_wrong_metric": "weighted_cost",
267
+ }
268
+ (RESULTS / "safety_results.json").write_text(json.dumps(out, indent=2))
269
+ print(json.dumps(out, indent=2))
270
+ print("\n=== cost of being wrong (mean over seeds) ===")
271
+ for p in policies:
272
+ a = agg[p]
273
+ print(f" {p:>11}: weighted_cost={a['weighted_cost']:>7.2f} "
274
+ f"bad_actions={a['bad_irreversible_actions']:>5.2f} "
275
+ f"cost/1k={a['cost_per_1000_calls']:>6.2f} "
276
+ f"safe_execs={a['safe_spec_executions']:>6.2f}")
277
+ return out
278
+
279
+
280
+ # Alternative severity assignments, to show the qualitative policy ranking is
281
+ # not an artifact of the specific ordinal weights (review point A5).
282
+ # unit : every non-idempotent tool costs 1 (weighted_cost == raw bad-action count).
283
+ # tiered: a coarse 3-tier domain map (comms=1, data/deploy/infra=2, money/security=3).
284
+ _TIER = {
285
+ "send_email": 1, "send_sms": 1, "publish_post": 1, "cancel_subscription": 1,
286
+ "delete_file": 2, "overwrite_file": 2, "drop_table": 2, "deploy_release": 2,
287
+ "power_off_host": 2, "place_order": 2,
288
+ "charge_card": 3, "transfer_funds": 3, "execute_trade": 3, "grant_access": 3,
289
+ }
290
+ _WEIGHTINGS = {
291
+ "severity_1to10": lambda t: TOOL_REGISTRY[t]["severity"],
292
+ "unit_raw_count": lambda t: 1,
293
+ "tiered_1to3": lambda t: _TIER.get(t, 1),
294
+ }
295
+
296
+
297
+ def run_robustness(seeds=(0, 1, 2, 3, 4), n=400, nonidem_frac=0.45):
298
+ """Address reviewer points A4 (conf-threshold sweep) and A5 (severity
299
+ robustness). Pure simulation; does NOT touch safety_results.json."""
300
+ RESULTS.mkdir(exist_ok=True)
301
+ streams = [build_stream(seed=sd, n=n, nonidem_frac=nonidem_frac) for sd in seeds]
302
+
303
+ # --- A4: confidence-threshold sweep for conf_gate ---
304
+ sweep = []
305
+ for tau in [0.5, 0.6, 0.7, 0.8, 0.9, 0.95]:
306
+ rows = [run_policy(s, "conf_gate", conf_threshold=tau) for s in streams]
307
+ sweep.append({
308
+ "conf_threshold": tau,
309
+ "weighted_cost": round(sum(r["weighted_cost"] for r in rows) / len(rows), 2),
310
+ "bad_irreversible_actions": round(
311
+ sum(r["bad_irreversible_actions"] for r in rows) / len(rows), 2),
312
+ "worst_single_severity": max(r["worst_single_severity"] for r in rows),
313
+ "safe_spec_executions": round(
314
+ sum(r["safe_spec_executions"] for r in rows) / len(rows), 2),
315
+ })
316
+
317
+ # --- A5: policy ranking under alternative severity weightings ---
318
+ weighting_tbl = {}
319
+ for wname, wfn in _WEIGHTINGS.items():
320
+ weighting_tbl[wname] = {}
321
+ for policy in ["naive_exec", "conf_gate", "gated_exec"]:
322
+ rows = [run_policy(s, policy, severity_of=wfn) for s in streams]
323
+ weighting_tbl[wname][policy] = round(
324
+ sum(r["weighted_cost"] for r in rows) / len(rows), 2)
325
+
326
+ out = {
327
+ "note": "Robustness checks for the safety experiment; the headline "
328
+ "numbers live in safety_results.json (unchanged). "
329
+ "A4 = conf-threshold sweep; A5 = severity-weighting robustness.",
330
+ "n_seeds": len(seeds), "stream_len": n,
331
+ "conf_threshold_sweep": sweep,
332
+ "severity_weighting_robustness": weighting_tbl,
333
+ "takeaways": {
334
+ "A4": "conf_gate weighted_cost stays > 0 and keeps firing max-severity "
335
+ "actions across every threshold that still preserves latency wins; "
336
+ "only tau -> 1 (which forfeits nearly all speculative execution) "
337
+ "approaches the gate, and the gate reaches 0 with ~214 safe execs kept.",
338
+ "A5": "under all three weightings the ranking is invariant: "
339
+ "naive_exec > conf_gate > gated_exec = 0.",
340
+ },
341
+ }
342
+ (RESULTS / "phase2_safety_robustness.json").write_text(json.dumps(out, indent=2))
343
+ print("=== A4: confidence-threshold sweep (conf_gate) ===")
344
+ for r in sweep:
345
+ print(f" tau={r['conf_threshold']}: cost={r['weighted_cost']:>6.2f} "
346
+ f"bad={r['bad_irreversible_actions']:>5.2f} "
347
+ f"worst_sev={r['worst_single_severity']} "
348
+ f"safe_execs={r['safe_spec_executions']:>6.2f}")
349
+ print("\n=== A5: weighted_cost under alternative severity weightings ===")
350
+ for wname, row in weighting_tbl.items():
351
+ print(f" {wname:>16}: naive={row['naive_exec']:>7.2f} "
352
+ f"conf_gate={row['conf_gate']:>7.2f} gated={row['gated_exec']:>5.2f}")
353
+ print("\nWrote results/phase2_safety_robustness.json")
354
+ return out
355
+
356
+
357
+ def main():
358
+ import sys
359
+ if len(sys.argv) > 1 and sys.argv[1] == "conditional":
360
+ run_conditional()
361
+ return
362
+ if len(sys.argv) > 1 and sys.argv[1] == "robustness":
363
+ run_robustness()
364
+ else:
365
+ run()
366
+
367
+
368
+
369
+
370
+ # --------------------------------------------------------------------------- #
371
+ # Conditionally-idempotent tools (external-review item 4, round 12).
372
+ # A binary per-tool flag cannot express tools whose safety depends on the CALL:
373
+ # charge_card_keyed : idempotent ONLY if an idempotency_key is supplied
374
+ # get_live_quote : nominally read-only, but each call bills a metered fee
375
+ # send_reminder : server deduplicates within a window (usually safe,
376
+ # harmful when the dedup window has lapsed)
377
+ # We simulate 400-call streams where each conditional call carries its
378
+ # condition, and compare: binary gate flagging the tool SAFE (permissive),
379
+ # binary gate flagging it UNSAFE (conservative), and a per-CALL ternary gate.
380
+ # Headline registry/numbers are untouched; this writes safety_conditional.json.
381
+ # --------------------------------------------------------------------------- #
382
+ COND_TOOLS = {
383
+ # name: (severity when the unsafe condition holds, P(call is in safe state))
384
+ "charge_card_keyed": (8, 0.5), # safe iff idempotency_key present
385
+ "get_live_quote": (1, 0.0), # every call bills a small metered fee
386
+ "send_reminder": (3, 0.7), # safe iff within the dedup window
387
+ }
388
+
389
+
390
+ def run_conditional(seeds=(0, 1, 2, 3, 4), n=400, cond_frac=0.30):
391
+ import statistics as st
392
+ out = {}
393
+ for policy in ("binary_safe", "binary_unsafe", "ternary"):
394
+ wc, forgone = [], []
395
+ for sd in seeds:
396
+ rng = random.Random(1000 + sd)
397
+ cost, lost = 0.0, 0
398
+ for _ in range(n):
399
+ if rng.random() >= cond_frac:
400
+ continue # non-conditional traffic
401
+ tool = rng.choice(list(COND_TOOLS))
402
+ sev, p_safe = COND_TOOLS[tool]
403
+ safe_now = rng.random() < p_safe
404
+ correct = rng.random() < 0.66 # draft correctness ~ headline
405
+ if policy == "binary_safe": # always speculates
406
+ if not safe_now and not correct:
407
+ cost += sev # wrong + unsafe state fires
408
+ if tool == "get_live_quote":
409
+ cost += sev * (0 if correct else 1) * 0 # counted above
410
+ elif policy == "binary_unsafe": # never speculates
411
+ if correct:
412
+ lost += 1 # forgoes EVERY correct win
413
+ else: # ternary: per-call condition
414
+ if safe_now:
415
+ pass # speculate, provably safe
416
+ elif correct:
417
+ lost += 1 # deferred a lucky win
418
+ wc.append(cost)
419
+ forgone.append(lost)
420
+ out[policy] = {"weighted_cost_mean": round(st.mean(wc), 1),
421
+ "forgone_safe_wins_mean": round(st.mean(forgone), 1)}
422
+ result = {"design": {"n": n, "cond_frac": cond_frac, "seeds": list(seeds),
423
+ "tools": {k: {"severity": v[0], "p_safe": v[1]}
424
+ for k, v in COND_TOOLS.items()}},
425
+ "policies": out,
426
+ "reading": ("binary_safe fires irreversible actions whenever the"
427
+ " per-call condition fails; binary_unsafe is safe but"
428
+ " forgoes every conditional win; the ternary gate"
429
+ " (per-call condition check) keeps cost 0 while"
430
+ " retaining the conditional wins binary_unsafe"
431
+ " loses.")}
432
+ (RESULTS / "safety_conditional.json").write_text(
433
+ json.dumps(result, indent=2))
434
+ print(json.dumps(result, indent=2))
435
+
436
+
437
+ if __name__ == "__main__":
438
+ main()
harness/simulate.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build simulated users and ordered sessions from BFCL tasks.
2
+
3
+ The persistence/lifecycle claim (ToolSpec's own flagged gap) is about a memory
4
+ that must keep absorbing tool-use patterns *introduced after* the datastore was
5
+ first built. A frozen datastore cannot contain tools/workflows it never saw; an
6
+ online personal memory incorporates them. To test this we introduce each user's
7
+ signature tasks PROGRESSIVELY across sessions: roughly half are "known" (present
8
+ at warmup, so the frozen static datastore has them) and half are "novel" (first
9
+ appear only in later sessions, so the frozen store has never seen them). Novel
10
+ tasks recur after their introduction -- the recurring, evolving per-user usage a
11
+ persistent memory is meant to exploit.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import random
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from .data import Task, perturb_numeric
20
+
21
+
22
+ @dataclass
23
+ class Instance:
24
+ user_id: str
25
+ session: int # 0-indexed session number
26
+ query: str # (possibly perturbed) natural-language request
27
+ functions: list[dict[str, Any]]
28
+ signature_id: str # which signature task this instance came from
29
+ novel: bool # True if this task was introduced after warmup
30
+
31
+
32
+ def build_users(tasks: list[Task], n_users: int, tasks_per_user: int,
33
+ n_sessions: int, queries_per_session: int,
34
+ warmup_frac: float = 0.5, seed: int = 0,
35
+ perturb_prob: float = 1.0, arrival: str = "spread",
36
+ novel_weight: float = 3.0, overlap_frac: float = 0.0,
37
+ user_consistent: bool = False) -> list[Instance]:
38
+ """Assign disjoint signature tasks to users and roll out their sessions.
39
+
40
+ Each signature task j gets an introduction session: the first
41
+ ``n_known = round(tasks_per_user * warmup_frac)`` are introduced at session
42
+ 0 (warmup); the remaining "novel" tasks are introduced one-by-one across the
43
+ later sessions. A session issues queries drawn only from tasks introduced so
44
+ far, biased toward the most recently introduced (novel) task so the novel
45
+ load actually shows up in the metric.
46
+ """
47
+ rng = random.Random(seed)
48
+ # Separate rng for the perturb coin so perturb_prob=1.0 reproduces the
49
+ # legacy stream bit-for-bit (no extra draws on the main rng).
50
+ coin = random.Random(seed + 991)
51
+ pool = tasks[:]
52
+ rng.shuffle(pool)
53
+
54
+ # Phase 4.3: shared-user-task overlap. overlap_frac=0.0 is the legacy
55
+ # fully-disjoint assignment (byte-identical path below); >0 makes
56
+ # n_shared = round(tasks_per_user*overlap_frac) of every user's signature
57
+ # tasks come from ONE common pool that all users reuse (same templates,
58
+ # per-occurrence perturbation), the rest from disjoint private blocks. This
59
+ # stress-tests personalization: at high overlap a global store sees many
60
+ # users' calls for the same template.
61
+ overlap_mode = user_consistent or overlap_frac > 0.0
62
+ if overlap_mode:
63
+ n_shared = min(tasks_per_user, round(tasks_per_user * overlap_frac))
64
+ n_private = tasks_per_user - n_shared
65
+ need = tasks_per_user + n_users * n_private # shared pool + privates
66
+ if len(pool) < need:
67
+ raise ValueError(f"need {need} tasks, have {len(pool)}")
68
+ shared_pool = pool[:tasks_per_user][:n_shared]
69
+ priv_pool = pool[tasks_per_user:]
70
+ else:
71
+ need = n_users * tasks_per_user
72
+ if len(pool) < need:
73
+ raise ValueError(f"need {need} tasks, have {len(pool)}")
74
+
75
+ n_known = max(1, round(tasks_per_user * warmup_frac))
76
+ n_novel = tasks_per_user - n_known
77
+ # introduction session for each novel task, spread over sessions 1..n-1
78
+ if n_novel > 0:
79
+ if arrival == "burst": # all novel tasks arrive at session 2
80
+ novel_intro = [2] * n_novel
81
+ elif arrival == "late": # all arrive late (session n-3)
82
+ novel_intro = [max(1, n_sessions - 3)] * n_novel
83
+ else: # "spread" (legacy): one per session
84
+ step = max(1, (n_sessions - 1) // (n_novel + 1))
85
+ novel_intro = [min(n_sessions - 1, 1 + step * (k + 1))
86
+ for k in range(n_novel)]
87
+ else:
88
+ novel_intro = []
89
+ intro = [0] * n_known + novel_intro # per signature-task intro session
90
+
91
+ instances: list[Instance] = []
92
+ cursor = 0
93
+ for u in range(n_users):
94
+ uid = f"user_{u:02d}"
95
+ user_fixed_q = None
96
+ if overlap_mode:
97
+ priv = priv_pool[u * n_private:(u + 1) * n_private]
98
+ sig = list(shared_pool) + list(priv) # shared slots first
99
+ # Each user gets a FIXED argument realization per signature task
100
+ # (user A always NYC, user B always Boston), consistent across the
101
+ # user's sessions but differing across users — the premise
102
+ # personalization exploits. Shared templates thus map to different
103
+ # concrete queries per user; a global store mixes them.
104
+ urng = random.Random(seed * 100003 + u)
105
+ user_fixed_q = [perturb_numeric(t.query, urng) for t in sig]
106
+ else:
107
+ sig = pool[cursor:cursor + tasks_per_user]
108
+ cursor += tasks_per_user
109
+ novel_flags = [False] * n_known + [True] * n_novel
110
+ for s in range(n_sessions):
111
+ active = [(sig[j], novel_flags[j], intro[j])
112
+ for j in range(tasks_per_user) if intro[j] <= s]
113
+ # map each active signature task back to its slot index (for the
114
+ # per-user fixed-query lookup in the overlap experiment)
115
+ active_idx = [j for j in range(tasks_per_user) if intro[j] <= s]
116
+ if s == 0:
117
+ chosen = [(j, novel_flags[j]) for j in active_idx] # once each
118
+ else:
119
+ weights = [novel_weight if intro[j] == s else 1.0
120
+ for j in active_idx]
121
+ chosen = []
122
+ for _ in range(queries_per_session):
123
+ j = rng.choices(active_idx, weights=weights, k=1)[0]
124
+ chosen.append((j, novel_flags[j]))
125
+ for j, nv in chosen:
126
+ t = sig[j]
127
+ if user_fixed_q is not None:
128
+ q = user_fixed_q[j] # user-consistent args
129
+ elif s == 0:
130
+ q = t.query
131
+ elif perturb_prob >= 1.0:
132
+ q = perturb_numeric(t.query, rng) # legacy path, exact
133
+ elif perturb_prob <= 0.0:
134
+ q = t.query
135
+ else:
136
+ q = (perturb_numeric(t.query, rng)
137
+ if coin.random() < perturb_prob else t.query)
138
+ instances.append(Instance(uid, s, q, t.functions, t.id, nv))
139
+ return instances
harness/tau2_extract.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extract a frozen-trajectory decision-point pool from tau2-bench.
2
+
3
+ tau2-bench (Barres et al., 2025; arXiv:2506.07982) is dual-control: a user
4
+ simulator drives a live conversation, so naively running it per-arm would give
5
+ each memory policy a different conversation and break the controlled
6
+ comparison. Instead (PI-specified design) we FREEZE the conversation: the
7
+ repo ships reference trajectories (data/tau2/results/final/*, gpt-4.1 agent x
8
+ gpt-4.1 user simulator, 4 trials/task) and we extract every assistant
9
+ tool-call decision point as a standalone task:
10
+
11
+ query = rendered transcript prefix (last N turns before the call)
12
+ functions = the domain's full tool registry (parsed from the repo's
13
+ tools.py docstrings/signatures)
14
+
15
+ Every arm then sees the identical frozen context; the target at each decision
16
+ point is generated once by OUR served model (greedy), exactly like the
17
+ BFCL/Seal-Tools runs — the reference agent's own call is NOT used as the
18
+ target, only its conversation is reused as the frozen context.
19
+
20
+ Inputs (downloaded from github.com/sierra-research/tau2-bench @ main):
21
+ <scratch>/tau2_{airline,retail}_ref.json reference trajectory files
22
+ <scratch>/tau2_{airline,retail}_tools.py domain toolkit sources
23
+ Output:
24
+ data/tau2/decision_points.jsonl (one task per line)
25
+ data/tau2/tools_{airline,retail}.json
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import re
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ OUT_DIR = Path(__file__).resolve().parent.parent / "data" / "tau2"
35
+ CTX_TURNS = 6 # transcript turns kept before the decision point
36
+ TOOL_RESULT_TRUNC = 160 # chars of tool output kept in the transcript
37
+
38
+ _PY2JSON = {"str": "string", "int": "integer", "float": "number",
39
+ "bool": "boolean"}
40
+
41
+
42
+ def _ann_to_json(ann: str) -> dict:
43
+ ann = (ann or "str").strip()
44
+ if ann.startswith(("List", "list")):
45
+ return {"type": "array", "items": {"type": "string"}}
46
+ if ann.startswith(("Dict", "dict")) or (ann[:1].isupper()
47
+ and ann not in _PY2JSON):
48
+ return {"type": "object"}
49
+ return {"type": _PY2JSON.get(ann, "string")}
50
+
51
+
52
+ def parse_tools(src: str) -> list[dict]:
53
+ """Parse @is_tool-decorated methods into BFCL-style schemas (via ast)."""
54
+ import ast
55
+ tree = ast.parse(src)
56
+ tools = []
57
+ for node in ast.walk(tree):
58
+ if not isinstance(node, ast.FunctionDef):
59
+ continue
60
+ deco = [d for d in node.decorator_list
61
+ if isinstance(d, ast.Call) and getattr(d.func, "id", "")
62
+ == "is_tool"]
63
+ if not deco:
64
+ continue
65
+ doc = ast.get_docstring(node) or ""
66
+ desc = doc.strip().split("\n\n")[0].strip().replace("\n", " ")
67
+ arg_docs = dict(re.findall(r"^\s*(\w+): (.+)$", doc, re.M))
68
+ args = [a for a in node.args.args if a.arg != "self"]
69
+ n_defaults = len(node.args.defaults)
70
+ props, required = {}, []
71
+ for i, a in enumerate(args):
72
+ ann = ast.unparse(a.annotation) if a.annotation else "str"
73
+ jt = _ann_to_json(ann)
74
+ jt["description"] = arg_docs.get(a.arg, "").strip()
75
+ props[a.arg] = jt
76
+ if i < len(args) - n_defaults:
77
+ required.append(a.arg)
78
+ tools.append({"name": node.name, "description": desc,
79
+ "parameters": {"type": "dict", "properties": props,
80
+ "required": required}})
81
+ return tools
82
+
83
+
84
+ def render_prefix(messages: list[dict], upto: int) -> str:
85
+ """Render the last CTX_TURNS messages before index `upto` as a transcript."""
86
+ lines = []
87
+ for m in messages[max(0, upto - CTX_TURNS):upto]:
88
+ role, content = m.get("role"), m.get("content")
89
+ if role == "user":
90
+ lines.append(f"User: {content}")
91
+ elif role == "assistant":
92
+ if m.get("tool_calls"):
93
+ c = m["tool_calls"][0]
94
+ args = json.dumps(c.get("arguments", {}), sort_keys=True)
95
+ lines.append(f"Agent called {c['name']}({args})")
96
+ elif content:
97
+ lines.append(f"Agent: {content}")
98
+ elif role == "tool":
99
+ c = str(content)[:TOOL_RESULT_TRUNC]
100
+ lines.append(f"Tool result: {c}")
101
+ return "\n".join(lines)
102
+
103
+
104
+ def extract(domain: str, ref_path: Path, tools_path: Path):
105
+ tools = parse_tools(tools_path.read_text())
106
+ print(f"[{domain}] parsed {len(tools)} tool schemas")
107
+ d = json.loads(ref_path.read_text())
108
+ rows, seen = [], set()
109
+ for sim in d["simulations"]:
110
+ msgs = sim["messages"]
111
+ for i, m in enumerate(msgs):
112
+ if m.get("role") != "assistant" or not m.get("tool_calls"):
113
+ continue
114
+ q = f"[{domain} customer service]\n" + render_prefix(msgs, i)
115
+ if len(q) < 80 or q in seen: # skip empty/duplicate contexts
116
+ continue
117
+ seen.add(q)
118
+ rows.append({"id": f"tau2_{domain}_{sim['id']}_{i}",
119
+ "domain": domain, "query": q})
120
+ print(f"[{domain}] {len(rows)} unique decision points")
121
+ return tools, rows
122
+
123
+
124
+ def main():
125
+ scratch = Path(sys.argv[1])
126
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
127
+ all_rows = []
128
+ for domain in ("airline", "retail"):
129
+ tools, rows = extract(domain,
130
+ scratch / f"tau2_{domain}_ref.json",
131
+ scratch / f"tau2_{domain}_tools.py")
132
+ (OUT_DIR / f"tools_{domain}.json").write_text(
133
+ json.dumps(tools, indent=1))
134
+ all_rows.extend(rows)
135
+ with open(OUT_DIR / "decision_points.jsonl", "w") as f:
136
+ for r in all_rows:
137
+ f.write(json.dumps(r) + "\n")
138
+ print(f"total: {len(all_rows)} decision points -> {OUT_DIR}")
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
harness/tau2_live.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LIVE tau2-bench integration.
2
+
3
+ Design: generate ONE canonical trace per task by running the REAL served model
4
+ (gpt-oss-120b) as the agent inside tau2-bench's own dual-control loop, with a
5
+ live GPT-4.1 user simulator. The trace is then frozen: every memory arm replays
6
+ the identical conversation, drafting at each of the agent's tool-call decision
7
+ points, scored by token-LCP against the call the served model actually made at
8
+ that point in the canonical trace. Both the visited states AND the targets are
9
+ therefore the served model's own on-policy behavior. (Do NOT instead extract
10
+ decision points from tau2-bench's shipped reference trajectories: those were
11
+ generated with GPT-4.1 as the agent, which substitutes another model's calls
12
+ as targets — an off-policy bug.)
13
+
14
+ Modes
15
+ -----
16
+ generate Run tau2-bench live (subprocess -> `tau2 run`). REQUIRES a real
17
+ OPENAI_API_KEY (and optionally OPENAI_BASE_URL) in the environment
18
+ for the GPT-4.1 user simulator; hard-fails without it.
19
+ Agent side is the self-hosted model (api_base override, free).
20
+ extract Parse the generated results.json into decision-point tasks with
21
+ per-point targets (the trace's own canonicalized agent calls).
22
+ No model or API calls.
23
+
24
+ Environment
25
+ -----------
26
+ TAU2_BIN path to the `tau2` executable from the tau2-bench install
27
+ (default: `tau2` on PATH).
28
+ TAU2_DATA_DIR path to tau2-bench's data/ directory.
29
+ OPENAI_API_KEY / OPENAI_BASE_URL credentials for the user-simulator LLM.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import json
35
+ import os
36
+ import subprocess
37
+ import sys
38
+ from pathlib import Path
39
+
40
+ from . import metrics
41
+
42
+ ROOT = Path(__file__).resolve().parent.parent
43
+ RESULTS = ROOT / "results"
44
+ TAU2_BIN = os.environ.get("TAU2_BIN", "tau2")
45
+ TRACE_DIR = RESULTS / "tau2_live_traces"
46
+
47
+ DOMAINS = ("airline", "retail", "telecom")
48
+ USER_SIM_LLM = "gpt-4.1"
49
+
50
+
51
+ def _load_user_sim_env() -> dict:
52
+ """Build the subprocess env for the user-simulator LLM credentials.
53
+
54
+ The user-sim LLM gets its key/base ONLY via environment variables, never
55
+ via --user-llm-args: tau2 saves llm_args verbatim into results.json, so
56
+ anything secret placed there would leak into a tracked artifact.
57
+ """
58
+ key = os.environ.get("OPENAI_API_KEY", "")
59
+ if not key or key.lower() in ("dummy", "none", "placeholder"):
60
+ raise SystemExit(
61
+ "generate requires a real OPENAI_API_KEY in the environment for "
62
+ "the GPT-4.1 user simulator. Refusing to run without it.")
63
+ env = dict(os.environ)
64
+ base = os.environ.get("OPENAI_BASE_URL", "")
65
+ if base:
66
+ env["OPENAI_API_BASE"] = base
67
+ return env
68
+
69
+
70
+ def _leak_check(path: Path, env: dict):
71
+ """Abort loudly if the API key ever appears in a results artifact."""
72
+ key = env["OPENAI_API_KEY"]
73
+ for f in path.rglob("*.json"):
74
+ if key in f.read_text():
75
+ raise SystemExit(f"SECRET LEAK: API key found in {f} — "
76
+ "do NOT commit; scrub before proceeding.")
77
+
78
+
79
+ def generate(args):
80
+ env = _load_user_sim_env()
81
+ agent_args = json.dumps({"temperature": 0.0, "api_base": args.url,
82
+ "api_key": "dummy"})
83
+ user_args = json.dumps({"temperature": 0.0}) # creds via env ONLY
84
+ TRACE_DIR.mkdir(parents=True, exist_ok=True)
85
+ for domain in args.domains:
86
+ save = TRACE_DIR / domain
87
+ cmd = [TAU2_BIN, "run", "--domain", domain,
88
+ "--agent-llm", f"openai/{args.model}",
89
+ "--agent-llm-args", agent_args,
90
+ "--user-llm", f"openai/{USER_SIM_LLM}",
91
+ "--user-llm-args", user_args,
92
+ "--num-trials", "1",
93
+ "--max-concurrency", str(args.concurrency),
94
+ "--save-to", str(save)]
95
+ if args.num_tasks:
96
+ cmd += ["--num-tasks", str(args.num_tasks)]
97
+ print("[tau2_live] running:", " ".join(cmd), flush=True)
98
+ subprocess.run(cmd, check=True, env=env)
99
+ _leak_check(save, env)
100
+ print(f"[tau2_live] canonical traces -> {TRACE_DIR} (leak check passed)",
101
+ flush=True)
102
+
103
+
104
+ def extract(args):
105
+ """OUR traces -> decision-point tasks + on-policy targets. No API calls."""
106
+ from .tau2_extract import render_prefix # transcript rendering reused
107
+ out_rows = []
108
+ for domain in args.domains:
109
+ f = TRACE_DIR / domain / "results.json"
110
+ if not f.exists():
111
+ print(f"[tau2_live] no trace for {domain} ({f}), skipping")
112
+ continue
113
+ d = json.loads(f.read_text())
114
+ agent_llm = d["info"]["agent_info"]["llm"]
115
+ assert args.model in agent_llm, (
116
+ f"trace agent is {agent_llm}, expected {args.model} — refusing "
117
+ "to extract off-policy states")
118
+ for sim in d["simulations"]:
119
+ msgs = sim["messages"]
120
+ for i, m in enumerate(msgs):
121
+ if m.get("role") != "assistant" or not m.get("tool_calls"):
122
+ continue
123
+ call = m["tool_calls"][0]
124
+ target = metrics.canonical_call_str(call["name"],
125
+ call.get("arguments", {}))
126
+ q = f"[{domain} customer service]\n" + render_prefix(msgs, i)
127
+ if len(q) < 80:
128
+ continue
129
+ out_rows.append({
130
+ "id": f"tau2live_{domain}_{sim['id']}_{i}",
131
+ "domain": domain, "query": q, "target": target})
132
+ out = RESULTS / "tau2_live_decision_points.jsonl"
133
+ with open(out, "w") as fh:
134
+ for r in out_rows:
135
+ fh.write(json.dumps(r) + "\n")
136
+ print(f"[tau2_live] {len(out_rows)} on-policy decision points -> {out}")
137
+ print("Next: score with the standard users/sessions replay, using each "
138
+ "point's cached 'target' (no target generation needed — the trace "
139
+ "IS the served model's behavior).")
140
+
141
+
142
+ def main():
143
+ p = argparse.ArgumentParser()
144
+ p.add_argument("mode", choices=["generate", "extract", "score"])
145
+ p.add_argument("--domains", nargs="+", default=list(DOMAINS))
146
+ p.add_argument("--url", default="http://localhost:30000/v1")
147
+ p.add_argument("--model", default="gpt-oss-120b")
148
+ p.add_argument("--model-path", default="",
149
+ help="tokenizer path for cross-model scoring; empty -> "
150
+ "gpt-oss tokenizer + cached trace targets")
151
+ p.add_argument("--workers", type=int, default=16,
152
+ help="target-generation concurrency (cross-model score)")
153
+ p.add_argument("--rendered-targets", action="store_true",
154
+ help="force the cross-model path (fresh greedy targets "
155
+ "over rendered states) even for gpt-oss-120b, so all "
156
+ "three models share one protocol")
157
+ p.add_argument("--max-none-frac", type=float, default=0.20,
158
+ help="abort if the None-target fraction exceeds this; "
159
+ "raise ONLY with probe-verified justification that "
160
+ "the Nones are genuine model declines, not server "
161
+ "failures")
162
+ p.add_argument("--toolspec", action="store_true",
163
+ help="add the Phase-4.4 faithful ToolSpec baseline as a "
164
+ "4th arm (frozen global kNN-vote + schema-aware FSM)")
165
+ p.add_argument("--concurrency", type=int, default=4)
166
+ p.add_argument("--num-tasks", type=int, default=0,
167
+ help="limit tasks per domain (0 = all; use for pilots)")
168
+ args = p.parse_args()
169
+ {"generate": generate, "extract": extract, "score": score}[args.mode](args)
170
+
171
+
172
+
173
+
174
+ def score(args):
175
+ """3-arm replay over the live decision points.
176
+
177
+ Default (gpt-oss-120b): targets are cached from the canonical traces (the
178
+ served model's own on-policy calls); perturb_prob=0 so every query is
179
+ verbatim and pre-cached — no model/API calls at all.
180
+
181
+ Cross-model (--model gemma-4-31B-it --model-path <tokenizer> --url <srv>):
182
+ each decision-point STATE stays frozen (it comes from the gpt-oss canonical
183
+ conversations — off-policy states for this model, flag in any report), but
184
+ the TARGET is generated fresh by the scored model itself (greedy, $0,
185
+ self-hosted) so targets remain on-policy per model — the same discipline as
186
+ the BFCL/Seal-Tools cross-model runs and NOT the rejected shipped-trace
187
+ design (which substituted another model's calls as targets). Targets are
188
+ file-cached per model so re-runs are GPU-free."""
189
+ from collections import defaultdict
190
+ from .data import Task
191
+ from .memory import (Embedder, NoMemory, PersonalMemory, StaticGlobal,
192
+ ToolSpecBaseline)
193
+ from .run_accept import _parse_target
194
+ from .simulate import build_users
195
+ dp = [json.loads(l) for l in
196
+ (RESULTS / "tau2_live_decision_points.jsonl").read_text()
197
+ .splitlines()]
198
+ dp = [r for r in dp if r["domain"] in args.domains]
199
+ tools = {d: json.loads((ROOT / "data" / "tau2" /
200
+ f"tools_{d}.json").read_text())
201
+ for d in args.domains}
202
+ tasks = [Task(id=r["id"], query=r["query"],
203
+ functions=tools[r["domain"]], origin_id=r["id"])
204
+ for r in dp]
205
+ crossmodel = args.model != "gpt-oss-120b" or args.rendered_targets
206
+ if crossmodel:
207
+ assert args.model_path, "--model-path (tokenizer) required for " \
208
+ "cross-model scoring"
209
+ from .run_accept import generate_targets
210
+ from .client import ToolClient
211
+ cache_f = RESULTS / f"tau2_live_targets_{args.model}.json"
212
+ cache = (json.loads(cache_f.read_text()) if cache_f.exists() else {})
213
+ missing = [t for t in tasks if t.query not in cache]
214
+ if missing:
215
+ client = ToolClient(url=args.url, model=args.model)
216
+ assert client.ping(), f"{args.model} not reachable at {args.url}"
217
+ print(f"[tau2_live] cross-model targets: {len(missing)} to "
218
+ f"generate ({len(cache)} cached)", flush=True)
219
+ # chunked so a mid-run server failure loses <=1 chunk of work
220
+ CHUNK = 100
221
+ for c0 in range(0, len(missing), CHUNK):
222
+ fresh = generate_targets(client, missing[c0:c0 + CHUNK],
223
+ workers=args.workers)
224
+ cache.update(fresh)
225
+ cache_f.write_text(json.dumps(cache))
226
+ print(f"[tau2_live] cache checkpoint: {len(cache)} targets",
227
+ flush=True)
228
+ targets = cache
229
+ n_none = sum(1 for t in tasks if targets.get(t.query) is None)
230
+ print(f"[tau2_live] {args.model}: {n_none}/{len(tasks)} no-call "
231
+ "states (excluded from all arms symmetrically)", flush=True)
232
+ # Sanity gate: generate_call returns None BOTH for a genuine decline
233
+ # and for a hard failure (server wedge/timeouts). A wedged server
234
+ # silently converts the tail of the pool into fake declines and
235
+ # biases the run, so refuse to score when the None fraction exceeds
236
+ # --max-none-frac UNLESS the Nones were probe-verified as genuine
237
+ # (e.g. nemotron deliberates in text on rendered multi-turn states —
238
+ # probe-verified as genuine declines).
239
+ if n_none > args.max_none_frac * len(tasks):
240
+ raise SystemExit(
241
+ f"ABORT: {n_none}/{len(tasks)} None targets for {args.model} "
242
+ f"exceeds --max-none-frac={args.max_none_frac}. If a probe "
243
+ "confirms genuine declines (not server failure), re-run with "
244
+ "a higher --max-none-frac; otherwise fix the server, delete "
245
+ f"the poisoned nulls from {cache_f.name}, and re-run.")
246
+ metrics.get_tokenizer(args.model_path)
247
+ else:
248
+ targets = {r["query"]: r["target"] for r in dp}
249
+ metrics.get_tokenizer(
250
+ os.environ.get("SPECMEM_TOKENIZER", "openai/gpt-oss-120b"))
251
+ embedder = Embedder()
252
+ per_seed = {}
253
+ for sd in (0, 1, 2):
254
+ instances = build_users(tasks, n_users=40, tasks_per_user=15,
255
+ n_sessions=12, queries_per_session=6,
256
+ seed=sd, perturb_prob=0.0)
257
+ instances.sort(key=lambda x: (x.session, x.user_id))
258
+ arms = [NoMemory(), StaticGlobal(),
259
+ PersonalMemory(capacity=48, eviction="lru")]
260
+ if getattr(args, "toolspec", False):
261
+ arms.append(ToolSpecBaseline())
262
+ agg = {a.name: defaultdict(list) for a in arms}
263
+ cur = -1
264
+ for ins in instances:
265
+ tgt = targets.get(ins.query)
266
+ if tgt is None:
267
+ continue
268
+ if ins.session != cur:
269
+ cur = ins.session
270
+ if cur == 1:
271
+ for a in arms:
272
+ if hasattr(a, "freeze"):
273
+ a.freeze()
274
+ for a in arms:
275
+ agg[a.name][ins.session].append(metrics.score(
276
+ a.draft(ins.query, ins.functions, ins.user_id, embedder),
277
+ tgt))
278
+ cn, ca = _parse_target(tgt)
279
+ for a in arms[1:]:
280
+ a.observe(ins.query, ins.functions, ins.user_id, cn, ca,
281
+ embedder)
282
+ if isinstance(a, PersonalMemory) and ins.session == 0:
283
+ a.seed_shared(ins.query, cn, ca, embedder)
284
+ per_seed[sd] = {
285
+ n: {"MAT": round(sum(x["accept_length"] for s, xs in v.items()
286
+ if s > 0 for x in xs) /
287
+ max(1, sum(len(xs) for s, xs in v.items()
288
+ if s > 0)), 3),
289
+ "n": sum(len(xs) for s, xs in v.items() if s > 0)}
290
+ for n, v in agg.items()}
291
+ print(f"seed {sd}:", per_seed[sd], flush=True)
292
+ import statistics as st
293
+ summary = {}
294
+ for arm in per_seed[0]:
295
+ vals = [per_seed[sd][arm]["MAT"] for sd in per_seed]
296
+ summary[arm] = {"MAT_mean": round(st.mean(vals), 3),
297
+ "MAT_std": round(st.pstdev(vals), 3),
298
+ "n": per_seed[0][arm]["n"]}
299
+ g = summary["personal_memory"]["MAT_mean"]
300
+ s0 = summary["static_global"]["MAT_mean"]
301
+ out = {"config": {"pool": len(tasks), "domains": sorted(args.domains),
302
+ "model": args.model,
303
+ "users": 40, "tasks_per_user": 15,
304
+ "sessions": 12, "seeds": [0, 1, 2], "perturb_prob": 0.0,
305
+ "targets": ("on-policy per scored model (fresh greedy "
306
+ "calls over frozen gpt-oss trace states — "
307
+ "off-policy STATES, on-policy targets)"
308
+ if crossmodel else
309
+ "on-policy from live canonical traces")},
310
+ "per_seed": per_seed, "summary": summary,
311
+ "gap_pct": round(100 * (g - s0) / s0, 1)}
312
+ suffix = ("" if sorted(args.domains) == sorted(DOMAINS)
313
+ else "_" + "_".join(sorted(args.domains)))
314
+ if crossmodel:
315
+ suffix += f"_{args.model}"
316
+ (RESULTS / f"tau2_live_accept_results{suffix}.json").write_text(
317
+ json.dumps(out, indent=2))
318
+ print(json.dumps({"summary": summary, "gap_pct": out["gap_pct"]},
319
+ indent=1))
320
+
321
+
322
+ if __name__ == "__main__":
323
+ main()
harness/ttl_arm.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TTL-windowed lifecycle baseline (external-review item 2).
2
+
3
+ A stronger lifecycle baseline between "frozen" and "live-with-full-write-back":
4
+ the store still ingests live, but every entry EXPIRES after `ttl` sessions
5
+ regardless of LRU capacity pressure — i.e., bounded staleness by construction.
6
+ If a short TTL matches full live memory, most of the live benefit is staleness
7
+ decay; if it costs acceptance, cross-session persistence of older-but-recurring
8
+ calls matters beyond mere freshness. Replays the phase-2 seed-0 stream against
9
+ cached targets (no GPU/model calls).
10
+
11
+ Usage: python -m harness.ttl_arm (from code/)
12
+ Writes results/phase2_ttl_arm.json.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from collections import defaultdict
18
+ from pathlib import Path
19
+
20
+ from . import metrics
21
+ from .data import load_bfcl
22
+ from .memory import Embedder, PersonalMemory, StaticGlobal
23
+ from .run_accept import MODEL_PATH, _parse_target
24
+ from .simulate import build_users
25
+
26
+ ROOT = Path(__file__).resolve().parent.parent
27
+ RESULTS = ROOT / "results"
28
+
29
+ TTLS = (1, 2, 4, 8)
30
+
31
+
32
+ class TTLPersonalMemory(PersonalMemory):
33
+ """PersonalMemory whose entries expire after `ttl` sessions."""
34
+
35
+ def __init__(self, ttl: int, capacity: int = 48):
36
+ super().__init__(capacity=capacity, eviction="lru")
37
+ self.ttl = ttl
38
+ self.name = f"personal_ttl{ttl}"
39
+ self._entry_session: dict[int, int] = {}
40
+ self._session = 0
41
+
42
+ def tick_session(self, session: int) -> None:
43
+ self._session = session
44
+ cutoff = session - self.ttl
45
+ for store in self.stores.values():
46
+ for eid in [e for e in store
47
+ if self._entry_session.get(e, 0) < cutoff]:
48
+ del store[eid]
49
+
50
+ def observe(self, query, functions, user_id, name, args, embedder):
51
+ eid_before = self._next_id
52
+ super().observe(query, functions, user_id, name, args, embedder)
53
+ for eid in range(eid_before, self._next_id):
54
+ self._entry_session[eid] = self._session
55
+
56
+
57
+ def main():
58
+ metrics.get_tokenizer(MODEL_PATH)
59
+ tasks = load_bfcl()
60
+ embedder = Embedder()
61
+ instances = build_users(tasks, n_users=40, tasks_per_user=15,
62
+ n_sessions=12, queries_per_session=6, seed=0)
63
+ instances.sort(key=lambda x: (x.session, x.user_id))
64
+ targets = json.loads((RESULTS / "phase2_targets_seed0.json").read_text())
65
+
66
+ static = StaticGlobal()
67
+ live = PersonalMemory(capacity=48, eviction="lru")
68
+ ttl_arms = [TTLPersonalMemory(t) for t in TTLS]
69
+ arms = [("static", static), ("personal_live", live)] + \
70
+ [(a.name, a) for a in ttl_arms]
71
+
72
+ agg = {n: defaultdict(list) for n, _ in arms}
73
+ cur = -1
74
+ for ins in instances:
75
+ tgt = targets.get(ins.query)
76
+ if tgt is None:
77
+ continue
78
+ if ins.session != cur:
79
+ cur = ins.session
80
+ if cur == 1:
81
+ static.freeze()
82
+ for a in ttl_arms:
83
+ a.tick_session(cur)
84
+ for n, a in arms:
85
+ agg[n][ins.session].append(
86
+ metrics.score(a.draft(ins.query, ins.functions, ins.user_id,
87
+ embedder), tgt))
88
+ cname, cargs = _parse_target(tgt)
89
+ for n, a in arms:
90
+ a.observe(ins.query, ins.functions, ins.user_id, cname, cargs,
91
+ embedder)
92
+ if isinstance(a, PersonalMemory) and ins.session == 0:
93
+ a.seed_shared(ins.query, cname, cargs, embedder)
94
+
95
+ out = {}
96
+ for n, _ in arms:
97
+ scores = [x for s, xs in agg[n].items() if s > 0 for x in xs]
98
+ out[n] = {"n": len(scores),
99
+ "MAT": round(sum(x["accept_length"] for x in scores)
100
+ / len(scores), 3),
101
+ "exact_rate": round(sum(1 for x in scores if x["exact"])
102
+ / len(scores), 4)}
103
+ result = {"config": {"seed": 0, "capacity": 48, "ttls": list(TTLS),
104
+ "targets": "phase2_targets_seed0.json (cached)"},
105
+ "post_warmup": out}
106
+ (RESULTS / "phase2_ttl_arm.json").write_text(json.dumps(result, indent=2))
107
+ print(json.dumps(result, indent=2))
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()
harness/wallclock.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scoped wall-clock micro-benchmark (external-review item 3).
2
+
3
+ Not a production deployment study. Measures, on the real infra:
4
+ (a) draft-side overhead: embed(query) + nearest-neighbor retrieval against a
5
+ realistically-sized personal store (~48 entries) — timed on this CPU node;
6
+ (b) target-side decode speed of the served gpt-oss-120b: per-token decode
7
+ time from paired short/long generations (sequential, unbatched);
8
+ then compares mean retrieval overhead against the decode time represented by
9
+ the accepted-token gap (personal 19.96 vs static 15.70 MAT, headline run).
10
+ Writes results/wallclock_micro.json.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import time
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import requests
20
+
21
+ from .data import load_bfcl
22
+ from .memory import Embedder, _best_match, Entry
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ RESULTS = ROOT / "results"
26
+ URL = "http://localhost:30000/v1/chat/completions"
27
+ N_RETRIEVAL = 200
28
+ N_DECODE_PAIRS = 30
29
+
30
+
31
+ def main():
32
+ tasks = load_bfcl()[:N_RETRIEVAL]
33
+ emb = Embedder()
34
+ # warm the embedder, then build a 48-entry store
35
+ store = [Entry(emb.embed(t.query), "x") for t in tasks[:48]]
36
+ for t in tasks[:20]:
37
+ emb.embed(t.query + " warm")
38
+ lat = []
39
+ for t in tasks:
40
+ q = t.query + " ?" # bust the embed cache
41
+ t0 = time.perf_counter()
42
+ e = emb.embed(q)
43
+ _best_match(e, store)
44
+ lat.append((time.perf_counter() - t0) * 1000)
45
+ lat.sort()
46
+ retr = {"n": len(lat), "mean_ms": round(float(np.mean(lat)), 2),
47
+ "median_ms": round(lat[len(lat) // 2], 2),
48
+ "p95_ms": round(lat[int(0.95 * len(lat))], 2)}
49
+
50
+ def timed_gen(prompt, max_tok):
51
+ t0 = time.perf_counter()
52
+ r = requests.post(URL, json={
53
+ "model": "gpt-oss-120b", "temperature": 0.0,
54
+ "max_tokens": max_tok,
55
+ "messages": [{"role": "user", "content": prompt}]}, timeout=120)
56
+ dt = time.perf_counter() - t0
57
+ u = r.json().get("usage", {})
58
+ return dt, u.get("completion_tokens", max_tok)
59
+
60
+ per_tok = []
61
+ for t in tasks[:N_DECODE_PAIRS]:
62
+ p = "Write a long detailed paragraph about: " + t.query[:200]
63
+ d1, n1 = timed_gen(p, 8)
64
+ d2, n2 = timed_gen(p, 168)
65
+ if n2 > n1:
66
+ per_tok.append((d2 - d1) / (n2 - n1) * 1000)
67
+ per_tok.sort()
68
+ dec = {"n_pairs": len(per_tok),
69
+ "per_token_ms_median": round(per_tok[len(per_tok) // 2], 2),
70
+ "per_token_ms_mean": round(float(np.mean(per_tok)), 2)}
71
+
72
+ ptok = dec["per_token_ms_median"]
73
+ out = {
74
+ "retrieval_overhead": retr,
75
+ "decode": dec,
76
+ "comparison": {
77
+ "personal_MAT_19.96_decode_ms": round(19.96 * ptok, 1),
78
+ "static_MAT_15.70_decode_ms": round(15.70 * ptok, 1),
79
+ "gap_4.26_tokens_decode_ms": round(4.26 * ptok, 1),
80
+ "retrieval_overhead_mean_ms": retr["mean_ms"],
81
+ "overhead_over_gap_pct": round(
82
+ 100 * retr["mean_ms"] / (4.26 * ptok), 1),
83
+ },
84
+ "caveats": ("Sequential unbatched decode on the live server (batching "
85
+ "changes per-token time); retrieval timed on the CPU node,"
86
+ " single-threaded; no end-to-end speculative decoder is "
87
+ "integrated — this bounds the overhead-vs-savings ratio, "
88
+ "not deployed speedup."),
89
+ }
90
+ (RESULTS / "wallclock_micro.json").write_text(json.dumps(out, indent=2))
91
+ print(json.dumps(out, indent=2))
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core harness dependencies (tested versions in parentheses).
2
+ requests>=2.31 # (2.32) OpenAI-compatible HTTP client
3
+ numpy>=1.26 # (2.x) aggregation / bootstrap CI
4
+ transformers>=4.44 # (5.6.0) tokenizer for the token-LCP accept metric
5
+ sentence-transformers>=3.0 # (5.6.0) all-MiniLM-L6-v2 query embedder (CPU)
6
+ torch>=2.3 # (2.9.1) backend for the embedder / tokenizers
7
+ matplotlib>=3.8 # figures only
8
+
9
+ # Serving (not installed by this file — install in the serving environment):
10
+ # sglang (tested 0.5.11) for gpt-oss-120b and gemma-4-31B-it
11
+ # vllm (tested 0.18) for Nemotron-3-Super-120B
12
+ # tau2 experiments additionally need tau2-bench:
13
+ # https://github.com/sierra-research/tau2-bench
scripts/download_data.sh ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Download the benchmark data used by the SpecMem harness into data/.
3
+ #
4
+ # We do not redistribute any third-party benchmark; this script fetches each
5
+ # file directly from its official source. See data/README.md for provenance
6
+ # and licenses, and for the two datasets that need a manual step
7
+ # (ToolBench: Google Drive; tau2-bench: generated from the tau2-bench repo).
8
+ set -euo pipefail
9
+
10
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
11
+ DATA="$ROOT/data"
12
+ GH_RAW="https://raw.githubusercontent.com"
13
+
14
+ fetch() { # fetch <url> <dest>
15
+ mkdir -p "$(dirname "$2")"
16
+ if [ -s "$2" ]; then echo " [skip] $2 (exists)"; return; fi
17
+ echo " [get ] $2"
18
+ curl -fsSL --retry 3 "$1" -o "$2"
19
+ }
20
+
21
+ echo "== BFCL v4 (Berkeley Function-Call Leaderboard, Apache-2.0) =="
22
+ BFCL="$GH_RAW/ShishirPatil/gorilla/main/berkeley-function-call-leaderboard/bfcl_eval/data"
23
+ for f in BFCL_v4_simple_python.json BFCL_v4_multiple.json BFCL_v4_parallel.json; do
24
+ fetch "$BFCL/$f" "$DATA/bfcl/$f"
25
+ done
26
+
27
+ echo "== Seal-Tools (Apache-2.0) =="
28
+ SEAL="$GH_RAW/fairyshine/Seal-Tools/master/Seal-Tools_Dataset"
29
+ fetch "$SEAL/tool.jsonl" "$DATA/sealtools/tool.jsonl"
30
+ fetch "$SEAL/test_in_domain.jsonl" "$DATA/sealtools/test_in_domain.jsonl"
31
+
32
+ echo "== ToolAlpaca (Apache-2.0) =="
33
+ TA="$GH_RAW/tangqiaoyu/ToolAlpaca/main/data"
34
+ fetch "$TA/eval_simulated.json" "$DATA/toolalpaca/eval_simulated.json"
35
+ fetch "$TA/eval_real.json" "$DATA/toolalpaca/eval_real.json"
36
+
37
+ echo "== API-Bank (MIT; HF dataset liminghao1630/API-Bank) =="
38
+ AB="https://huggingface.co/datasets/liminghao1630/API-Bank/resolve/main/test-data"
39
+ fetch "$AB/level-1-api.json" "$DATA/apibank/level-1-api.json"
40
+ fetch "$AB/level-2-api.json" "$DATA/apibank/level-2-api.json"
41
+
42
+ echo
43
+ echo "Done. Two datasets need a manual step (only for the corresponding experiments):"
44
+ echo " * ToolBench -> data/toolbench/test_instruction/G{1,2,3}_*.json"
45
+ echo " from the official OpenBMB/ToolBench Google Drive release."
46
+ echo " * tau2-bench -> data/tau2/tools_{airline,retail,telecom}.json"
47
+ echo " generated from github.com/sierra-research/tau2-bench;"
48
+ echo " see data/README.md."