Initial release: SpecMem harness (code only, credentials-free)
Browse files- .gitignore +14 -0
- LICENSE +202 -0
- README.md +169 -0
- data/README.md +48 -0
- harness/__init__.py +0 -0
- harness/arch_fig.py +186 -0
- harness/bootstrap_ci.py +109 -0
- harness/capacity_sweep.py +79 -0
- harness/client.py +202 -0
- harness/crossmodel.py +109 -0
- harness/data.py +352 -0
- harness/memory.py +432 -0
- harness/metrics.py +84 -0
- harness/phase4_freshness_fig.py +67 -0
- harness/phase4_maintable.py +277 -0
- harness/phase4_overlap.py +141 -0
- harness/phase4_partb.py +103 -0
- harness/phase4_suffixdecoding.py +139 -0
- harness/phase4_suffixdecoding_maintable.py +147 -0
- harness/phase4_throughput.py +117 -0
- harness/phase4_wallclock.py +161 -0
- harness/plots.py +107 -0
- harness/reset_arm.py +97 -0
- harness/review_r1_warmup.py +144 -0
- harness/review_r2_provenance.py +177 -0
- harness/review_r3_confidence.py +149 -0
- harness/review_r3_rebuild_arm.py +149 -0
- harness/run_ablation.py +193 -0
- harness/run_accept.py +275 -0
- harness/safety.py +438 -0
- harness/simulate.py +139 -0
- harness/tau2_extract.py +142 -0
- harness/tau2_live.py +323 -0
- harness/ttl_arm.py +111 -0
- harness/wallclock.py +95 -0
- requirements.txt +13 -0
- scripts/download_data.sh +48 -0
.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)
|
| 4 |
+
[](https://www.python.org/)
|
| 5 |
+
[](#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."
|