ActionMan-0.1 research preview
Browse files- .gitattributes +3 -0
- LICENSE +15 -0
- README.md +148 -0
- assets/arena-mining.jpg +3 -0
- assets/how-it-works.jpg +3 -0
- assets/latency_vs_output_size.png +3 -0
- assets/reliability_bfcl_v4.png +0 -0
- code/actionman/__init__.py +11 -0
- code/actionman/__pycache__/__init__.cpython-312.pyc +0 -0
- code/actionman/__pycache__/api.cpython-312.pyc +0 -0
- code/actionman/__pycache__/audit.cpython-312.pyc +0 -0
- code/actionman/__pycache__/cached.cpython-312.pyc +0 -0
- code/actionman/__pycache__/features.cpython-312.pyc +0 -0
- code/actionman/__pycache__/infer.cpython-312.pyc +0 -0
- code/actionman/__pycache__/infer_v3.cpython-312.pyc +0 -0
- code/actionman/__pycache__/model.cpython-312.pyc +0 -0
- code/actionman/__pycache__/schema.cpython-312.pyc +0 -0
- code/actionman/__pycache__/scoring.cpython-312.pyc +0 -0
- code/actionman/__pycache__/span.cpython-312.pyc +0 -0
- code/actionman/__pycache__/v3feat.cpython-312.pyc +0 -0
- code/actionman/api.py +53 -0
- code/actionman/audit.py +129 -0
- code/actionman/cached.py +177 -0
- code/actionman/data.py +220 -0
- code/actionman/features.py +129 -0
- code/actionman/infer.py +132 -0
- code/actionman/infer_v3.py +182 -0
- code/actionman/model.py +176 -0
- code/actionman/schema.py +252 -0
- code/actionman/scoring.py +87 -0
- code/actionman/span.py +29 -0
- code/actionman/v3feat.py +181 -0
- config.json +107 -0
- model.safetensors +3 -0
- tokenizer.json +0 -0
- tokenizer_config.json +24 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/arena-mining.jpg filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
assets/how-it-works.jpg filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
assets/latency_vs_output_size.png filter=lfs diff=lfs merge=lfs -text
|
LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ActionMan-0.1 weights: research preview licence
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Shreyash Kumar Singh
|
| 4 |
+
|
| 5 |
+
The model weights in this repository (model.safetensors) are released for research and evaluation use only.
|
| 6 |
+
|
| 7 |
+
They were trained on data that includes Salesforce's xlam-function-calling-60k (CC-BY-4.0), whose dataset card states
|
| 8 |
+
"This release is for research purposes only." Commercial use of these weights is not permitted under this preview licence.
|
| 9 |
+
|
| 10 |
+
Other training data: Team-ACE/ToolACE (Apache-2.0) and NousResearch/hermes-function-calling-v1 (Apache-2.0).
|
| 11 |
+
Base model: answerdotai/ModernBERT-large (Apache-2.0).
|
| 12 |
+
|
| 13 |
+
The inference code in code/actionman/ is released under the MIT licence (see the GitHub repository).
|
| 14 |
+
|
| 15 |
+
THE WEIGHTS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: other
|
| 3 |
+
license_name: actionman-research-preview
|
| 4 |
+
license_link: LICENSE
|
| 5 |
+
library_name: pytorch
|
| 6 |
+
base_model: answerdotai/ModernBERT-large
|
| 7 |
+
tags:
|
| 8 |
+
- function-calling
|
| 9 |
+
- tool-calling
|
| 10 |
+
- tool-use
|
| 11 |
+
- non-autoregressive
|
| 12 |
+
- masked-diffusion
|
| 13 |
+
- modernbert
|
| 14 |
+
- research
|
| 15 |
+
datasets:
|
| 16 |
+
- Salesforce/xlam-function-calling-60k
|
| 17 |
+
- Team-ACE/ToolACE
|
| 18 |
+
- NousResearch/hermes-function-calling-v1
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
# ActionMan-0.1
|
| 22 |
+
|
| 23 |
+
**A 0.4B tool caller that doesn't write JSON.**
|
| 24 |
+
- One encoder read picks the tool.
|
| 25 |
+
- Masked diffusion fills every argument at once, in two or three passes.
|
| 26 |
+
|
| 27 |
+
ActionMan started as a question: decision models answer in milliseconds but can only choose, while LLMs can act but write one token
|
| 28 |
+
at a time. Can a decision-style encoder also write the arguments?
|
| 29 |
+
|
| 30 |
+
> **Early research preview.**
|
| 31 |
+
> - **Speed:** on one RTX 4090 at batch 1 it answers in **17 ms** (p50), where the same-data Qwen3-0.6B served by tuned vLLM takes
|
| 32 |
+
> **121 ms**. That is 7x for one call and 16x for three or more.
|
| 33 |
+
> - **Accuracy:** it is **less accurate** than that LLM (BFCL v4 Non-Live 66.0 vs 75.7).
|
| 34 |
+
> - **Scope:** it is single-turn only, and it is slow on a laptop CPU.
|
| 35 |
+
>
|
| 36 |
+
> It is fast and well calibrated; it is not a drop-in replacement for an LLM tool caller.
|
| 37 |
+
|
| 38 |
+

|
| 39 |
+
|
| 40 |
+
## How it works
|
| 41 |
+
|
| 42 |
+
- **Encoder:** ModernBERT-large (395M) reads the tools (as compact signatures), the query, and up to four `[CALL]` markers.
|
| 43 |
+
- **Decision head:** each marker picks a tool or none, by dot product against the tool-name tokens. A length head sizes each argument.
|
| 44 |
+
- **Slot block:** it is appended after the input and filled in parallel by mask-predict. Slots below 0.85 confidence are re-masked;
|
| 45 |
+
most requests finish in 2-3 passes. The input is encoded once and cached; the passes run as CUDA graphs.
|
| 46 |
+
- **Copy, pick, list:**
|
| 47 |
+
- A copy switch points at a span of the query instead of spelling the value.
|
| 48 |
+
- Booleans and enums (up to 16 options) are picked from their options.
|
| 49 |
+
- Lists get a count head and item slots.
|
| 50 |
+
|
| 51 |
+
About 410M unique parameters. The decoder is tied to the embeddings, but it is stored twice in the bf16 file.
|
| 52 |
+
|
| 53 |
+
## Results
|
| 54 |
+
|
| 55 |
+
| BFCL v4, official harness (`bfcl-eval==2026.3.23`) | size | Non-Live | Live | Irrelevance |
|
| 56 |
+
|---|---|---|---|---|
|
| 57 |
+
| **ActionMan-0.1** | 0.4B | 66.0 | 45.1 | **81.2** |
|
| 58 |
+
| Hammer2.1-0.5b | 0.49B | 65.9 | 54.7 | 80.7 |
|
| 59 |
+
| granite-4.0-350m | 0.35B | 67.7 | 46.7 | 61.1 |
|
| 60 |
+
| Qwen3-0.6B (FC) | 0.75B | 72.9 | 58.8 | 80.9 |
|
| 61 |
+
| Qwen3-0.6B fine-tuned on ActionMan's data | 0.75B | 75.7 | 54.1 | 79.1 |
|
| 62 |
+
| Qwen3-1.7B (FC) | 2.0B | 82.7 | 74.7 | 76.8 |
|
| 63 |
+
|
| 64 |
+
| Latency, batch 1, RTX 4090, 500 BFCL v4 prompts, end to end | p50 | p95 | correct on the 500 |
|
| 65 |
+
|---|---|---|---|
|
| 66 |
+
| **ActionMan-0.1, compiled** | **17.0 ms** | 28.9 ms | 59.2% |
|
| 67 |
+
| Qwen3-0.6B fine-tuned, vLLM 0.11 (tuned) | 121 ms | 347 ms | 68.6% |
|
| 68 |
+
| Qwen3-0.6B fine-tuned, llama.cpp Q8_0, GPU | 159 ms | 336 ms | 69.0% |
|
| 69 |
+
| 8 server CPU threads: ActionMan fp32 vs llama.cpp Q8_0 | 0.45 s | | 59.0% vs 66.5% |
|
| 70 |
+
|
| 71 |
+

|
| 72 |
+
|
| 73 |
+
- **The harness** reproduces the public leaderboard for Hammer and granite within 0.25 Non-Live points.
|
| 74 |
+
- **No BFCL Overall:** multi-turn and agentic categories were not run.
|
| 75 |
+
- **The full report** has per-category scores with confidence intervals, error classes, and our own 2,340-prompt eval:
|
| 76 |
+
[docs/results/benchmark.md](https://github.com/shing1Sks/ActionMan/blob/dev/docs/results/benchmark.md).
|
| 77 |
+
|
| 78 |
+
## Usage
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
pip install "git+https://github.com/shing1Sks/ActionMan" # torch, transformers>=5, safetensors, huggingface_hub
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
```python
|
| 85 |
+
from actionman.api import ActionMan
|
| 86 |
+
|
| 87 |
+
am = ActionMan.from_pretrained("shing-dev/ActionMan-0.1") # compiles on GPU (one-off warm-up)
|
| 88 |
+
tools = [{"type": "function", "function": {
|
| 89 |
+
"name": "get_weather", "description": "Current weather for a city",
|
| 90 |
+
"parameters": {"type": "object", "properties": {
|
| 91 |
+
"city": {"type": "string"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}},
|
| 92 |
+
"required": ["city"]}}}]
|
| 93 |
+
am.predict("What's the weather in Paris in celsius?", tools)
|
| 94 |
+
# [{"name": "get_weather", "arguments": {"city": "Paris", "unit": "celsius"}, "confidence": 0.9812}]
|
| 95 |
+
am.predict("Tell me a joke", tools)
|
| 96 |
+
# []
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
- **Without pip:** the inference code is also in this repo under `code/actionman/`. Add `code/` to `sys.path`.
|
| 100 |
+
- **No call:** an empty list means no tool fits. `require=True` forces a call, like `tool_choice="required"`.
|
| 101 |
+
- **`confidence`** is the minimum over the tool and every argument.
|
| 102 |
+
|
| 103 |
+
## Calibration: knowing when it doesn't know
|
| 104 |
+
|
| 105 |
+

|
| 106 |
+
|
| 107 |
+
| | our eval (2,340 prompts) | BFCL v4 Python |
|
| 108 |
+
|---|---|---|
|
| 109 |
+
| accuracy, answering everything | 78.6 | 56.1 |
|
| 110 |
+
| accuracy when the least confident 10% abstain | 85.1 | 62.1 |
|
| 111 |
+
| accuracy when the least confident 20% abstain | 89.5 | 66.9 |
|
| 112 |
+
|
| 113 |
+
Confidence ranks errors well, but it is over-confident on BFCL (ECE 0.28, vs 0.11 on our eval). Use it as a threshold, not as a probability.
|
| 114 |
+
|
| 115 |
+
## Limits
|
| 116 |
+
|
| 117 |
+
- **Single turn, English.** At most 4 calls per request, lists of up to 7 items, and an input of about 896 tokens.
|
| 118 |
+
- **No reasoning.**
|
| 119 |
+
- Values that are not in the text are its main error: "until 11 PM" -> `23`, unit conversion, world knowledge.
|
| 120 |
+
- On long, real user prompts (BFCL Live) it falls well behind small LLMs.
|
| 121 |
+
- **CPU:** 0.45 s per call on 8 server cores, and several seconds on a laptop CPU. The speed claim is a GPU claim.
|
| 122 |
+
- **The arena:** ActionMan executes instructions; it does not plan. Given a description of a game state instead of an instruction,
|
| 123 |
+
it loops (1 of 12 mining races won).
|
| 124 |
+
|
| 125 |
+
## Training
|
| 126 |
+
|
| 127 |
+
- **Data:** 5 epochs over 80,459 rows:
|
| 128 |
+
- xLAM-60k, 58,000;
|
| 129 |
+
- ToolACE, 10,359;
|
| 130 |
+
- Hermes v1, 1,100;
|
| 131 |
+
- no-call negatives, 11,000 (built from xLAM queries).
|
| 132 |
+
- **Hardware and cost:** one RTX 4090, about 2 hours. The whole research, every run included, cost about $16 of rented GPU time,
|
| 133 |
+
plus $2 for the benchmarks.
|
| 134 |
+
|
| 135 |
+
## Licence
|
| 136 |
+
|
| 137 |
+
- **The weights are a research preview, for research use only.** The training data includes Salesforce's xLAM-60k, whose card
|
| 138 |
+
says "This release is for research purposes only."
|
| 139 |
+
- **ToolACE and Hermes are Apache-2.0.**
|
| 140 |
+
- **The inference code is MIT.**
|
| 141 |
+
- **Open weights** would need a retrain without xLAM, which is planned.
|
| 142 |
+
|
| 143 |
+
## Links
|
| 144 |
+
|
| 145 |
+
- **Code, benchmarks, arena:** [github.com/shing1Sks/ActionMan](https://github.com/shing1Sks/ActionMan)
|
| 146 |
+
- **The research film:** [actionman-research-film.mp4](https://github.com/shing1Sks/ActionMan/releases/download/v0.1/actionman-research-film.mp4)
|
| 147 |
+
|
| 148 |
+
Research by Shreyash Kumar Singh with an AI coding agent (Claude).
|
assets/arena-mining.jpg
ADDED
|
Git LFS Details
|
assets/how-it-works.jpg
ADDED
|
Git LFS Details
|
assets/latency_vs_output_size.png
ADDED
|
Git LFS Details
|
assets/reliability_bfcl_v4.png
ADDED
|
code/actionman/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ActionMan: decision encoder + masked-diffusion argument filler."""
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def beat():
|
| 7 |
+
"""Touch $HEARTBEAT (if set) so the box watchdog knows we're alive."""
|
| 8 |
+
p = os.environ.get("HEARTBEAT")
|
| 9 |
+
if p:
|
| 10 |
+
with open(p, "w") as f:
|
| 11 |
+
f.write(str(time.time()))
|
code/actionman/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (822 Bytes). View file
|
|
|
code/actionman/__pycache__/api.cpython-312.pyc
ADDED
|
Binary file (4.96 kB). View file
|
|
|
code/actionman/__pycache__/audit.cpython-312.pyc
ADDED
|
Binary file (8.59 kB). View file
|
|
|
code/actionman/__pycache__/cached.cpython-312.pyc
ADDED
|
Binary file (14.5 kB). View file
|
|
|
code/actionman/__pycache__/features.cpython-312.pyc
ADDED
|
Binary file (8.03 kB). View file
|
|
|
code/actionman/__pycache__/infer.cpython-312.pyc
ADDED
|
Binary file (9.86 kB). View file
|
|
|
code/actionman/__pycache__/infer_v3.cpython-312.pyc
ADDED
|
Binary file (13.7 kB). View file
|
|
|
code/actionman/__pycache__/model.cpython-312.pyc
ADDED
|
Binary file (17 kB). View file
|
|
|
code/actionman/__pycache__/schema.cpython-312.pyc
ADDED
|
Binary file (15.8 kB). View file
|
|
|
code/actionman/__pycache__/scoring.cpython-312.pyc
ADDED
|
Binary file (5.79 kB). View file
|
|
|
code/actionman/__pycache__/span.cpython-312.pyc
ADDED
|
Binary file (2.84 kB). View file
|
|
|
code/actionman/__pycache__/v3feat.cpython-312.pyc
ADDED
|
Binary file (13.1 kB). View file
|
|
|
code/actionman/api.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Public API: load a released ActionMan and turn (query, tools) into tool calls.
|
| 2 |
+
|
| 3 |
+
from actionman.api import ActionMan
|
| 4 |
+
am = ActionMan.from_pretrained("shing-dev/ActionMan-0.1") # or a local export directory
|
| 5 |
+
am.predict("Weather in Paris in celsius?", tools) # tools: OpenAI-style function schemas
|
| 6 |
+
# -> [{"name": "get_weather", "arguments": {"city": "Paris", "unit": "celsius"}, "confidence": 0.97}]
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
|
| 15 |
+
from . import cached as C
|
| 16 |
+
from .features import load_tokenizer
|
| 17 |
+
from .infer import predict as _predict
|
| 18 |
+
from .model import ActionMan as _Model
|
| 19 |
+
from .schema import Example, from_openai_tool
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ActionMan:
|
| 23 |
+
def __init__(self, model, tok, flags: dict, device: str):
|
| 24 |
+
self.model, self.tok, self.flags, self.device = model, tok, flags, device
|
| 25 |
+
|
| 26 |
+
@classmethod
|
| 27 |
+
def from_pretrained(cls, path: str, device: str | None = None, compile: bool | None = None, token: str | None = None):
|
| 28 |
+
"""path: a local export dir or a Hugging Face repo id. compile (default: on GPU): padded static encoders
|
| 29 |
+
under torch.compile, about 4x faster per call after a one-off warm-up."""
|
| 30 |
+
from safetensors.torch import load_file
|
| 31 |
+
from transformers import ModernBertConfig
|
| 32 |
+
if not os.path.isdir(path):
|
| 33 |
+
from huggingface_hub import snapshot_download
|
| 34 |
+
path = snapshot_download(path, token=token)
|
| 35 |
+
cfg = json.load(open(os.path.join(path, "config.json")))
|
| 36 |
+
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 37 |
+
tok = load_tokenizer(path)
|
| 38 |
+
model = _Model.from_encoder(ModernBertConfig.from_dict(cfg["encoder"]), tok, v3=cfg["flags"]["v3"])
|
| 39 |
+
model.load_state_dict({k: v.float() for k, v in load_file(os.path.join(path, "model.safetensors")).items()})
|
| 40 |
+
model = model.to(device).eval()
|
| 41 |
+
if compile if compile is not None else device == "cuda": # process-wide swap, as in evaluate --compile
|
| 42 |
+
C.encode_context, C.encode_slots = C.make_static(model.mlm.model)
|
| 43 |
+
return cls(model, tok, cfg["flags"], device)
|
| 44 |
+
|
| 45 |
+
@torch.no_grad()
|
| 46 |
+
def predict(self, query: str, tools: list[dict], T: int = 3, require: bool = False) -> list[dict]:
|
| 47 |
+
"""Tool calls for `query`, best first. Empty list = no tool fits (unless require=True, like tool_choice="required").
|
| 48 |
+
confidence = min(tool confidence, every argument's confidence). It ranks errors well enough to abstain on
|
| 49 |
+
(docs/results/benchmark.md: the least confident 10% abstaining takes our eval from 78.6 to 85.1)."""
|
| 50 |
+
ex = Example(query, [from_openai_tool(t) for t in tools], [])
|
| 51 |
+
pred = _predict(self.model, self.tok, ex, T=T, device=self.device, require=require, **self.flags)
|
| 52 |
+
return [{"name": c.name, "arguments": c.args,
|
| 53 |
+
"confidence": round(min([c.conf_tool, *c.conf_args.values()]), 4)} for c in pred.calls]
|
code/actionman/audit.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data audit (v3 S0): how much argument mass can be copied from the query, picked from options, or is a list."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
from collections import Counter, defaultdict
|
| 6 |
+
|
| 7 |
+
from .schema import param_options, value_to_text
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def query_offsets(tok, query: str) -> list[tuple[int, int]]:
|
| 11 |
+
"""Char span per query token, with a leading space (BPE 'Ġ') trimmed off."""
|
| 12 |
+
offs = tok(query, add_special_tokens=False, return_offsets_mapping=True).offset_mapping
|
| 13 |
+
out = []
|
| 14 |
+
for a, b in offs:
|
| 15 |
+
while a < b and query[a].isspace():
|
| 16 |
+
a += 1
|
| 17 |
+
out.append((a, b))
|
| 18 |
+
return out
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def copy_span(tok, query: str, text: str, offs=None):
|
| 22 |
+
"""First case-insensitive, token-aligned occurrence of `text` in `query` -> (start_tok, end_tok) or None."""
|
| 23 |
+
text = str(text).strip()
|
| 24 |
+
if not text:
|
| 25 |
+
return None
|
| 26 |
+
offs = offs if offs is not None else query_offsets(tok, query)
|
| 27 |
+
starts = {a: i for i, (a, b) in reversed(list(enumerate(offs))) if b > a}
|
| 28 |
+
ends = {b: i for i, (a, b) in enumerate(offs) if b > a}
|
| 29 |
+
low, t = query.lower(), text.lower()
|
| 30 |
+
at = low.find(t)
|
| 31 |
+
while at != -1:
|
| 32 |
+
s, e = starts.get(at), ends.get(at + len(t))
|
| 33 |
+
if s is not None and e is not None and e >= s:
|
| 34 |
+
return s, e
|
| 35 |
+
at = low.find(t, at + 1)
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _kind(v) -> str:
|
| 40 |
+
if isinstance(v, bool):
|
| 41 |
+
return "bool"
|
| 42 |
+
if isinstance(v, (int, float)):
|
| 43 |
+
return "num"
|
| 44 |
+
if isinstance(v, str):
|
| 45 |
+
return "str"
|
| 46 |
+
if isinstance(v, list):
|
| 47 |
+
return "list"
|
| 48 |
+
if isinstance(v, dict):
|
| 49 |
+
return "dict"
|
| 50 |
+
return "other"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def audit(tok, examples) -> dict:
|
| 54 |
+
"""Per-source counters over every gold argument value."""
|
| 55 |
+
stats: dict[str, Counter] = defaultdict(Counter)
|
| 56 |
+
for ex in examples:
|
| 57 |
+
c = stats[ex.source.split("_hardneg")[0].split("_neg")[0] if ex.source.startswith("xlam") else ex.source]
|
| 58 |
+
if not ex.calls:
|
| 59 |
+
continue
|
| 60 |
+
offs = query_offsets(tok, ex.query)
|
| 61 |
+
by_name = {t.name: t for t in ex.tools}
|
| 62 |
+
for call in ex.calls:
|
| 63 |
+
tool = by_name.get(call.name)
|
| 64 |
+
if tool is None:
|
| 65 |
+
continue
|
| 66 |
+
pmap = {p.name: p for p in tool.params}
|
| 67 |
+
for name, v in call.args.items():
|
| 68 |
+
p = pmap.get(name)
|
| 69 |
+
k = _kind(v)
|
| 70 |
+
c["args"] += 1
|
| 71 |
+
c[f"type_{k}"] += 1
|
| 72 |
+
n_tok = len(tok(value_to_text(v), add_special_tokens=False).input_ids)
|
| 73 |
+
if n_tok > 32:
|
| 74 |
+
c["over_32_tokens"] += 1
|
| 75 |
+
opts = param_options(p) if p else None
|
| 76 |
+
if opts is not None:
|
| 77 |
+
c["has_options"] += 1
|
| 78 |
+
if any(value_to_text(o).lower() == value_to_text(v).lower() for o in opts):
|
| 79 |
+
c["gold_in_options"] += 1
|
| 80 |
+
if k in ("str", "num"):
|
| 81 |
+
c[f"{k}_total"] += 1
|
| 82 |
+
if copy_span(tok, ex.query, value_to_text(v), offs) is not None:
|
| 83 |
+
c[f"{k}_copyable"] += 1
|
| 84 |
+
if k == "list":
|
| 85 |
+
c["list_items"] += len(v)
|
| 86 |
+
if len(v) > 7:
|
| 87 |
+
c["list_over_7"] += 1
|
| 88 |
+
for it in v:
|
| 89 |
+
c["list_item_total"] += 1
|
| 90 |
+
if len(tok(value_to_text(it), add_special_tokens=False).input_ids) > 8:
|
| 91 |
+
c["list_item_over_8_tokens"] += 1
|
| 92 |
+
if isinstance(it, (str, int, float)) and not isinstance(it, bool) and \
|
| 93 |
+
copy_span(tok, ex.query, value_to_text(it), offs) is not None:
|
| 94 |
+
c["list_item_copyable"] += 1
|
| 95 |
+
return stats
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _pct(a, b):
|
| 99 |
+
return f"{100 * a / b:.1f}%" if b else "-"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def report(stats: dict) -> str:
|
| 103 |
+
rows = ["| source | args | str copyable | num copyable | bool/enum (options) | gold in options | list args | "
|
| 104 |
+
"list items copyable | item >8 tok | list >7 | value >32 tok |", "|" + "---|" * 11]
|
| 105 |
+
for src in sorted(stats):
|
| 106 |
+
c = stats[src]
|
| 107 |
+
if not c["args"]:
|
| 108 |
+
continue
|
| 109 |
+
rows.append(f"| {src} | {c['args']} | {_pct(c['str_copyable'], c['str_total'])} of {c['str_total']} | "
|
| 110 |
+
f"{_pct(c['num_copyable'], c['num_total'])} of {c['num_total']} | "
|
| 111 |
+
f"{_pct(c['has_options'], c['args'])} | {_pct(c['gold_in_options'], c['has_options'])} | "
|
| 112 |
+
f"{_pct(c['type_list'], c['args'])} | {_pct(c['list_item_copyable'], c['list_item_total'])} | "
|
| 113 |
+
f"{_pct(c['list_item_over_8_tokens'], c['list_item_total'])} | {c['list_over_7']} | "
|
| 114 |
+
f"{_pct(c['over_32_tokens'], c['args'])} |")
|
| 115 |
+
return "\n".join(rows)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
from .data import load_jsonl
|
| 120 |
+
from .features import load_tokenizer
|
| 121 |
+
tok = load_tokenizer()
|
| 122 |
+
parts = []
|
| 123 |
+
for f in ("train_v2.jsonl", "test.jsonl", "bfcl.jsonl"):
|
| 124 |
+
st = audit(tok, load_jsonl(os.path.join("data/prepared", f)))
|
| 125 |
+
parts.append(f"## {f}\n\n{report(st)}\n")
|
| 126 |
+
out = "# v3 data audit (S0)\n\nShares are over gold argument values. 'copyable' = the value text occurs in the " \
|
| 127 |
+
"query, case-insensitively, on token boundaries.\n\n" + "\n".join(parts)
|
| 128 |
+
open("docs/results/audit.md", "w", encoding="utf-8").write(out)
|
| 129 |
+
print(out)
|
code/actionman/cached.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Block-causal 'cached' variant (Stage 4).
|
| 2 |
+
|
| 3 |
+
Sequence = [pass-A context incl. [CALL]x4 [SEP]] [ARGS] slot block [SEP].
|
| 4 |
+
Mask rule: context tokens see only context; slot-block tokens see everything.
|
| 5 |
+
Consequences:
|
| 6 |
+
* training needs ONE forward (decision/length heads read context states that can't see the slots);
|
| 7 |
+
* at inference the context K/V of every layer is computed once (that IS pass A) and each refinement
|
| 8 |
+
pass runs only the ~10-40 slot tokens through the network.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn.functional as F
|
| 14 |
+
from transformers.models.modernbert.modeling_modernbert import apply_rotary_pos_emb
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def block_masks(attn: torch.Tensor, ctx_len: torch.Tensor, window: int) -> dict:
|
| 18 |
+
"""attn [B,L] (1 = real token), ctx_len [B]. Returns HF ModernBERT mask dict (bool, True = attend)."""
|
| 19 |
+
B, L = attn.shape
|
| 20 |
+
idx = torch.arange(L, device=attn.device)
|
| 21 |
+
valid_k = attn.bool()[:, None, None, :]
|
| 22 |
+
q_slot = (idx[None, :] >= ctx_len[:, None])[:, None, :, None]
|
| 23 |
+
k_ctx = (idx[None, :] < ctx_len[:, None])[:, None, None, :]
|
| 24 |
+
eye = torch.eye(L, dtype=torch.bool, device=attn.device)[None, None]
|
| 25 |
+
full = (valid_k & (q_slot | k_ctx)) | eye
|
| 26 |
+
win = ((idx[:, None] - idx[None, :]).abs() <= window)[None, None]
|
| 27 |
+
return {"full_attention": full, "sliding_attention": full & win}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _layer_qkv(layer, x, cos, sin):
|
| 31 |
+
attn = layer.attn
|
| 32 |
+
B, L, _ = x.shape
|
| 33 |
+
qkv = attn.Wqkv(layer.attn_norm(x)).view(B, L, 3, -1, attn.head_dim)
|
| 34 |
+
q, k, v = (t.transpose(1, 2) for t in qkv.unbind(dim=2))
|
| 35 |
+
q, k = apply_rotary_pos_emb(q, k, cos, sin, unsqueeze_dim=1)
|
| 36 |
+
return q, k, v
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _finish(layer, x, out):
|
| 40 |
+
B, L = x.shape[:2]
|
| 41 |
+
x = x + layer.attn.Wo(out.transpose(1, 2).reshape(B, L, -1))
|
| 42 |
+
return x + layer.mlp(layer.mlp_norm(x))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@torch.no_grad()
|
| 46 |
+
def encode_context(enc, ids: torch.Tensor):
|
| 47 |
+
"""enc = ModernBertModel. ids [1,Lc]. Returns (final hidden [1,Lc,d], per-layer (k, v) cache)."""
|
| 48 |
+
cfg = enc.config
|
| 49 |
+
x = enc.embeddings(input_ids=ids)
|
| 50 |
+
L = ids.shape[1]
|
| 51 |
+
pos = torch.arange(L, device=ids.device)[None]
|
| 52 |
+
pe = {lt: enc.rotary_emb(x, pos, lt) for lt in set(cfg.layer_types)}
|
| 53 |
+
idx = torch.arange(L, device=ids.device)
|
| 54 |
+
win = ((idx[:, None] - idx[None, :]).abs() <= cfg.sliding_window)[None, None]
|
| 55 |
+
cache = []
|
| 56 |
+
for layer in enc.layers:
|
| 57 |
+
cos, sin = pe[layer.attention_type]
|
| 58 |
+
q, k, v = _layer_qkv(layer, x, cos, sin)
|
| 59 |
+
mask = win if layer.attention_type == "sliding_attention" else None
|
| 60 |
+
out = F.scaled_dot_product_attention(q, k, v, attn_mask=mask, scale=layer.attn.head_dim ** -0.5)
|
| 61 |
+
x = _finish(layer, x, out)
|
| 62 |
+
cache.append((k, v))
|
| 63 |
+
return enc.final_norm(x), cache
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@torch.no_grad()
|
| 67 |
+
def encode_slots(enc, slot_ids: torch.Tensor, start: int, cache):
|
| 68 |
+
"""Run only the slot-block tokens (absolute positions start..start+S-1) over the cached context K/V."""
|
| 69 |
+
cfg = enc.config
|
| 70 |
+
x = enc.embeddings(input_ids=slot_ids)
|
| 71 |
+
S = slot_ids.shape[1]
|
| 72 |
+
Lc = cache[0][0].shape[2]
|
| 73 |
+
qpos = torch.arange(start, start + S, device=slot_ids.device)
|
| 74 |
+
kpos = torch.cat([torch.arange(Lc, device=slot_ids.device), qpos])
|
| 75 |
+
pe = {lt: enc.rotary_emb(x, qpos[None], lt) for lt in set(cfg.layer_types)}
|
| 76 |
+
win = ((qpos[:, None] - kpos[None, :]).abs() <= cfg.sliding_window)[None, None]
|
| 77 |
+
for layer, (ck, cv) in zip(enc.layers, cache):
|
| 78 |
+
cos, sin = pe[layer.attention_type]
|
| 79 |
+
q, k, v = _layer_qkv(layer, x, cos, sin)
|
| 80 |
+
k, v = torch.cat([ck, k], dim=2), torch.cat([cv, v], dim=2)
|
| 81 |
+
mask = win if layer.attention_type == "sliding_attention" else None
|
| 82 |
+
out = F.scaled_dot_product_attention(q, k, v, attn_mask=mask, scale=layer.attn.head_dim ** -0.5)
|
| 83 |
+
x = _finish(layer, x, out)
|
| 84 |
+
return enc.final_norm(x)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ---------- static-shape variants for CUDA graphs / torch.compile (v3 S6) ----------
|
| 88 |
+
# Lengths are padded up to a bucket and the real lengths are passed as tensors, so one compiled graph serves every
|
| 89 |
+
# prompt in a bucket. Padded keys are masked out; outputs at padded positions are garbage and get sliced off.
|
| 90 |
+
|
| 91 |
+
def _ctx_core(enc, ids, valid):
|
| 92 |
+
"""ids [1,P] padded; valid = tensor([real length]). -> hidden [1,P,d], cache."""
|
| 93 |
+
cfg = enc.config
|
| 94 |
+
x = enc.embeddings(input_ids=ids)
|
| 95 |
+
P = ids.shape[1]
|
| 96 |
+
idx = torch.arange(P, device=ids.device)
|
| 97 |
+
pos = idx[None]
|
| 98 |
+
pe = {lt: enc.rotary_emb(x, pos, lt) for lt in set(cfg.layer_types)}
|
| 99 |
+
keyok = (idx < valid)[None, None, None, :]
|
| 100 |
+
eye = torch.eye(P, dtype=torch.bool, device=ids.device)[None, None]
|
| 101 |
+
full = keyok | eye
|
| 102 |
+
win = full & ((idx[:, None] - idx[None, :]).abs() <= cfg.sliding_window)[None, None]
|
| 103 |
+
cache = []
|
| 104 |
+
for layer in enc.layers:
|
| 105 |
+
cos, sin = pe[layer.attention_type]
|
| 106 |
+
q, k, v = _layer_qkv(layer, x, cos, sin)
|
| 107 |
+
mask = win if layer.attention_type == "sliding_attention" else full
|
| 108 |
+
out = F.scaled_dot_product_attention(q, k, v, attn_mask=mask, scale=layer.attn.head_dim ** -0.5)
|
| 109 |
+
x = _finish(layer, x, out)
|
| 110 |
+
cache.append((k, v))
|
| 111 |
+
return enc.final_norm(x), cache
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _slot_core(enc, slot_ids, start, s_valid, c_valid, cache):
|
| 115 |
+
"""slot_ids [1,Sp] padded; start/s_valid/c_valid tensors; cache from _ctx_core (padded context)."""
|
| 116 |
+
cfg = enc.config
|
| 117 |
+
x = enc.embeddings(input_ids=slot_ids)
|
| 118 |
+
Sp = slot_ids.shape[1]
|
| 119 |
+
Lc = cache[0][0].shape[2]
|
| 120 |
+
ar = torch.arange(Sp, device=slot_ids.device)
|
| 121 |
+
qpos = start + ar
|
| 122 |
+
kc = torch.arange(Lc, device=slot_ids.device)
|
| 123 |
+
kpos = torch.cat([kc, qpos])
|
| 124 |
+
keyok = torch.cat([kc < c_valid, ar < s_valid])[None, None, None, :]
|
| 125 |
+
pe = {lt: enc.rotary_emb(x, qpos[None], lt) for lt in set(cfg.layer_types)}
|
| 126 |
+
full = keyok | torch.cat([torch.zeros(Sp, Lc, dtype=torch.bool, device=slot_ids.device),
|
| 127 |
+
torch.eye(Sp, dtype=torch.bool, device=slot_ids.device)], dim=1)[None, None]
|
| 128 |
+
win = full & ((qpos[:, None] - kpos[None, :]).abs() <= cfg.sliding_window)[None, None]
|
| 129 |
+
for layer, (ck, cv) in zip(enc.layers, cache):
|
| 130 |
+
cos, sin = pe[layer.attention_type]
|
| 131 |
+
q, k, v = _layer_qkv(layer, x, cos, sin)
|
| 132 |
+
k, v = torch.cat([ck, k], dim=2), torch.cat([cv, v], dim=2)
|
| 133 |
+
mask = win if layer.attention_type == "sliding_attention" else full
|
| 134 |
+
out = F.scaled_dot_product_attention(q, k, v, attn_mask=mask, scale=layer.attn.head_dim ** -0.5)
|
| 135 |
+
x = _finish(layer, x, out)
|
| 136 |
+
return enc.final_norm(x)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _bucket(n: int, b: int) -> int:
|
| 140 |
+
return -(-n // b) * b
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def make_static(enc, ctx_bucket: int = 256, slot_bucket: int = 128, compile: bool = True):
|
| 144 |
+
"""-> (encode_context, encode_slots) drop-ins that pad to buckets (and run compiled, CUDA-graphed cores).
|
| 145 |
+
Buckets are coarse on purpose: every (context bucket, slot bucket) pair is a separate compiled graph."""
|
| 146 |
+
ctx_core, slot_core = _ctx_core, _slot_core
|
| 147 |
+
if compile:
|
| 148 |
+
import torch._dynamo as dynamo
|
| 149 |
+
for name in ("cache_size_limit", "recompile_limit"):
|
| 150 |
+
if hasattr(dynamo.config, name):
|
| 151 |
+
setattr(dynamo.config, name, 64)
|
| 152 |
+
ctx_core = torch.compile(_ctx_core, mode="reduce-overhead", dynamic=False, fullgraph=False)
|
| 153 |
+
slot_core = torch.compile(_slot_core, mode="reduce-overhead", dynamic=False, fullgraph=False)
|
| 154 |
+
pad_id = enc.config.pad_token_id or 0
|
| 155 |
+
state = {}
|
| 156 |
+
|
| 157 |
+
@torch.no_grad()
|
| 158 |
+
def encode_context_s(enc_, ids):
|
| 159 |
+
L = ids.shape[1]
|
| 160 |
+
P = _bucket(L, ctx_bucket)
|
| 161 |
+
ids_p = F.pad(ids, (0, P - L), value=pad_id)
|
| 162 |
+
h, cache = ctx_core(enc, ids_p, torch.tensor(L, device=ids.device))
|
| 163 |
+
h, cache = h.clone(), [(k.clone(), v.clone()) for k, v in cache] # outlive the CUDA-graph buffers
|
| 164 |
+
state["c_valid"] = L
|
| 165 |
+
return h[:, :L], cache
|
| 166 |
+
|
| 167 |
+
@torch.no_grad()
|
| 168 |
+
def encode_slots_s(enc_, slot_ids, start, cache):
|
| 169 |
+
S = slot_ids.shape[1]
|
| 170 |
+
Sp = _bucket(S, slot_bucket)
|
| 171 |
+
ids_p = F.pad(slot_ids, (0, Sp - S), value=pad_id)
|
| 172 |
+
dev = slot_ids.device
|
| 173 |
+
out = slot_core(enc, ids_p, torch.tensor(start, device=dev), torch.tensor(S, device=dev),
|
| 174 |
+
torch.tensor(state["c_valid"], device=dev), cache)
|
| 175 |
+
return out[:, :S].clone()
|
| 176 |
+
|
| 177 |
+
return encode_context_s, encode_slots_s
|
code/actionman/data.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Datasets, collation (random mask ratio = masked-diffusion training), splits, negatives."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import random
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
from .features import build_pass_a, build_pass_b, gold_plan
|
| 10 |
+
from .schema import Example, example_from_dict, example_to_dict
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_jsonl(path) -> list[Example]:
|
| 14 |
+
with open(path, encoding="utf-8") as f:
|
| 15 |
+
return [example_from_dict(json.loads(line)) for line in f if line.strip()]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def save_jsonl(path, examples: list[Example]):
|
| 19 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 20 |
+
for ex in examples:
|
| 21 |
+
f.write(json.dumps(example_to_dict(ex), ensure_ascii=False) + "\n")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def featurize(tok, ex: Example, cached: bool = False, markers: bool = False, v3: bool = False) -> dict | None:
|
| 25 |
+
if v3:
|
| 26 |
+
from .v3feat import build_pass_a_v3, build_pass_b_v3, gold_plan_v3
|
| 27 |
+
a = build_pass_a_v3(tok, ex)
|
| 28 |
+
if a is None:
|
| 29 |
+
return None
|
| 30 |
+
b = None
|
| 31 |
+
if ex.calls and len(ex.calls) <= len(a["call_pos"]):
|
| 32 |
+
b = build_pass_b_v3(tok, ex, a, gold_plan_v3(tok, ex, a), with_targets=True, keep_context=cached,
|
| 33 |
+
markers=markers)
|
| 34 |
+
return {"a": a, "b": b}
|
| 35 |
+
a = build_pass_a(tok, ex)
|
| 36 |
+
if a is None:
|
| 37 |
+
return None
|
| 38 |
+
b = None
|
| 39 |
+
if ex.calls and len(ex.calls) <= len(a["call_pos"]):
|
| 40 |
+
b = build_pass_b(tok, ex, a, gold_plan(a, ex), with_targets=True, keep_context=cached, markers=markers)
|
| 41 |
+
return {"a": a, "b": b}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class FeatureDataset(torch.utils.data.Dataset):
|
| 45 |
+
def __init__(self, tok, examples: list[Example], cached: bool = False, markers: bool = False, v3: bool = False):
|
| 46 |
+
self.tok, self.examples, self.cached, self.markers, self.v3 = tok, examples, cached, markers, v3
|
| 47 |
+
|
| 48 |
+
def __len__(self):
|
| 49 |
+
return len(self.examples)
|
| 50 |
+
|
| 51 |
+
def __getitem__(self, i):
|
| 52 |
+
return featurize(self.tok, self.examples[i], self.cached, self.markers, self.v3)
|
| 53 |
+
|
| 54 |
+
def length(self, i) -> int:
|
| 55 |
+
"""Cheap length proxy for bucketing (chars/3)."""
|
| 56 |
+
ex = self.examples[i]
|
| 57 |
+
n = len(ex.query) + sum(len(t.name) + len(t.description) + 20 * len(t.params) for t in ex.tools)
|
| 58 |
+
return n // 3 + 40
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _pad(seqs, value):
|
| 62 |
+
L = max(len(s) for s in seqs)
|
| 63 |
+
ids = torch.full((len(seqs), L), value, dtype=torch.long)
|
| 64 |
+
attn = torch.zeros((len(seqs), L), dtype=torch.long)
|
| 65 |
+
for i, s in enumerate(seqs):
|
| 66 |
+
ids[i, : len(s)] = torch.tensor(s)
|
| 67 |
+
attn[i, : len(s)] = 1
|
| 68 |
+
return ids, attn
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _mask_slots(b, rng, v3_mask: str = "clean"):
|
| 72 |
+
"""Masked-diffusion corruption: random ratio r ~ U(0,1], mask that share of slot tokens, reveal the rest.
|
| 73 |
+
v3_mask="legacy" reproduces the original v3 run (s10): every anchor always masked, gate trained on all anchors."""
|
| 74 |
+
ids = list(b["input_ids"])
|
| 75 |
+
flat = [(p, t) for pos, tg in zip(b["slot_pos"], b["slot_targets"]) for p, t in zip(pos, tg)]
|
| 76 |
+
if not flat: # v3: option-only slot block
|
| 77 |
+
return ids, []
|
| 78 |
+
n_mask = max(1, round(rng.uniform(0.0, 1.0) * len(flat)))
|
| 79 |
+
masked = set(rng.sample(range(len(flat)), n_mask))
|
| 80 |
+
always = set()
|
| 81 |
+
if b.get("anchors") and v3_mask == "legacy":
|
| 82 |
+
always = set(b["anchors"])
|
| 83 |
+
b.pop("_clean", None)
|
| 84 |
+
elif b.get("anchors"):
|
| 85 |
+
# v3: half the values are fully masked ("clean"); only their anchors train the copy gate / span pointer,
|
| 86 |
+
# matching inference, where the gate is read once with every slot masked. Other values follow the random
|
| 87 |
+
# mask, so revealed anchors (as in later refinement passes) are seen too.
|
| 88 |
+
clean = [pos for pos in b["slot_pos"] if rng.random() < 0.5]
|
| 89 |
+
always = {p for pos in clean for p in pos}
|
| 90 |
+
b["_clean"] = [pos[0] for pos in clean]
|
| 91 |
+
targets = []
|
| 92 |
+
for idx, (p, t) in enumerate(flat):
|
| 93 |
+
if idx in masked or p in always:
|
| 94 |
+
targets.append((p, t))
|
| 95 |
+
else:
|
| 96 |
+
ids[p] = t
|
| 97 |
+
return ids, targets
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def collate(items, tok, rng: random.Random | None = None, cached: bool = False, v3_mask: str = "clean"):
|
| 101 |
+
rng = rng or random
|
| 102 |
+
items = [it for it in items if it is not None]
|
| 103 |
+
if not items:
|
| 104 |
+
return None
|
| 105 |
+
if cached:
|
| 106 |
+
return _collate_cached(items, tok, rng, v3_mask)
|
| 107 |
+
A = [it["a"] for it in items]
|
| 108 |
+
a_ids, a_attn = _pad([a["input_ids"] for a in A], tok.pad_token_id)
|
| 109 |
+
J = max(max(len(a["t_pos"]) for a in A), 1)
|
| 110 |
+
t_pos = torch.full((len(A), J), -1, dtype=torch.long)
|
| 111 |
+
for i, a in enumerate(A):
|
| 112 |
+
t_pos[i, : len(a["t_pos"])] = torch.tensor(a["t_pos"], dtype=torch.long)
|
| 113 |
+
dec = torch.tensor([[J if (l == len(a["t_pos"])) else l for l in a["dec_labels"]] for a in A])
|
| 114 |
+
len_idx, len_labels = [], []
|
| 115 |
+
for i, a in enumerate(A):
|
| 116 |
+
for (ci, j, k, b) in a["len_items"]:
|
| 117 |
+
len_idx.append((i, a["call_pos"][ci], a["arg_pos"][(j, k)]))
|
| 118 |
+
len_labels.append(b)
|
| 119 |
+
batch = {"a_ids": a_ids, "a_attn": a_attn, "t_pos": t_pos, "t_mask": t_pos >= 0,
|
| 120 |
+
"call_pos": torch.tensor([a["call_pos"] for a in A]), "dec_labels": dec,
|
| 121 |
+
"len_idx": torch.tensor(len_idx, dtype=torch.long).reshape(-1, 3),
|
| 122 |
+
"len_labels": torch.tensor(len_labels, dtype=torch.long)}
|
| 123 |
+
|
| 124 |
+
# pass B with a random mask ratio per example (masked-diffusion objective)
|
| 125 |
+
B = [it["b"] for it in items if it["b"] is not None and (it["b"]["slot_pos"] or it["b"].get("opt_slots"))]
|
| 126 |
+
if B:
|
| 127 |
+
seqs, sb, sp, sl = [], [], [], []
|
| 128 |
+
for bi, b in enumerate(B):
|
| 129 |
+
ids, targets = _mask_slots(b, rng, v3_mask)
|
| 130 |
+
for p, t in targets:
|
| 131 |
+
sb.append(bi); sp.append(p); sl.append(t)
|
| 132 |
+
seqs.append(ids)
|
| 133 |
+
b_ids, b_attn = _pad(seqs, tok.pad_token_id)
|
| 134 |
+
batch.update({"b_ids": b_ids, "b_attn": b_attn, "slot_b": torch.tensor(sb), "slot_p": torch.tensor(sp),
|
| 135 |
+
"slot_labels": torch.tensor(sl)})
|
| 136 |
+
else:
|
| 137 |
+
batch["b_ids"] = None
|
| 138 |
+
if "q_pos" in A[0]:
|
| 139 |
+
from .v3feat import v3_extras
|
| 140 |
+
batch.update(v3_extras(A, list(enumerate(B))))
|
| 141 |
+
return batch
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _collate_cached(items, tok, rng, v3_mask="clean"):
|
| 145 |
+
"""One sequence per example: pass-A context + slot block, read with the block-causal mask."""
|
| 146 |
+
A, seqs, ctx, sb, sp, sl = [], [], [], [], [], []
|
| 147 |
+
for i, it in enumerate(items):
|
| 148 |
+
a, b = it["a"], it["b"]
|
| 149 |
+
A.append(a)
|
| 150 |
+
ctx.append(len(a["input_ids"]))
|
| 151 |
+
if b is not None and (b["slot_pos"] or b.get("opt_slots")):
|
| 152 |
+
ids, targets = _mask_slots(b, rng, v3_mask)
|
| 153 |
+
for p, t in targets:
|
| 154 |
+
sb.append(i); sp.append(p); sl.append(t)
|
| 155 |
+
seqs.append(ids)
|
| 156 |
+
else:
|
| 157 |
+
seqs.append(a["input_ids"])
|
| 158 |
+
ids, attn = _pad(seqs, tok.pad_token_id)
|
| 159 |
+
J = max(max(len(a["t_pos"]) for a in A), 1)
|
| 160 |
+
t_pos = torch.full((len(A), J), -1, dtype=torch.long)
|
| 161 |
+
for i, a in enumerate(A):
|
| 162 |
+
t_pos[i, : len(a["t_pos"])] = torch.tensor(a["t_pos"], dtype=torch.long)
|
| 163 |
+
dec = torch.tensor([[J if (l == len(a["t_pos"])) else l for l in a["dec_labels"]] for a in A])
|
| 164 |
+
len_idx, len_labels = [], []
|
| 165 |
+
for i, a in enumerate(A):
|
| 166 |
+
for (ci, j, k, b) in a["len_items"]:
|
| 167 |
+
len_idx.append((i, a["call_pos"][ci], a["arg_pos"][(j, k)]))
|
| 168 |
+
len_labels.append(b)
|
| 169 |
+
out = {"a_ids": ids, "a_attn": attn, "t_pos": t_pos, "t_mask": t_pos >= 0,
|
| 170 |
+
"call_pos": torch.tensor([a["call_pos"] for a in A]), "dec_labels": dec,
|
| 171 |
+
"len_idx": torch.tensor(len_idx, dtype=torch.long).reshape(-1, 3),
|
| 172 |
+
"len_labels": torch.tensor(len_labels, dtype=torch.long),
|
| 173 |
+
"ctx_len": torch.tensor(ctx), "b_ids": None, "slot_b": torch.tensor(sb, dtype=torch.long),
|
| 174 |
+
"slot_p": torch.tensor(sp, dtype=torch.long), "slot_labels": torch.tensor(sl, dtype=torch.long)}
|
| 175 |
+
if "q_pos" in A[0]:
|
| 176 |
+
from .v3feat import v3_extras
|
| 177 |
+
out.update(v3_extras(A, [(i, it["b"]) for i, it in enumerate(items)
|
| 178 |
+
if it["b"] is not None and (it["b"]["slot_pos"] or it["b"].get("opt_slots"))]))
|
| 179 |
+
return out
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def token_batches(lengths: list[int], tokens_per_batch: int, rng: random.Random, max_bs: int = 256):
|
| 183 |
+
"""Length-bucketed batches (sorted by length, chunked by token budget, shuffled)."""
|
| 184 |
+
order = sorted(range(len(lengths)), key=lambda i: lengths[i])
|
| 185 |
+
batches, cur, cur_max = [], [], 0
|
| 186 |
+
for i in order:
|
| 187 |
+
m = max(cur_max, lengths[i])
|
| 188 |
+
if cur and (m * (len(cur) + 1) > tokens_per_batch or len(cur) >= max_bs):
|
| 189 |
+
batches.append(cur); cur, m = [], lengths[i]
|
| 190 |
+
cur.append(i); cur_max = m
|
| 191 |
+
if cur:
|
| 192 |
+
batches.append(cur)
|
| 193 |
+
rng.shuffle(batches)
|
| 194 |
+
return batches
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def hard_negatives(examples: list[Example], n: int, rng: random.Random) -> list[Example]:
|
| 198 |
+
"""Same query, same domain: drop every tool the gold answer uses, keep its related neighbours -> no call."""
|
| 199 |
+
out = []
|
| 200 |
+
pool = [e for e in examples if e.calls and len(e.tools) > len({c.name for c in e.calls})]
|
| 201 |
+
rng.shuffle(pool)
|
| 202 |
+
for e in pool[:n]:
|
| 203 |
+
needed = {c.name for c in e.calls}
|
| 204 |
+
tools = [t for t in e.tools if t.name not in needed]
|
| 205 |
+
out.append(Example(e.query, tools, [], id=f"hardneg_{len(out)}", source="xlam_hardneg", gold=[]))
|
| 206 |
+
return out
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def irrelevance_negatives(examples: list[Example], n: int, rng: random.Random) -> list[Example]:
|
| 210 |
+
"""Pair real queries with tool lists from other examples that don't contain any needed tool."""
|
| 211 |
+
out = []
|
| 212 |
+
pool = [e for e in examples if e.tools]
|
| 213 |
+
while len(out) < n:
|
| 214 |
+
q, other = rng.choice(pool), rng.choice(pool)
|
| 215 |
+
needed = {c.name for c in q.calls}
|
| 216 |
+
tools = [t for t in other.tools if t.name not in needed]
|
| 217 |
+
if not tools or other is q:
|
| 218 |
+
continue
|
| 219 |
+
out.append(Example(q.query, tools, [], id=f"neg_{len(out)}", source="xlam_neg", gold=[]))
|
| 220 |
+
return out
|
code/actionman/features.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tokenizer setup and the two sequence builders.
|
| 2 |
+
|
| 3 |
+
Pass A: [CLS] [T] sig_1 [T] sig_2 ... [USER] query [CALL] x4 [SEP]
|
| 4 |
+
Pass B: <pass A without [SEP]> [ARGS] tool( p= [MASK]*n , q= [MASK]*m ) ... [SEP]
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from .schema import Example, signature_pieces, value_to_text
|
| 9 |
+
|
| 10 |
+
SPECIAL = ["[T]", "[CALL]", "[ARGS]", "[USER]", "[OPT]"] # [OPT]: v3 option marker (appended, ids of the rest unchanged)
|
| 11 |
+
MAX_CALLS = 4
|
| 12 |
+
BUCKETS = [0, 4, 8, 16, 32] # slot counts for bucket ids 0..4; id 5 = overflow
|
| 13 |
+
OVERFLOW = 5
|
| 14 |
+
N_BUCKETS = 6
|
| 15 |
+
MAX_LEN_A = 896
|
| 16 |
+
MAX_LEN_B = 1024
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def load_tokenizer(name: str = "answerdotai/ModernBERT-large"):
|
| 20 |
+
from transformers import AutoTokenizer
|
| 21 |
+
tok = AutoTokenizer.from_pretrained(name)
|
| 22 |
+
tok.add_special_tokens({"additional_special_tokens": SPECIAL})
|
| 23 |
+
tok.am = {s: tok.convert_tokens_to_ids(s) for s in SPECIAL}
|
| 24 |
+
return tok
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def bucket_of(n_tokens: int) -> int:
|
| 28 |
+
for b in range(1, 5):
|
| 29 |
+
if n_tokens <= BUCKETS[b]:
|
| 30 |
+
return b
|
| 31 |
+
return OVERFLOW
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _enc(tok, text: str) -> list[int]:
|
| 35 |
+
return tok(text, add_special_tokens=False).input_ids
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def value_tokens(tok, value) -> list[int]:
|
| 39 |
+
return _enc(tok, value_to_text(value))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def build_pass_a(tok, ex: Example, desc_words: int = 8, max_len: int = MAX_LEN_A) -> dict | None:
|
| 43 |
+
"""Context sequence + decision / length labels. Returns None if it can't fit."""
|
| 44 |
+
for dw in (desc_words, 0):
|
| 45 |
+
ids = [tok.cls_token_id]
|
| 46 |
+
t_pos, arg_pos = [], {}
|
| 47 |
+
for j, tool in enumerate(ex.tools):
|
| 48 |
+
t_pos.append(len(ids))
|
| 49 |
+
ids.append(tok.am["[T]"])
|
| 50 |
+
for text, kind, k in signature_pieces(tool, dw):
|
| 51 |
+
if kind == "param":
|
| 52 |
+
arg_pos[(j, k)] = len(ids)
|
| 53 |
+
ids += _enc(tok, text)
|
| 54 |
+
ids.append(tok.am["[USER]"])
|
| 55 |
+
ids += _enc(tok, ex.query)
|
| 56 |
+
call_pos = list(range(len(ids), len(ids) + MAX_CALLS))
|
| 57 |
+
ids += [tok.am["[CALL]"]] * MAX_CALLS + [tok.sep_token_id]
|
| 58 |
+
if len(ids) <= max_len:
|
| 59 |
+
break
|
| 60 |
+
else:
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
names = [t.name for t in ex.tools]
|
| 64 |
+
dec_labels, len_items = [], []
|
| 65 |
+
for i in range(MAX_CALLS):
|
| 66 |
+
if i < len(ex.calls):
|
| 67 |
+
c = ex.calls[i]
|
| 68 |
+
if c.name not in names:
|
| 69 |
+
return None
|
| 70 |
+
j = names.index(c.name)
|
| 71 |
+
dec_labels.append(j)
|
| 72 |
+
for k, p in enumerate(ex.tools[j].params):
|
| 73 |
+
b = bucket_of(len(value_tokens(tok, c.args[p.name]))) if p.name in c.args else 0
|
| 74 |
+
len_items.append((i, j, k, b))
|
| 75 |
+
else:
|
| 76 |
+
dec_labels.append(len(ex.tools)) # NONE
|
| 77 |
+
if len(ex.calls) > MAX_CALLS:
|
| 78 |
+
dec_labels = [-100] * MAX_CALLS # can't represent; train nothing on decisions
|
| 79 |
+
len_items = [it for it in len_items if it[0] < MAX_CALLS]
|
| 80 |
+
return {"input_ids": ids, "t_pos": t_pos, "call_pos": call_pos, "arg_pos": arg_pos,
|
| 81 |
+
"dec_labels": dec_labels, "len_items": len_items, "desc_words": dw}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def build_pass_b(tok, ex: Example, a: dict, plan: list[tuple[int, dict[int, int]]],
|
| 85 |
+
with_targets: bool, max_len: int = MAX_LEN_B, keep_context: bool = False,
|
| 86 |
+
markers: bool = False) -> dict | None:
|
| 87 |
+
"""Append the slot block for `plan` = [(tool_j, {param_k: bucket})] per call.
|
| 88 |
+
Buckets 1..4 get BUCKETS[b] mask slots; 0 (absent) and 5 (overflow) get none."""
|
| 89 |
+
# keep_context=True (cached variant): the full pass-A sequence is the prefix, so its states can be reused
|
| 90 |
+
ids = (a["input_ids"] if keep_context else a["input_ids"][:-1]) + [tok.am["[ARGS]"]]
|
| 91 |
+
ctx_len = len(a["input_ids"]) if keep_context else len(ids) - 1
|
| 92 |
+
slots, targets, index = [], [], []
|
| 93 |
+
for i, (j, buckets) in enumerate(plan):
|
| 94 |
+
tool = ex.tools[j]
|
| 95 |
+
# markers=True (v2): '#1 tool(' '#2 tool(' so repeated calls of the same tool are distinguishable
|
| 96 |
+
ids += _enc(tok, (f" #{i + 1} " if markers else "") + tool.name + "(")
|
| 97 |
+
first = True
|
| 98 |
+
for k, p in enumerate(tool.params):
|
| 99 |
+
b = buckets.get(k, 0)
|
| 100 |
+
if b == 0 or b == OVERFLOW:
|
| 101 |
+
continue
|
| 102 |
+
ids += _enc(tok, ("" if first else ", ") + p.name + "=")
|
| 103 |
+
first = False
|
| 104 |
+
n = BUCKETS[b]
|
| 105 |
+
pos = list(range(len(ids), len(ids) + n))
|
| 106 |
+
ids += [tok.mask_token_id] * n
|
| 107 |
+
slots.append(pos)
|
| 108 |
+
index.append((i, k))
|
| 109 |
+
if with_targets:
|
| 110 |
+
v = value_tokens(tok, ex.calls[i].args[p.name])[:n]
|
| 111 |
+
targets.append(v + [tok.pad_token_id] * (n - len(v)))
|
| 112 |
+
ids += _enc(tok, ")")
|
| 113 |
+
ids.append(tok.sep_token_id)
|
| 114 |
+
if len(ids) > max_len:
|
| 115 |
+
return None
|
| 116 |
+
out = {"input_ids": ids, "slot_pos": slots, "slot_index": index, "ctx_len": ctx_len}
|
| 117 |
+
if with_targets:
|
| 118 |
+
out["slot_targets"] = targets
|
| 119 |
+
return out
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def gold_plan(a: dict, ex: Example) -> list[tuple[int, dict[int, int]]]:
|
| 123 |
+
"""One (tool_j, {param_k: bucket}) entry per gold call, in call order - including calls to tools
|
| 124 |
+
with no parameters (an empty dict), so plan index == call index."""
|
| 125 |
+
plan = []
|
| 126 |
+
for i in range(min(len(ex.calls), MAX_CALLS)):
|
| 127 |
+
buckets = {k: b for (ci, _, k, b) in a["len_items"] if ci == i}
|
| 128 |
+
plan.append((a["dec_labels"][i], buckets))
|
| 129 |
+
return plan
|
code/actionman/infer.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inference: pass A (decide tools + arg lengths) -> slot block -> mask-predict refinement."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
from .features import BUCKETS, MAX_CALLS, OVERFLOW, SPECIAL, build_pass_a, build_pass_b
|
| 9 |
+
from .schema import Call, Example, text_to_value
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class PredCall:
|
| 14 |
+
name: str
|
| 15 |
+
args: dict = field(default_factory=dict)
|
| 16 |
+
conf_tool: float = 0.0
|
| 17 |
+
conf_args: dict = field(default_factory=dict)
|
| 18 |
+
escalated: list = field(default_factory=list)
|
| 19 |
+
|
| 20 |
+
def as_call(self) -> Call:
|
| 21 |
+
return Call(self.name, self.args)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class Prediction:
|
| 26 |
+
calls: list[PredCall]
|
| 27 |
+
passes: int = 0
|
| 28 |
+
skipped: bool = False
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _banned_ids(tok, vocab: int) -> torch.Tensor:
|
| 32 |
+
ids = [tok.mask_token_id, tok.cls_token_id, tok.sep_token_id] + [tok.am[s] for s in SPECIAL]
|
| 33 |
+
ids += list(range(len(tok), vocab))
|
| 34 |
+
return torch.tensor(sorted(set(i for i in ids if i < vocab)))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@torch.no_grad()
|
| 38 |
+
def predict(model, tok, ex: Example, T: int = 2, tau: float = 0.85, device="cpu", cached: bool = False,
|
| 39 |
+
markers: bool = False, v3: bool = False, snap: bool = False, require: bool = False) -> Prediction:
|
| 40 |
+
if v3:
|
| 41 |
+
from .infer_v3 import predict_v3
|
| 42 |
+
return predict_v3(model, tok, ex, T=T, tau=tau, device=device, cached=cached, markers=markers, snap=snap, require=require)
|
| 43 |
+
model.eval()
|
| 44 |
+
ex0 = Example(ex.query, ex.tools, [], ex.id, ex.source)
|
| 45 |
+
a = build_pass_a(tok, ex0)
|
| 46 |
+
if a is None or not ex.tools:
|
| 47 |
+
return Prediction([], 0, skipped=a is None)
|
| 48 |
+
ids = torch.tensor([a["input_ids"]], device=device)
|
| 49 |
+
if cached:
|
| 50 |
+
from .cached import encode_context, encode_slots
|
| 51 |
+
h, kv = encode_context(model.mlm.model, ids)
|
| 52 |
+
else:
|
| 53 |
+
h = model.encode(ids, torch.ones_like(ids))
|
| 54 |
+
J = len(a["t_pos"])
|
| 55 |
+
call_pos = torch.tensor([a["call_pos"]], device=device)
|
| 56 |
+
t_pos = torch.tensor([a["t_pos"]], device=device)
|
| 57 |
+
probs = model.decision_logits(h, call_pos, t_pos, torch.ones_like(t_pos, dtype=torch.bool)).float().softmax(-1)[0]
|
| 58 |
+
|
| 59 |
+
chosen = [] # (call_i, tool_j, conf)
|
| 60 |
+
for i in range(MAX_CALLS):
|
| 61 |
+
j = int(probs[i].argmax())
|
| 62 |
+
if j == J:
|
| 63 |
+
break
|
| 64 |
+
chosen.append((i, j, float(probs[i, j])))
|
| 65 |
+
if not chosen:
|
| 66 |
+
return Prediction([], 1)
|
| 67 |
+
|
| 68 |
+
rows, keys = [], []
|
| 69 |
+
for i, j, _ in chosen:
|
| 70 |
+
for k in range(len(ex.tools[j].params)):
|
| 71 |
+
rows.append((0, a["call_pos"][i], a["arg_pos"][(j, k)]))
|
| 72 |
+
keys.append((i, j, k))
|
| 73 |
+
len_logits = model.length_logits(h, torch.tensor(rows, device=device).reshape(-1, 3)).float()
|
| 74 |
+
plan, preds = [], []
|
| 75 |
+
for ci, (i, j, conf) in enumerate(chosen):
|
| 76 |
+
buckets, pc = {}, PredCall(ex.tools[j].name, conf_tool=conf)
|
| 77 |
+
for r, (ri, rj, k) in enumerate(keys):
|
| 78 |
+
if ri != i:
|
| 79 |
+
continue
|
| 80 |
+
lg = len_logits[r].clone()
|
| 81 |
+
if not ex.tools[j].params[k].optional:
|
| 82 |
+
lg[0] = float("-inf") # required params can't be absent
|
| 83 |
+
b = int(lg.argmax())
|
| 84 |
+
buckets[k] = b
|
| 85 |
+
if b == OVERFLOW:
|
| 86 |
+
pc.escalated.append(ex.tools[j].params[k].name)
|
| 87 |
+
plan.append((j, buckets))
|
| 88 |
+
preds.append(pc)
|
| 89 |
+
|
| 90 |
+
b = build_pass_b(tok, ex0, a, plan, with_targets=False, keep_context=cached, markers=markers)
|
| 91 |
+
passes = 1
|
| 92 |
+
if b is not None and b["slot_pos"]:
|
| 93 |
+
ids_b = torch.tensor([b["input_ids"]], device=device)
|
| 94 |
+
flat = [p for pos in b["slot_pos"] for p in pos]
|
| 95 |
+
fpos = torch.tensor(flat, device=device)
|
| 96 |
+
conf = torch.zeros(len(flat), device=device)
|
| 97 |
+
masked = torch.ones(len(flat), dtype=torch.bool, device=device)
|
| 98 |
+
vocab = model.mlm.decoder.weight.shape[0]
|
| 99 |
+
banned = _banned_ids(tok, vocab).to(device)
|
| 100 |
+
for t in range(T):
|
| 101 |
+
passes = t + 1
|
| 102 |
+
if cached:
|
| 103 |
+
c0 = b["ctx_len"]
|
| 104 |
+
hs = encode_slots(model.mlm.model, ids_b[:, c0:], c0, kv)[0, fpos - c0]
|
| 105 |
+
logits = model.mlm.decoder(model.mlm.head(hs)).float()
|
| 106 |
+
else:
|
| 107 |
+
logits = model.forward_b(ids_b, torch.ones_like(ids_b), torch.zeros_like(fpos), fpos).float()
|
| 108 |
+
logits[:, banned] = float("-inf")
|
| 109 |
+
p, tokens = logits.softmax(-1).max(-1)
|
| 110 |
+
conf = torch.where(masked, p, conf)
|
| 111 |
+
ids_b[0, fpos[masked]] = tokens[masked]
|
| 112 |
+
if t == T - 1:
|
| 113 |
+
break
|
| 114 |
+
masked = conf < tau
|
| 115 |
+
if not masked.any():
|
| 116 |
+
break
|
| 117 |
+
ids_b[0, fpos[masked]] = tok.mask_token_id
|
| 118 |
+
out_tokens = ids_b[0, fpos].tolist()
|
| 119 |
+
confs = conf.tolist()
|
| 120 |
+
cursor = 0
|
| 121 |
+
for (i, k), pos in zip(b["slot_index"], b["slot_pos"]):
|
| 122 |
+
n = len(pos)
|
| 123 |
+
toks, cs = out_tokens[cursor: cursor + n], confs[cursor: cursor + n]
|
| 124 |
+
cursor += n
|
| 125 |
+
if tok.pad_token_id in toks:
|
| 126 |
+
toks = toks[: toks.index(tok.pad_token_id)]
|
| 127 |
+
text = tok.decode(toks, skip_special_tokens=True).strip()
|
| 128 |
+
ci = [c[0] for c in chosen].index(i)
|
| 129 |
+
param = ex.tools[chosen[ci][1]].params[k]
|
| 130 |
+
preds[ci].args[param.name] = text_to_value(text, param.type)
|
| 131 |
+
preds[ci].conf_args[param.name] = float(min(cs)) if cs else 1.0
|
| 132 |
+
return Prediction(preds, passes + 1)
|
code/actionman/infer_v3.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""v3 inference: pass A (tools, lengths, list counts) -> slot block with option / item slots ->
|
| 2 |
+
copy-or-generate per value (decided on the first pass) -> mask-predict refinement of the generated slots."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
from .features import MAX_CALLS, OVERFLOW
|
| 10 |
+
from .infer import Prediction, PredCall, _banned_ids
|
| 11 |
+
from .schema import Example, param_options, text_to_value
|
| 12 |
+
from .span import best_span, snap_word
|
| 13 |
+
from .v3feat import build_pass_a_v3, build_pass_b_v3
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@torch.no_grad()
|
| 17 |
+
def predict_v3(model, tok, ex: Example, T: int = 2, tau: float = 0.85, device="cpu", cached: bool = False,
|
| 18 |
+
markers: bool = True, snap: bool = False, require: bool = False) -> Prediction:
|
| 19 |
+
"""snap: widen copied spans to whole words/numbers (inference-only fix for copies cut mid-word).
|
| 20 |
+
require: like tool_choice="required" -- the first call never abstains; it takes the best tool instead."""
|
| 21 |
+
model.eval()
|
| 22 |
+
ex0 = Example(ex.query, ex.tools, [], ex.id, ex.source)
|
| 23 |
+
a = build_pass_a_v3(tok, ex0)
|
| 24 |
+
if a is None or not ex.tools:
|
| 25 |
+
return Prediction([], 0, skipped=a is None)
|
| 26 |
+
ids = torch.tensor([a["input_ids"]], device=device)
|
| 27 |
+
if cached:
|
| 28 |
+
from .cached import encode_context, encode_slots
|
| 29 |
+
h, kv = encode_context(model.mlm.model, ids)
|
| 30 |
+
else:
|
| 31 |
+
h = model.encode(ids, torch.ones_like(ids))
|
| 32 |
+
J = len(a["t_pos"])
|
| 33 |
+
call_pos = torch.tensor([a["call_pos"]], device=device)
|
| 34 |
+
t_pos = torch.tensor([a["t_pos"]], device=device)
|
| 35 |
+
probs = model.decision_logits(h, call_pos, t_pos, torch.ones_like(t_pos, dtype=torch.bool)).float().softmax(-1)[0]
|
| 36 |
+
chosen = []
|
| 37 |
+
for i in range(MAX_CALLS):
|
| 38 |
+
j = int(probs[i].argmax())
|
| 39 |
+
if j == J and require and i == 0:
|
| 40 |
+
j = int(probs[i, :J].argmax())
|
| 41 |
+
if j == J:
|
| 42 |
+
break
|
| 43 |
+
chosen.append((i, j, float(probs[i, j])))
|
| 44 |
+
if not chosen:
|
| 45 |
+
return Prediction([], 1)
|
| 46 |
+
|
| 47 |
+
rows, keys = [], []
|
| 48 |
+
for i, j, _ in chosen:
|
| 49 |
+
for k in range(len(ex.tools[j].params)):
|
| 50 |
+
rows.append((0, a["call_pos"][i], a["arg_pos"][(j, k)]))
|
| 51 |
+
keys.append((i, j, k))
|
| 52 |
+
idx = torch.tensor(rows, device=device).reshape(-1, 3)
|
| 53 |
+
len_logits = model.length_logits(h, idx).float()
|
| 54 |
+
cnt_logits = model.count_logits(h, idx).float()
|
| 55 |
+
plan, preds = [], []
|
| 56 |
+
for i, j, conf in chosen:
|
| 57 |
+
modes, pc = {}, PredCall(ex.tools[j].name, conf_tool=conf)
|
| 58 |
+
for r, (ri, _, k) in enumerate(keys):
|
| 59 |
+
if ri != i:
|
| 60 |
+
continue
|
| 61 |
+
p = ex.tools[j].params[k]
|
| 62 |
+
lg = len_logits[r].clone()
|
| 63 |
+
if not p.optional:
|
| 64 |
+
lg[0] = float("-inf")
|
| 65 |
+
b = int(lg.argmax())
|
| 66 |
+
if b == 0:
|
| 67 |
+
continue
|
| 68 |
+
if param_options(p) is not None:
|
| 69 |
+
modes[k] = ("opt", 0)
|
| 70 |
+
elif p.type == "list" and int(cnt_logits[r].argmax()) >= 1:
|
| 71 |
+
modes[k] = ("list", int(cnt_logits[r].argmax()))
|
| 72 |
+
else:
|
| 73 |
+
modes[k] = ("text", b)
|
| 74 |
+
if b == OVERFLOW:
|
| 75 |
+
pc.escalated.append(p.name)
|
| 76 |
+
plan.append((j, modes))
|
| 77 |
+
preds.append(pc)
|
| 78 |
+
|
| 79 |
+
bb = build_pass_b_v3(tok, ex0, a, plan, with_targets=False, keep_context=cached, markers=markers)
|
| 80 |
+
if bb is None or not (bb["slot_pos"] or bb["opt_slots"]):
|
| 81 |
+
return Prediction(preds, 2)
|
| 82 |
+
ids_b = torch.tensor([bb["input_ids"]], device=device)
|
| 83 |
+
c0 = bb["ctx_len"]
|
| 84 |
+
|
| 85 |
+
def forward():
|
| 86 |
+
if cached:
|
| 87 |
+
hs = encode_slots(model.mlm.model, ids_b[:, c0:], c0, kv)
|
| 88 |
+
return torch.cat([h, hs], dim=1)
|
| 89 |
+
return model.encode(ids_b, torch.ones_like(ids_b))
|
| 90 |
+
|
| 91 |
+
flat = [p for pos in bb["slot_pos"] for p in pos]
|
| 92 |
+
fpos = torch.tensor(flat, dtype=torch.long, device=device)
|
| 93 |
+
conf = torch.zeros(len(flat), device=device)
|
| 94 |
+
masked = torch.ones(len(flat), dtype=torch.bool, device=device)
|
| 95 |
+
fixed = torch.zeros(len(flat), dtype=torch.bool, device=device)
|
| 96 |
+
vocab = model.mlm.decoder.weight.shape[0]
|
| 97 |
+
banned = _banned_ids(tok, vocab).to(device)
|
| 98 |
+
copied = {} # value index -> (text, conf)
|
| 99 |
+
starts = [0]
|
| 100 |
+
for pos in bb["slot_pos"]:
|
| 101 |
+
starts.append(starts[-1] + len(pos))
|
| 102 |
+
passes, hb = 0, None
|
| 103 |
+
for t in range(T):
|
| 104 |
+
passes = t + 1
|
| 105 |
+
hb = forward()
|
| 106 |
+
if t == 0 and bb["anchors"]:
|
| 107 |
+
ab = torch.zeros(len(bb["anchors"]), dtype=torch.long, device=device)
|
| 108 |
+
ap = torch.tensor(bb["anchors"], device=device)
|
| 109 |
+
qpos = torch.tensor([a["q_pos"]], device=device).expand(len(bb["anchors"]), -1)
|
| 110 |
+
g, st, en = model.copy_logits(hb, ab, ap, qpos)
|
| 111 |
+
g = g.float().sigmoid()
|
| 112 |
+
for v in range(len(bb["anchors"])):
|
| 113 |
+
if g[v] <= 0.5 or not a["q_pos"]:
|
| 114 |
+
continue
|
| 115 |
+
s, e, pspan = best_span(st[v], en[v])
|
| 116 |
+
cs, ce = a["q_offs"][s][0], a["q_offs"][e][1]
|
| 117 |
+
if snap:
|
| 118 |
+
cs, ce = snap_word(ex.query, cs, ce)
|
| 119 |
+
text = ex.query[cs: ce].strip()
|
| 120 |
+
if not text:
|
| 121 |
+
continue
|
| 122 |
+
copied[v] = (text, float(g[v]) * pspan)
|
| 123 |
+
n = len(bb["slot_pos"][v])
|
| 124 |
+
span_ids = [a["input_ids"][q] for q in a["q_pos"][s: e + 1]][:n]
|
| 125 |
+
span_ids += [tok.pad_token_id] * (n - len(span_ids))
|
| 126 |
+
sl = slice(starts[v], starts[v + 1])
|
| 127 |
+
ids_b[0, fpos[sl]] = torch.tensor(span_ids, device=device)
|
| 128 |
+
fixed[sl] = True
|
| 129 |
+
if not flat:
|
| 130 |
+
break
|
| 131 |
+
logits = model.mlm.decoder(model.mlm.head(hb[0, fpos])).float()
|
| 132 |
+
logits[:, banned] = float("-inf")
|
| 133 |
+
p, tokens = logits.softmax(-1).max(-1)
|
| 134 |
+
upd = masked & ~fixed
|
| 135 |
+
conf = torch.where(upd, p, conf)
|
| 136 |
+
ids_b[0, fpos[upd]] = tokens[upd]
|
| 137 |
+
conf = torch.where(fixed, torch.ones_like(conf), conf)
|
| 138 |
+
if t == T - 1:
|
| 139 |
+
break
|
| 140 |
+
masked = (conf < tau) & ~fixed
|
| 141 |
+
if not masked.any():
|
| 142 |
+
break
|
| 143 |
+
ids_b[0, fpos[masked]] = tok.mask_token_id
|
| 144 |
+
|
| 145 |
+
ci_of = {c[0]: n for n, c in enumerate(chosen)}
|
| 146 |
+
out_tokens, confs = ids_b[0, fpos].tolist() if flat else [], conf.tolist()
|
| 147 |
+
items = defaultdict(list) # (call, param) -> [(text, conf)] for list items
|
| 148 |
+
for v, ((i, k, item), pos) in enumerate(zip(bb["slot_index"], bb["slot_pos"])):
|
| 149 |
+
if v in copied:
|
| 150 |
+
text, cv = copied[v]
|
| 151 |
+
else:
|
| 152 |
+
toks = out_tokens[starts[v]: starts[v + 1]]
|
| 153 |
+
if tok.pad_token_id in toks:
|
| 154 |
+
toks = toks[: toks.index(tok.pad_token_id)]
|
| 155 |
+
text = tok.decode(toks, skip_special_tokens=True).strip()
|
| 156 |
+
cs = confs[starts[v]: starts[v + 1]]
|
| 157 |
+
cv = float(min(cs)) if cs else 1.0
|
| 158 |
+
ci = ci_of[i]
|
| 159 |
+
param = ex.tools[chosen[ci][1]].params[k]
|
| 160 |
+
if item is None:
|
| 161 |
+
preds[ci].args[param.name] = text_to_value(text, param.type)
|
| 162 |
+
preds[ci].conf_args[param.name] = cv
|
| 163 |
+
else:
|
| 164 |
+
items[(ci, param.name)].append((text, cv))
|
| 165 |
+
for (ci, name), vals in items.items():
|
| 166 |
+
preds[ci].args[name] = [text_to_value(t, "any") for t, _ in vals]
|
| 167 |
+
preds[ci].conf_args[name] = min(c for _, c in vals)
|
| 168 |
+
if bb["opt_slots"]:
|
| 169 |
+
ob = torch.zeros(len(bb["opt_slots"]), dtype=torch.long, device=device)
|
| 170 |
+
oa = torch.tensor([s[2] for s in bb["opt_slots"]], device=device)
|
| 171 |
+
M = max(len(s[3]) for s in bb["opt_slots"])
|
| 172 |
+
opos = torch.full((len(bb["opt_slots"]), M), -1, dtype=torch.long, device=device)
|
| 173 |
+
for n, s in enumerate(bb["opt_slots"]):
|
| 174 |
+
opos[n, : len(s[3])] = torch.tensor(s[3], device=device)
|
| 175 |
+
po = model.option_logits(hb, ob, oa, opos).float().softmax(-1)
|
| 176 |
+
for n, (i, k, _, _, _) in enumerate(bb["opt_slots"]):
|
| 177 |
+
ci = ci_of[i]
|
| 178 |
+
param = ex.tools[chosen[ci][1]].params[k]
|
| 179 |
+
m = int(po[n].argmax())
|
| 180 |
+
preds[ci].args[param.name] = param_options(param)[m]
|
| 181 |
+
preds[ci].conf_args[param.name] = float(po[n, m])
|
| 182 |
+
return Prediction(preds, passes + 1)
|
code/actionman/model.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ActionMan: ModernBERT encoder + MLM head (slot filler) + decision head + length head."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import math
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
import torch.nn.functional as F
|
| 9 |
+
|
| 10 |
+
from .features import N_BUCKETS
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ActionMan(nn.Module):
|
| 14 |
+
def __init__(self, mlm, v3: bool = False):
|
| 15 |
+
super().__init__()
|
| 16 |
+
self.mlm = mlm
|
| 17 |
+
d = mlm.config.hidden_size
|
| 18 |
+
self.q = nn.Linear(d, d)
|
| 19 |
+
self.k = nn.Linear(d, d)
|
| 20 |
+
self.none = nn.Linear(d, 1)
|
| 21 |
+
self.len_head = nn.Sequential(nn.Linear(3 * d, d), nn.GELU(), nn.Linear(d, N_BUCKETS))
|
| 22 |
+
self.scale = 1.0 / math.sqrt(d)
|
| 23 |
+
self.v3 = v3
|
| 24 |
+
if v3: # copy switch + span pointer, option picker, list item count
|
| 25 |
+
from .v3feat import N_COUNT
|
| 26 |
+
self.gate = nn.Linear(d, 1)
|
| 27 |
+
self.sq, self.sk, self.eq, self.ek = nn.Linear(d, d), nn.Linear(d, d), nn.Linear(d, d), nn.Linear(d, d)
|
| 28 |
+
self.opt_q, self.opt_k = nn.Linear(d, d), nn.Linear(d, d)
|
| 29 |
+
self.count_head = nn.Sequential(nn.Linear(3 * d, d), nn.GELU(), nn.Linear(d, N_COUNT))
|
| 30 |
+
|
| 31 |
+
@classmethod
|
| 32 |
+
def from_encoder(cls, name_or_config, tok, attn_impl: str | None = None, v3: bool = False):
|
| 33 |
+
from transformers import ModernBertConfig, ModernBertForMaskedLM
|
| 34 |
+
if isinstance(name_or_config, ModernBertConfig):
|
| 35 |
+
mlm = ModernBertForMaskedLM(name_or_config)
|
| 36 |
+
else:
|
| 37 |
+
kw = {"attn_implementation": attn_impl} if attn_impl else {}
|
| 38 |
+
mlm = ModernBertForMaskedLM.from_pretrained(name_or_config, **kw)
|
| 39 |
+
mlm.resize_token_embeddings(len(tok), pad_to_multiple_of=64, mean_resizing=False)
|
| 40 |
+
dec = mlm.decoder
|
| 41 |
+
if dec.bias is not None and dec.bias.shape[0] != dec.weight.shape[0]:
|
| 42 |
+
bias = torch.zeros(dec.weight.shape[0], dtype=dec.bias.dtype)
|
| 43 |
+
bias[: dec.bias.shape[0]] = dec.bias.data
|
| 44 |
+
dec.bias = nn.Parameter(bias)
|
| 45 |
+
dec.out_features = dec.weight.shape[0]
|
| 46 |
+
return cls(mlm, v3=v3)
|
| 47 |
+
|
| 48 |
+
def head_params(self):
|
| 49 |
+
return [p for n, p in self.named_parameters() if not n.startswith("mlm.")]
|
| 50 |
+
|
| 51 |
+
def encode(self, ids, attn):
|
| 52 |
+
return self.mlm.model(input_ids=ids, attention_mask=attn).last_hidden_state
|
| 53 |
+
|
| 54 |
+
def decision_logits(self, h, call_pos, t_pos, t_mask):
|
| 55 |
+
bi = torch.arange(h.shape[0], device=h.device)[:, None]
|
| 56 |
+
call_h = h[bi, call_pos] # [B,4,d]
|
| 57 |
+
t_h = h[bi, t_pos.clamp(min=0)] # [B,J,d]
|
| 58 |
+
logits = torch.einsum("bid,bjd->bij", self.q(call_h), self.k(t_h)) * self.scale
|
| 59 |
+
logits = logits.masked_fill(~t_mask[:, None, :], float("-inf"))
|
| 60 |
+
return torch.cat([logits, self.none(call_h)], dim=-1) # [B,4,J+1]; NONE = J
|
| 61 |
+
|
| 62 |
+
def length_logits(self, h, len_idx):
|
| 63 |
+
"""len_idx: [N,3] rows of (batch index, [CALL] position, param-name position)."""
|
| 64 |
+
if not len_idx.numel():
|
| 65 |
+
return h.new_zeros(0, N_BUCKETS)
|
| 66 |
+
hc, ha = h[len_idx[:, 0], len_idx[:, 1]], h[len_idx[:, 0], len_idx[:, 2]]
|
| 67 |
+
return self.len_head(torch.cat([hc, ha, hc * ha], dim=-1))
|
| 68 |
+
|
| 69 |
+
def forward_a(self, batch):
|
| 70 |
+
h = self.encode(batch["a_ids"], batch["a_attn"]) # [B,L,d]
|
| 71 |
+
dec = self.decision_logits(h, batch["call_pos"], batch["t_pos"], batch["t_mask"])
|
| 72 |
+
return dec, self.length_logits(h, batch["len_idx"])
|
| 73 |
+
|
| 74 |
+
def forward_b(self, ids, attn, slot_b, slot_p):
|
| 75 |
+
h = self.encode(ids, attn)
|
| 76 |
+
hs = h[slot_b, slot_p]
|
| 77 |
+
return self.mlm.decoder(self.mlm.head(hs)) # [M,V]
|
| 78 |
+
|
| 79 |
+
# ---------- v3 heads ----------
|
| 80 |
+
|
| 81 |
+
def copy_logits(self, h, ab, ap, qpos):
|
| 82 |
+
"""Anchors (batch ab, pos ap) [N]; qpos [N,Q] query positions (-1 = pad). -> gate [N], start/end [N,Q]."""
|
| 83 |
+
ha = h[ab, ap]
|
| 84 |
+
hq = h[ab[:, None], qpos.clamp(min=0)]
|
| 85 |
+
pad = qpos < 0
|
| 86 |
+
st = torch.einsum("nd,nqd->nq", self.sq(ha), self.sk(hq)) * self.scale
|
| 87 |
+
en = torch.einsum("nd,nqd->nq", self.eq(ha), self.ek(hq)) * self.scale
|
| 88 |
+
return self.gate(ha).squeeze(-1), st.masked_fill(pad, -1e4), en.masked_fill(pad, -1e4)
|
| 89 |
+
|
| 90 |
+
def option_logits(self, h, ob, oa, opos):
|
| 91 |
+
"""Option anchors (ob, oa) [N]; opos [N,M] positions of their [OPT] markers (-1 = pad) -> [N,M]."""
|
| 92 |
+
ho = h[ob[:, None], opos.clamp(min=0)]
|
| 93 |
+
lg = torch.einsum("nd,nmd->nm", self.opt_q(h[ob, oa]), self.opt_k(ho)) * self.scale
|
| 94 |
+
return lg.masked_fill(opos < 0, -1e4)
|
| 95 |
+
|
| 96 |
+
def count_logits(self, h, idx):
|
| 97 |
+
if not idx.numel():
|
| 98 |
+
return h.new_zeros(0, self.count_head[-1].out_features)
|
| 99 |
+
hc, ha = h[idx[:, 0], idx[:, 1]], h[idx[:, 0], idx[:, 2]]
|
| 100 |
+
return self.count_head(torch.cat([hc, ha, hc * ha], dim=-1))
|
| 101 |
+
|
| 102 |
+
def v3_losses(self, h_b, h_a, batch):
|
| 103 |
+
z = h_a.sum() * 0
|
| 104 |
+
l_gate = l_span = l_opt = l_cnt = z
|
| 105 |
+
if batch["anchor_b"].numel():
|
| 106 |
+
g, st, en = self.copy_logits(h_b, batch["anchor_b"], batch["anchor_p"], batch["qpos"][batch["anchor_b"]])
|
| 107 |
+
l_gate = F.binary_cross_entropy_with_logits(g.float(), batch["gate_y"])
|
| 108 |
+
sel = batch["span_s"] >= 0
|
| 109 |
+
if sel.any():
|
| 110 |
+
l_span = 0.5 * (F.cross_entropy(st[sel].float(), batch["span_s"][sel]) +
|
| 111 |
+
F.cross_entropy(en[sel].float(), batch["span_e"][sel]))
|
| 112 |
+
valid = batch["opt_y"] >= 0
|
| 113 |
+
if valid.any():
|
| 114 |
+
lo = self.option_logits(h_b, batch["opt_b"][valid], batch["opt_anchor"][valid], batch["opt_pos"][valid])
|
| 115 |
+
l_opt = F.cross_entropy(lo.float(), batch["opt_y"][valid])
|
| 116 |
+
if batch["count_idx"].numel():
|
| 117 |
+
l_cnt = F.cross_entropy(self.count_logits(h_a, batch["count_idx"]).float(), batch["count_y"])
|
| 118 |
+
parts = {"gate": l_gate.item(), "span": l_span.item(), "opt": l_opt.item(), "count": l_cnt.item()}
|
| 119 |
+
return l_gate + l_span + l_opt + l_cnt, parts
|
| 120 |
+
|
| 121 |
+
def loss_v3(self, batch):
|
| 122 |
+
"""Standard-mask v3: pass A and pass B are separate forwards."""
|
| 123 |
+
h_a = self.encode(batch["a_ids"], batch["a_attn"])
|
| 124 |
+
dec_logits = self.decision_logits(h_a, batch["call_pos"], batch["t_pos"], batch["t_mask"])
|
| 125 |
+
len_logits = self.length_logits(h_a, batch["len_idx"])
|
| 126 |
+
l_dec = F.cross_entropy(dec_logits.flatten(0, 1).float(), batch["dec_labels"].flatten(), ignore_index=-100)
|
| 127 |
+
l_len = F.cross_entropy(len_logits.float(), batch["len_labels"]) if len_logits.shape[0] else h_a.sum() * 0
|
| 128 |
+
l_slot = h_a.sum() * 0
|
| 129 |
+
h_b = h_a
|
| 130 |
+
if batch.get("b_ids") is not None:
|
| 131 |
+
h_b = self.encode(batch["b_ids"], batch["b_attn"])
|
| 132 |
+
if batch["slot_b"].numel():
|
| 133 |
+
hs = h_b[batch["slot_b"], batch["slot_p"]]
|
| 134 |
+
l_slot = F.cross_entropy(self.mlm.decoder(self.mlm.head(hs)).float(), batch["slot_labels"])
|
| 135 |
+
if batch.get("b_ids") is None:
|
| 136 |
+
batch = {**batch, "anchor_b": batch["anchor_b"][:0], "opt_y": batch["opt_y"][:0]}
|
| 137 |
+
l_v3, p3 = self.v3_losses(h_b, h_a, batch)
|
| 138 |
+
return l_dec + l_len + l_slot + l_v3, {"dec": l_dec.item(), "len": l_len.item(), "slot": l_slot.item(), **p3}
|
| 139 |
+
|
| 140 |
+
def loss(self, batch):
|
| 141 |
+
if batch.get("ctx_len") is not None:
|
| 142 |
+
return self.loss_cached(batch)
|
| 143 |
+
if self.v3:
|
| 144 |
+
return self.loss_v3(batch)
|
| 145 |
+
dec_logits, len_logits = self.forward_a(batch)
|
| 146 |
+
l_dec = F.cross_entropy(dec_logits.flatten(0, 1).float(), batch["dec_labels"].flatten(), ignore_index=-100)
|
| 147 |
+
l_len = (F.cross_entropy(len_logits.float(), batch["len_labels"]) if len_logits.shape[0]
|
| 148 |
+
else dec_logits.sum() * 0)
|
| 149 |
+
if batch.get("b_ids") is not None and batch["slot_b"].numel():
|
| 150 |
+
slot_logits = self.forward_b(batch["b_ids"], batch["b_attn"], batch["slot_b"], batch["slot_p"])
|
| 151 |
+
l_slot = F.cross_entropy(slot_logits.float(), batch["slot_labels"])
|
| 152 |
+
else:
|
| 153 |
+
l_slot = dec_logits.sum() * 0
|
| 154 |
+
total = l_dec + l_len + l_slot
|
| 155 |
+
return total, {"dec": l_dec.item(), "len": l_len.item(), "slot": l_slot.item()}
|
| 156 |
+
|
| 157 |
+
def loss_cached(self, batch):
|
| 158 |
+
"""Block-causal variant: one forward; heads read context states, which never see the slots."""
|
| 159 |
+
from .cached import block_masks
|
| 160 |
+
masks = block_masks(batch["a_attn"], batch["ctx_len"], self.mlm.config.sliding_window)
|
| 161 |
+
h = self.mlm.model(input_ids=batch["a_ids"], attention_mask=masks).last_hidden_state
|
| 162 |
+
dec_logits = self.decision_logits(h, batch["call_pos"], batch["t_pos"], batch["t_mask"])
|
| 163 |
+
len_logits = self.length_logits(h, batch["len_idx"])
|
| 164 |
+
l_dec = F.cross_entropy(dec_logits.flatten(0, 1).float(), batch["dec_labels"].flatten(), ignore_index=-100)
|
| 165 |
+
l_len = F.cross_entropy(len_logits.float(), batch["len_labels"]) if len_logits.shape[0] else h.sum() * 0
|
| 166 |
+
if batch["slot_b"].numel():
|
| 167 |
+
hs = h[batch["slot_b"], batch["slot_p"]]
|
| 168 |
+
l_slot = F.cross_entropy(self.mlm.decoder(self.mlm.head(hs)).float(), batch["slot_labels"])
|
| 169 |
+
else:
|
| 170 |
+
l_slot = h.sum() * 0
|
| 171 |
+
parts = {"dec": l_dec.item(), "len": l_len.item(), "slot": l_slot.item()}
|
| 172 |
+
total = l_dec + l_len + l_slot
|
| 173 |
+
if self.v3:
|
| 174 |
+
l_v3, p3 = self.v3_losses(h, h, batch)
|
| 175 |
+
total, parts = total + l_v3, {**parts, **p3}
|
| 176 |
+
return total, parts
|
code/actionman/schema.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified tool/call schema, converters from xLAM and BFCL, compact signatures, value <-> text."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import ast
|
| 5 |
+
import json
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
TYPES = ("str", "int", "float", "bool", "list", "dict", "any")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class Param:
|
| 14 |
+
name: str
|
| 15 |
+
type: str = "any"
|
| 16 |
+
optional: bool = False
|
| 17 |
+
default: Any = None
|
| 18 |
+
description: str = ""
|
| 19 |
+
options: list | None = None # declared enum values (v3 option slots)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class Tool:
|
| 24 |
+
name: str
|
| 25 |
+
description: str = ""
|
| 26 |
+
params: list[Param] = field(default_factory=list)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class Call:
|
| 31 |
+
name: str
|
| 32 |
+
args: dict[str, Any] = field(default_factory=dict)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class Example:
|
| 37 |
+
query: str
|
| 38 |
+
tools: list[Tool]
|
| 39 |
+
calls: list[Call]
|
| 40 |
+
id: str = ""
|
| 41 |
+
source: str = ""
|
| 42 |
+
# gold in BFCL "possible answer" form: [{func: {arg: [acceptable values]}}]; "" = may be omitted
|
| 43 |
+
gold: list[dict] = field(default_factory=list)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ---------- type normalisation ----------
|
| 47 |
+
|
| 48 |
+
def norm_type(t: Any) -> tuple[str, bool]:
|
| 49 |
+
"""Map xLAM ('str, optional', 'List[int]') or JSON-schema ('integer') types to (TYPE, optional)."""
|
| 50 |
+
s = str(t or "any").strip()
|
| 51 |
+
low = s.lower()
|
| 52 |
+
optional = "optional" in low
|
| 53 |
+
head = low.split(",")[0].split("[")[0].strip()
|
| 54 |
+
table = {
|
| 55 |
+
"str": "str", "string": "str",
|
| 56 |
+
"int": "int", "integer": "int",
|
| 57 |
+
"float": "float", "number": "float", "double": "float",
|
| 58 |
+
"bool": "bool", "boolean": "bool",
|
| 59 |
+
"list": "list", "array": "list", "tuple": "list", "set": "list",
|
| 60 |
+
"dict": "dict", "object": "dict",
|
| 61 |
+
}
|
| 62 |
+
return table.get(head, "any"), optional
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------- converters ----------
|
| 66 |
+
|
| 67 |
+
def from_xlam(row: dict) -> Example:
|
| 68 |
+
tools_raw = json.loads(row["tools"]) if isinstance(row["tools"], str) else row["tools"]
|
| 69 |
+
answers = json.loads(row["answers"]) if isinstance(row["answers"], str) else row["answers"]
|
| 70 |
+
tools = []
|
| 71 |
+
for t in tools_raw:
|
| 72 |
+
params = []
|
| 73 |
+
for pname, spec in (t.get("parameters") or {}).items():
|
| 74 |
+
spec = spec or {}
|
| 75 |
+
ty, opt = norm_type(spec.get("type"))
|
| 76 |
+
default = spec.get("default")
|
| 77 |
+
if default == "":
|
| 78 |
+
default = None
|
| 79 |
+
params.append(Param(pname, ty, opt, default, spec.get("description", "") or ""))
|
| 80 |
+
tools.append(Tool(t["name"], t.get("description", "") or "", params))
|
| 81 |
+
calls = [Call(a["name"], dict(a.get("arguments") or {})) for a in answers]
|
| 82 |
+
ex = Example(row["query"], tools, calls, id=f"xlam_{row.get('id', '')}", source="xlam")
|
| 83 |
+
ex.gold = gold_from_calls(tools, calls)
|
| 84 |
+
return ex
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def from_bfcl(q_row: dict, answer_row: dict | None, category: str) -> Example:
|
| 88 |
+
query = q_row["question"][0][-1]["content"] if q_row["question"] and q_row["question"][0] else ""
|
| 89 |
+
tools = []
|
| 90 |
+
for f in q_row["function"]:
|
| 91 |
+
props = (f.get("parameters") or {}).get("properties", {}) or {}
|
| 92 |
+
required = set((f.get("parameters") or {}).get("required", []) or [])
|
| 93 |
+
params = []
|
| 94 |
+
for pname, spec in props.items():
|
| 95 |
+
ty, _ = norm_type(spec.get("type"))
|
| 96 |
+
params.append(Param(pname, ty, pname not in required, spec.get("default"), spec.get("description", "") or "",
|
| 97 |
+
spec.get("enum") or None))
|
| 98 |
+
tools.append(Tool(f["name"], f.get("description", "") or "", params))
|
| 99 |
+
gold = answer_row["ground_truth"] if answer_row else []
|
| 100 |
+
calls = []
|
| 101 |
+
for g in gold:
|
| 102 |
+
(name, args), = g.items()
|
| 103 |
+
calls.append(Call(name, {k: v[0] for k, v in args.items() if v and v[0] != ""}))
|
| 104 |
+
return Example(query, tools, calls, id=q_row["id"], source=f"bfcl_{category}", gold=gold)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def gold_from_calls(tools: list[Tool], calls: list[Call]) -> list[dict]:
|
| 108 |
+
"""Build BFCL-style acceptable-value gold from exact xLAM answers."""
|
| 109 |
+
by_name = {t.name: t for t in tools}
|
| 110 |
+
gold = []
|
| 111 |
+
for c in calls:
|
| 112 |
+
accept: dict[str, list] = {k: [v] for k, v in c.args.items()}
|
| 113 |
+
tool = by_name.get(c.name)
|
| 114 |
+
if tool:
|
| 115 |
+
for p in tool.params:
|
| 116 |
+
if p.name in accept:
|
| 117 |
+
if p.optional and p.default is not None and _same(accept[p.name][0], p.default):
|
| 118 |
+
accept[p.name].append("")
|
| 119 |
+
elif p.optional or p.default is not None:
|
| 120 |
+
accept[p.name] = [""] + ([p.default] if p.default is not None else [])
|
| 121 |
+
gold.append({c.name: accept})
|
| 122 |
+
return gold
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _same(a: Any, b: Any) -> bool:
|
| 126 |
+
try:
|
| 127 |
+
return a == b or float(a) == float(b)
|
| 128 |
+
except (TypeError, ValueError):
|
| 129 |
+
return str(a) == str(b)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
MAX_OPTIONS = 16
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def param_options(p: Param) -> list | None:
|
| 136 |
+
"""Values a v3 option slot picks from: bools -> [True, False], declared enums of <= MAX_OPTIONS, else None."""
|
| 137 |
+
if p.type == "bool":
|
| 138 |
+
return [True, False]
|
| 139 |
+
if p.options and len(p.options) <= MAX_OPTIONS:
|
| 140 |
+
return list(p.options)
|
| 141 |
+
return None
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ---------- compact signature ----------
|
| 145 |
+
|
| 146 |
+
def _trim(text: str, words: int) -> str:
|
| 147 |
+
w = " ".join(str(text).split()).split(" ")
|
| 148 |
+
return " ".join(w[:words]) if words > 0 else ""
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def signature_pieces(tool: Tool, desc_words: int = 8) -> list[tuple[str, str, int]]:
|
| 152 |
+
"""Pieces of `name(p:type=default "desc", q?:int) | description`.
|
| 153 |
+
Each piece is (text, kind, param_index); kind in {'name', 'param', 'text'}.
|
| 154 |
+
The 'param' piece is exactly the parameter name so its first token can be located."""
|
| 155 |
+
pieces: list[tuple[str, str, int]] = [(tool.name, "name", -1), ("(", "text", -1)]
|
| 156 |
+
for k, p in enumerate(tool.params):
|
| 157 |
+
if k:
|
| 158 |
+
pieces.append((", ", "text", -1))
|
| 159 |
+
pieces.append((p.name, "param", k))
|
| 160 |
+
spec = ("?" if p.optional else "") + ":" + p.type
|
| 161 |
+
if p.default is not None:
|
| 162 |
+
spec += "=" + value_to_text(p.default)
|
| 163 |
+
d = _trim(p.description, desc_words)
|
| 164 |
+
if d:
|
| 165 |
+
spec += f' "{d}"'
|
| 166 |
+
pieces.append((spec, "text", -1))
|
| 167 |
+
pieces.append((")", "text", -1))
|
| 168 |
+
d = _trim(tool.description, max(desc_words + 4, 0) if desc_words > 0 else 0)
|
| 169 |
+
if d:
|
| 170 |
+
pieces.append((" | " + d, "text", -1))
|
| 171 |
+
return pieces
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def signature(tool: Tool, desc_words: int = 8) -> str:
|
| 175 |
+
return "".join(t for t, _, _ in signature_pieces(tool, desc_words))
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ---------- values ----------
|
| 179 |
+
|
| 180 |
+
def value_to_text(v: Any) -> str:
|
| 181 |
+
if isinstance(v, str):
|
| 182 |
+
return v
|
| 183 |
+
if isinstance(v, bool):
|
| 184 |
+
return "true" if v else "false"
|
| 185 |
+
return json.dumps(v, ensure_ascii=False)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def text_to_value(s: str, ty: str) -> Any:
|
| 189 |
+
s = s.strip()
|
| 190 |
+
if ty == "str":
|
| 191 |
+
return s
|
| 192 |
+
if ty == "bool":
|
| 193 |
+
return s.lower() in ("true", "1", "yes")
|
| 194 |
+
if ty in ("int", "float"):
|
| 195 |
+
try:
|
| 196 |
+
f = float(s)
|
| 197 |
+
if ty == "int" and f.is_integer():
|
| 198 |
+
return int(f)
|
| 199 |
+
return f
|
| 200 |
+
except ValueError:
|
| 201 |
+
pass
|
| 202 |
+
for parse in (json.loads, ast.literal_eval):
|
| 203 |
+
try:
|
| 204 |
+
return parse(s)
|
| 205 |
+
except Exception:
|
| 206 |
+
continue
|
| 207 |
+
return s
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ---------- OpenAI-style tool (for the LLM baseline) ----------
|
| 211 |
+
|
| 212 |
+
_JSON_TYPES = {"str": "string", "int": "integer", "float": "number", "bool": "boolean",
|
| 213 |
+
"list": "array", "dict": "object", "any": "string"}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def from_openai_tool(t: dict) -> Tool:
|
| 217 |
+
"""OpenAI-style tool ({"type": "function", "function": {...}} or the bare function dict) -> Tool."""
|
| 218 |
+
f = t.get("function", t)
|
| 219 |
+
ps = f.get("parameters") or {}
|
| 220 |
+
req = set(ps.get("required", []))
|
| 221 |
+
params = []
|
| 222 |
+
for name, s in (ps.get("properties") or {}).items():
|
| 223 |
+
ty, _ = norm_type(s.get("type"))
|
| 224 |
+
params.append(Param(name, ty, name not in req, s.get("default"), s.get("description", "") or "", s.get("enum")))
|
| 225 |
+
return Tool(f["name"], f.get("description", "") or "", params)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def to_openai_tool(tool: Tool) -> dict:
|
| 229 |
+
props = {}
|
| 230 |
+
for p in tool.params:
|
| 231 |
+
spec = {"type": _JSON_TYPES[p.type], "description": p.description}
|
| 232 |
+
if p.default is not None:
|
| 233 |
+
spec["default"] = p.default
|
| 234 |
+
props[p.name] = spec
|
| 235 |
+
return {"type": "function", "function": {
|
| 236 |
+
"name": tool.name, "description": tool.description,
|
| 237 |
+
"parameters": {"type": "object", "properties": props,
|
| 238 |
+
"required": [p.name for p in tool.params if not p.optional]}}}
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# ---------- (de)serialisation for jsonl ----------
|
| 242 |
+
|
| 243 |
+
def example_to_dict(ex: Example) -> dict:
|
| 244 |
+
return {"id": ex.id, "source": ex.source, "query": ex.query, "gold": ex.gold,
|
| 245 |
+
"tools": [{"name": t.name, "description": t.description,
|
| 246 |
+
"params": [vars(p) for p in t.params]} for t in ex.tools],
|
| 247 |
+
"calls": [vars(c) for c in ex.calls]}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def example_from_dict(d: dict) -> Example:
|
| 251 |
+
tools = [Tool(t["name"], t["description"], [Param(**p) for p in t["params"]]) for t in d["tools"]]
|
| 252 |
+
return Example(d["query"], tools, [Call(**c) for c in d["calls"]], d["id"], d["source"], d.get("gold", []))
|
code/actionman/scoring.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BFCL-style AST matching (re-implemented, simplified) for predicted calls vs acceptable-value gold.
|
| 2 |
+
|
| 3 |
+
gold: [{func_name: {arg: [acceptable values]}}]; "" in the acceptable list means the arg may be omitted.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import itertools
|
| 8 |
+
import re
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from .schema import Call
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _std(s: str) -> str:
|
| 15 |
+
return re.sub(r"[ ,./\-_*^]", "", str(s)).lower().replace("'", '"')
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _num(x):
|
| 19 |
+
if isinstance(x, bool):
|
| 20 |
+
return None
|
| 21 |
+
if isinstance(x, (int, float)):
|
| 22 |
+
return float(x)
|
| 23 |
+
if isinstance(x, str):
|
| 24 |
+
try:
|
| 25 |
+
return float(x)
|
| 26 |
+
except ValueError:
|
| 27 |
+
return None
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def value_match(pred: Any, gold: Any) -> bool:
|
| 32 |
+
if isinstance(gold, bool) or isinstance(pred, bool):
|
| 33 |
+
if isinstance(pred, str):
|
| 34 |
+
return pred.strip().lower() == str(gold).lower()
|
| 35 |
+
return pred == gold
|
| 36 |
+
gn, pn = _num(gold), _num(pred)
|
| 37 |
+
if gn is not None and not isinstance(gold, str):
|
| 38 |
+
return pn is not None and abs(pn - gn) < 1e-6
|
| 39 |
+
if isinstance(gold, str):
|
| 40 |
+
return _std(pred) == _std(gold) if not isinstance(pred, (list, dict)) else False
|
| 41 |
+
if isinstance(gold, (list, tuple)):
|
| 42 |
+
return isinstance(pred, (list, tuple)) and len(pred) == len(gold) and all(
|
| 43 |
+
value_match(p, g) for p, g in zip(pred, gold))
|
| 44 |
+
if isinstance(gold, dict):
|
| 45 |
+
if not isinstance(pred, dict):
|
| 46 |
+
return False
|
| 47 |
+
for k, gv in gold.items():
|
| 48 |
+
accept = gv if isinstance(gv, list) else [gv]
|
| 49 |
+
if k not in pred:
|
| 50 |
+
if "" not in accept:
|
| 51 |
+
return False
|
| 52 |
+
continue
|
| 53 |
+
if not any(value_match(pred[k], a) for a in accept if a != ""):
|
| 54 |
+
return False
|
| 55 |
+
return set(pred) <= set(gold)
|
| 56 |
+
return pred == gold
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def call_match(pred: Call, gold_call: dict) -> bool:
|
| 60 |
+
(name, accept), = gold_call.items()
|
| 61 |
+
if pred.name != name:
|
| 62 |
+
return False
|
| 63 |
+
for k, v in pred.args.items():
|
| 64 |
+
if k not in accept:
|
| 65 |
+
return False
|
| 66 |
+
if not any(value_match(v, a) for a in accept[k] if not (isinstance(a, str) and a == "")):
|
| 67 |
+
if not ("" in accept[k] and v in ("", None)):
|
| 68 |
+
return False
|
| 69 |
+
for k, vals in accept.items():
|
| 70 |
+
if k not in pred.args and "" not in vals:
|
| 71 |
+
return False
|
| 72 |
+
return True
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def match_calls(pred: list[Call], gold: list[dict]) -> bool:
|
| 76 |
+
if len(pred) != len(gold):
|
| 77 |
+
return False
|
| 78 |
+
if not gold:
|
| 79 |
+
return True
|
| 80 |
+
for perm in itertools.permutations(range(len(pred))):
|
| 81 |
+
if all(call_match(pred[perm[g]], gold[g]) for g in range(len(gold))):
|
| 82 |
+
return True
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def names_match(pred: list[Call], gold: list[dict]) -> bool:
|
| 87 |
+
return sorted(c.name for c in pred) == sorted(next(iter(g)) for g in gold)
|
code/actionman/span.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Span pointer decoding for the v3 copy head."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def best_span(start: torch.Tensor, end: torch.Tensor, max_len: int = 32):
|
| 8 |
+
"""Highest p(start) * p(end) with start <= end < start + max_len -> (s, e, prob)."""
|
| 9 |
+
ps, pe = start.float().softmax(-1), end.float().softmax(-1)
|
| 10 |
+
Q = ps.shape[0]
|
| 11 |
+
i = torch.arange(Q, device=ps.device)
|
| 12 |
+
ok = (i[None, :] >= i[:, None]) & (i[None, :] < i[:, None] + max_len)
|
| 13 |
+
score = (ps[:, None] * pe[None, :]).masked_fill(~ok, 0)
|
| 14 |
+
flat = int(score.argmax())
|
| 15 |
+
return flat // Q, flat % Q, float(score.flatten()[flat])
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _cls(ch: str) -> str:
|
| 19 |
+
return "a" if ch.isalpha() else "d" if ch.isdigit() else ""
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def snap_word(text: str, s: int, e: int) -> tuple[int, int]:
|
| 23 |
+
"""Widen the char span [s, e) so it never cuts a word or number in half ("US Doll" -> "US Dollars").
|
| 24 |
+
Only letter-letter and digit-digit edges are extended, so "100" stays "100" inside "100km"."""
|
| 25 |
+
while s > 0 and s < e and _cls(text[s]) and _cls(text[s - 1]) == _cls(text[s]):
|
| 26 |
+
s -= 1
|
| 27 |
+
while e < len(text) and e > s and _cls(text[e - 1]) and _cls(text[e]) == _cls(text[e - 1]):
|
| 28 |
+
e += 1
|
| 29 |
+
return s, e
|
code/actionman/v3feat.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""v3 sequences: copy targets into the query, Jev-style option blocks, per-item list slots.
|
| 2 |
+
|
| 3 |
+
Pass B v3 slot block:
|
| 4 |
+
#1 tool( city= [M][M][M][M] , unit= [M] ( [OPT] metric [OPT] imperial ) , tags= [ [M]x8 | [M]x8 ] )
|
| 5 |
+
Every scalar value / list item has an anchor (its first slot) that carries the copy gate and the span pointer;
|
| 6 |
+
every option param has one [MASK] anchor that points at its [OPT] markers. The v2 builders are untouched.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
|
| 12 |
+
from .audit import copy_span, query_offsets
|
| 13 |
+
from .features import BUCKETS, MAX_CALLS, MAX_LEN_B, OVERFLOW, _enc, build_pass_a, value_tokens
|
| 14 |
+
from .schema import Example, param_options, value_to_text
|
| 15 |
+
from .scoring import value_match
|
| 16 |
+
|
| 17 |
+
ITEM_SLOTS = 8
|
| 18 |
+
MAX_ITEMS = 7
|
| 19 |
+
N_COUNT = MAX_ITEMS + 1 # count classes: 0 = whole list as text (v2 path), 1..7 = item slots
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _scalar(v) -> bool:
|
| 23 |
+
return isinstance(v, (str, int, float)) and not isinstance(v, bool)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def list_mode(tok, query: str, v, offs) -> bool:
|
| 27 |
+
"""A list gets item slots if it has 1..7 scalar items, each copyable or at most ITEM_SLOTS tokens."""
|
| 28 |
+
if not isinstance(v, list) or not 1 <= len(v) <= MAX_ITEMS:
|
| 29 |
+
return False
|
| 30 |
+
for it in v:
|
| 31 |
+
if not _scalar(it):
|
| 32 |
+
return False
|
| 33 |
+
if len(value_tokens(tok, it)) > ITEM_SLOTS and copy_span(tok, query, value_to_text(it), offs) is None:
|
| 34 |
+
return False
|
| 35 |
+
return True
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def option_index(p, v) -> int | None:
|
| 39 |
+
opts = param_options(p)
|
| 40 |
+
if opts is None:
|
| 41 |
+
return None
|
| 42 |
+
for m, o in enumerate(opts):
|
| 43 |
+
if value_match(v, o) or value_to_text(v).strip().lower() == value_to_text(o).strip().lower():
|
| 44 |
+
return m
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def build_pass_a_v3(tok, ex: Example) -> dict | None:
|
| 49 |
+
"""Pass A plus query token positions/char offsets and count labels for declared-list params."""
|
| 50 |
+
a = build_pass_a(tok, ex)
|
| 51 |
+
if a is None:
|
| 52 |
+
return None
|
| 53 |
+
offs = query_offsets(tok, ex.query)
|
| 54 |
+
q_end = a["call_pos"][0] # query tokens sit right before the [CALL] markers
|
| 55 |
+
a["q_pos"] = list(range(q_end - len(offs), q_end))
|
| 56 |
+
a["q_offs"] = offs
|
| 57 |
+
count_items = []
|
| 58 |
+
for (i, j, k, b) in a["len_items"]:
|
| 59 |
+
p = ex.tools[j].params[k]
|
| 60 |
+
if p.type == "list" and b != 0:
|
| 61 |
+
v = ex.calls[i].args[p.name]
|
| 62 |
+
count_items.append((i, j, k, len(v) if list_mode(tok, ex.query, v, offs) else 0))
|
| 63 |
+
a["count_items"] = count_items
|
| 64 |
+
return a
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def gold_plan_v3(tok, ex: Example, a: dict) -> list[tuple[int, dict[int, tuple[str, int]]]]:
|
| 68 |
+
"""Per gold call: (tool_j, {param_k: (mode, n)}) with mode in opt / list (n items) / text (n = bucket)."""
|
| 69 |
+
plan = []
|
| 70 |
+
for i in range(min(len(ex.calls), MAX_CALLS)):
|
| 71 |
+
j, modes = a["dec_labels"][i], {}
|
| 72 |
+
for (ci, _, k, b) in a["len_items"]:
|
| 73 |
+
if ci != i or b == 0:
|
| 74 |
+
continue
|
| 75 |
+
p = ex.tools[j].params[k]
|
| 76 |
+
v = ex.calls[i].args[p.name]
|
| 77 |
+
if option_index(p, v) is not None:
|
| 78 |
+
modes[k] = ("opt", 0)
|
| 79 |
+
elif p.type == "list" and list_mode(tok, ex.query, v, a["q_offs"]):
|
| 80 |
+
modes[k] = ("list", len(v))
|
| 81 |
+
else:
|
| 82 |
+
modes[k] = ("text", b)
|
| 83 |
+
plan.append((j, modes))
|
| 84 |
+
return plan
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def build_pass_b_v3(tok, ex: Example, a: dict, plan, with_targets: bool, max_len: int = MAX_LEN_B,
|
| 88 |
+
keep_context: bool = False, markers: bool = True) -> dict | None:
|
| 89 |
+
ids = (a["input_ids"] if keep_context else a["input_ids"][:-1]) + [tok.am["[ARGS]"]]
|
| 90 |
+
ctx_len = len(a["input_ids"]) if keep_context else len(ids) - 1
|
| 91 |
+
out = {"slot_pos": [], "slot_index": [], "anchors": [], "opt_slots": [], "ctx_len": ctx_len, "q_pos": a["q_pos"]}
|
| 92 |
+
if with_targets:
|
| 93 |
+
out["slot_targets"], out["copy_targets"] = [], []
|
| 94 |
+
|
| 95 |
+
def add_value(n, value, key):
|
| 96 |
+
pos = list(range(len(ids), len(ids) + n))
|
| 97 |
+
ids.extend([tok.mask_token_id] * n)
|
| 98 |
+
out["slot_pos"].append(pos)
|
| 99 |
+
out["slot_index"].append(key)
|
| 100 |
+
out["anchors"].append(pos[0])
|
| 101 |
+
if with_targets:
|
| 102 |
+
v = value_tokens(tok, value)[:n]
|
| 103 |
+
out["slot_targets"].append(v + [tok.pad_token_id] * (n - len(v)))
|
| 104 |
+
cs = copy_span(tok, ex.query, value_to_text(value), a["q_offs"]) if _scalar(value) else None
|
| 105 |
+
out["copy_targets"].append((a["q_pos"][cs[0]], a["q_pos"][cs[1]]) if cs else None)
|
| 106 |
+
|
| 107 |
+
for i, (j, modes) in enumerate(plan):
|
| 108 |
+
tool = ex.tools[j]
|
| 109 |
+
ids.extend(_enc(tok, (f" #{i + 1} " if markers else "") + tool.name + "("))
|
| 110 |
+
first = True
|
| 111 |
+
for k, p in enumerate(tool.params):
|
| 112 |
+
if k not in modes:
|
| 113 |
+
continue
|
| 114 |
+
mode, n = modes[k]
|
| 115 |
+
if mode == "text" and n in (0, OVERFLOW):
|
| 116 |
+
continue
|
| 117 |
+
ids.extend(_enc(tok, ("" if first else ", ") + p.name + "="))
|
| 118 |
+
first = False
|
| 119 |
+
v = ex.calls[i].args[p.name] if with_targets else None
|
| 120 |
+
if mode == "opt":
|
| 121 |
+
anchor = len(ids)
|
| 122 |
+
ids.append(tok.mask_token_id)
|
| 123 |
+
ids.extend(_enc(tok, " ("))
|
| 124 |
+
opos = []
|
| 125 |
+
for o in param_options(p):
|
| 126 |
+
opos.append(len(ids))
|
| 127 |
+
ids.append(tok.am["[OPT]"])
|
| 128 |
+
ids.extend(_enc(tok, " " + value_to_text(o)))
|
| 129 |
+
ids.extend(_enc(tok, " )"))
|
| 130 |
+
tgt = option_index(p, v) if with_targets else None
|
| 131 |
+
out["opt_slots"].append((i, k, anchor, opos, -100 if tgt is None else tgt))
|
| 132 |
+
elif mode == "list":
|
| 133 |
+
ids.extend(_enc(tok, " ["))
|
| 134 |
+
for it in range(n):
|
| 135 |
+
if it:
|
| 136 |
+
ids.extend(_enc(tok, " |"))
|
| 137 |
+
add_value(ITEM_SLOTS, v[it] if with_targets else None, (i, k, it))
|
| 138 |
+
ids.extend(_enc(tok, " ]"))
|
| 139 |
+
else:
|
| 140 |
+
add_value(BUCKETS[n], v, (i, k, None))
|
| 141 |
+
ids.extend(_enc(tok, ")"))
|
| 142 |
+
ids.append(tok.sep_token_id)
|
| 143 |
+
if len(ids) > max_len:
|
| 144 |
+
return None
|
| 145 |
+
out["input_ids"] = ids
|
| 146 |
+
return out
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ---------- batching ----------
|
| 150 |
+
|
| 151 |
+
def v3_extras(A: list[dict], B_rows: list[tuple[int, dict]]) -> dict:
|
| 152 |
+
"""Tensors for the v3 heads. A: pass-A dicts (row = index); B_rows: (row in the pass-B batch, pass-B dict)."""
|
| 153 |
+
ab, ap, gy, ss, se, ob, oa, oy, opos = [], [], [], [], [], [], [], [], []
|
| 154 |
+
Q = max([len(b["q_pos"]) for _, b in B_rows] + [1])
|
| 155 |
+
n_rows = max([r for r, _ in B_rows] + [-1]) + 1
|
| 156 |
+
qpos = torch.full((max(n_rows, 1), Q), -1, dtype=torch.long)
|
| 157 |
+
for r, b in B_rows:
|
| 158 |
+
qpos[r, : len(b["q_pos"])] = torch.tensor(b["q_pos"], dtype=torch.long)
|
| 159 |
+
q0 = b["q_pos"][0] if b["q_pos"] else 0
|
| 160 |
+
clean = set(b.get("_clean", b["anchors"])) # set by data._mask_slots for training batches
|
| 161 |
+
for anchor, ct in zip(b["anchors"], b.get("copy_targets", [None] * len(b["anchors"]))):
|
| 162 |
+
if anchor not in clean:
|
| 163 |
+
continue
|
| 164 |
+
ab.append(r); ap.append(anchor)
|
| 165 |
+
gy.append(1.0 if ct else 0.0)
|
| 166 |
+
ss.append(ct[0] - q0 if ct else -100); se.append(ct[1] - q0 if ct else -100)
|
| 167 |
+
for (_, _, anchor, opts, tgt) in b["opt_slots"]:
|
| 168 |
+
ob.append(r); oa.append(anchor); oy.append(tgt); opos.append(opts)
|
| 169 |
+
M = max([len(o) for o in opos] + [1])
|
| 170 |
+
opt_pos = torch.full((len(opos), M), -1, dtype=torch.long)
|
| 171 |
+
for n, o in enumerate(opos):
|
| 172 |
+
opt_pos[n, : len(o)] = torch.tensor(o, dtype=torch.long)
|
| 173 |
+
cidx, cy = [], []
|
| 174 |
+
for i, a in enumerate(A):
|
| 175 |
+
for (ci, j, k, c) in a.get("count_items", []):
|
| 176 |
+
cidx.append((i, a["call_pos"][ci], a["arg_pos"][(j, k)]))
|
| 177 |
+
cy.append(c)
|
| 178 |
+
L = lambda x, dt=torch.long: torch.tensor(x, dtype=dt) # noqa: E731
|
| 179 |
+
return {"anchor_b": L(ab), "anchor_p": L(ap), "gate_y": L(gy, torch.float), "span_s": L(ss), "span_e": L(se),
|
| 180 |
+
"qpos": qpos, "opt_b": L(ob), "opt_anchor": L(oa), "opt_pos": opt_pos, "opt_y": L(oy),
|
| 181 |
+
"count_idx": L(cidx).reshape(-1, 3), "count_y": L(cy)}
|
config.json
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": "0.1",
|
| 3 |
+
"base": "answerdotai/ModernBERT-large",
|
| 4 |
+
"flags": {
|
| 5 |
+
"cached": true,
|
| 6 |
+
"markers": true,
|
| 7 |
+
"v3": true
|
| 8 |
+
},
|
| 9 |
+
"encoder": {
|
| 10 |
+
"transformers_version": "5.19.0",
|
| 11 |
+
"architectures": [
|
| 12 |
+
"ModernBertForMaskedLM"
|
| 13 |
+
],
|
| 14 |
+
"output_hidden_states": false,
|
| 15 |
+
"return_dict": true,
|
| 16 |
+
"dtype": "float32",
|
| 17 |
+
"chunk_size_feed_forward": 0,
|
| 18 |
+
"is_encoder_decoder": false,
|
| 19 |
+
"id2label": {
|
| 20 |
+
"0": "LABEL_0",
|
| 21 |
+
"1": "LABEL_1"
|
| 22 |
+
},
|
| 23 |
+
"label2id": {
|
| 24 |
+
"LABEL_0": 0,
|
| 25 |
+
"LABEL_1": 1
|
| 26 |
+
},
|
| 27 |
+
"problem_type": null,
|
| 28 |
+
"vocab_size": 50432,
|
| 29 |
+
"hidden_size": 1024,
|
| 30 |
+
"intermediate_size": 2624,
|
| 31 |
+
"num_hidden_layers": 28,
|
| 32 |
+
"num_attention_heads": 16,
|
| 33 |
+
"hidden_activation": "gelu",
|
| 34 |
+
"max_position_embeddings": 8192,
|
| 35 |
+
"initializer_range": 0.02,
|
| 36 |
+
"initializer_cutoff_factor": 2.0,
|
| 37 |
+
"norm_eps": 1e-05,
|
| 38 |
+
"norm_bias": false,
|
| 39 |
+
"pad_token_id": 50283,
|
| 40 |
+
"eos_token_id": 50282,
|
| 41 |
+
"bos_token_id": 50281,
|
| 42 |
+
"cls_token_id": 50281,
|
| 43 |
+
"sep_token_id": 50282,
|
| 44 |
+
"attention_bias": false,
|
| 45 |
+
"attention_dropout": 0.0,
|
| 46 |
+
"layer_types": [
|
| 47 |
+
"full_attention",
|
| 48 |
+
"sliding_attention",
|
| 49 |
+
"sliding_attention",
|
| 50 |
+
"full_attention",
|
| 51 |
+
"sliding_attention",
|
| 52 |
+
"sliding_attention",
|
| 53 |
+
"full_attention",
|
| 54 |
+
"sliding_attention",
|
| 55 |
+
"sliding_attention",
|
| 56 |
+
"full_attention",
|
| 57 |
+
"sliding_attention",
|
| 58 |
+
"sliding_attention",
|
| 59 |
+
"full_attention",
|
| 60 |
+
"sliding_attention",
|
| 61 |
+
"sliding_attention",
|
| 62 |
+
"full_attention",
|
| 63 |
+
"sliding_attention",
|
| 64 |
+
"sliding_attention",
|
| 65 |
+
"full_attention",
|
| 66 |
+
"sliding_attention",
|
| 67 |
+
"sliding_attention",
|
| 68 |
+
"full_attention",
|
| 69 |
+
"sliding_attention",
|
| 70 |
+
"sliding_attention",
|
| 71 |
+
"full_attention",
|
| 72 |
+
"sliding_attention",
|
| 73 |
+
"sliding_attention",
|
| 74 |
+
"full_attention"
|
| 75 |
+
],
|
| 76 |
+
"rope_parameters": {
|
| 77 |
+
"full_attention": {
|
| 78 |
+
"rope_theta": 160000.0,
|
| 79 |
+
"rope_type": "default"
|
| 80 |
+
},
|
| 81 |
+
"sliding_attention": {
|
| 82 |
+
"rope_theta": 10000.0,
|
| 83 |
+
"rope_type": "default"
|
| 84 |
+
}
|
| 85 |
+
},
|
| 86 |
+
"local_attention": 128,
|
| 87 |
+
"embedding_dropout": 0.0,
|
| 88 |
+
"mlp_bias": false,
|
| 89 |
+
"mlp_dropout": 0.0,
|
| 90 |
+
"decoder_bias": true,
|
| 91 |
+
"classifier_pooling": "mean",
|
| 92 |
+
"classifier_dropout": 0.0,
|
| 93 |
+
"classifier_bias": false,
|
| 94 |
+
"classifier_activation": "gelu",
|
| 95 |
+
"deterministic_flash_attn": false,
|
| 96 |
+
"sparse_prediction": false,
|
| 97 |
+
"sparse_pred_ignore_index": -100,
|
| 98 |
+
"tie_word_embeddings": true,
|
| 99 |
+
"_name_or_path": "",
|
| 100 |
+
"global_attn_every_n_layers": 3,
|
| 101 |
+
"gradient_checkpointing": false,
|
| 102 |
+
"layer_norm_eps": 1e-05,
|
| 103 |
+
"model_type": "modernbert",
|
| 104 |
+
"position_embedding_type": "absolute",
|
| 105 |
+
"output_attentions": false
|
| 106 |
+
}
|
| 107 |
+
}
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a2e10ab52163263397dfe356244cdbed2487488962695604879eb3979efb993d
|
| 3 |
+
size 924614024
|
tokenizer.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
tokenizer_config.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backend": "tokenizers",
|
| 3 |
+
"clean_up_tokenization_spaces": true,
|
| 4 |
+
"cls_token": "[CLS]",
|
| 5 |
+
"extra_special_tokens": [
|
| 6 |
+
"[T]",
|
| 7 |
+
"[CALL]",
|
| 8 |
+
"[ARGS]",
|
| 9 |
+
"[USER]",
|
| 10 |
+
"[OPT]"
|
| 11 |
+
],
|
| 12 |
+
"is_local": false,
|
| 13 |
+
"local_files_only": false,
|
| 14 |
+
"mask_token": "[MASK]",
|
| 15 |
+
"model_input_names": [
|
| 16 |
+
"input_ids",
|
| 17 |
+
"attention_mask"
|
| 18 |
+
],
|
| 19 |
+
"model_max_length": 8192,
|
| 20 |
+
"pad_token": "[PAD]",
|
| 21 |
+
"sep_token": "[SEP]",
|
| 22 |
+
"tokenizer_class": "TokenizersBackend",
|
| 23 |
+
"unk_token": "[UNK]"
|
| 24 |
+
}
|