PyTorch
Safetensors
action-model
function-calling
tool-calling
tool-use
non-autoregressive
masked-diffusion
modernbert
shing-dev commited on
Commit
6fd3740
·
verified ·
1 Parent(s): c729983

ActionMan-0.1 research preview

Browse files
.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
+ ![how it works](assets/how-it-works.jpg)
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
+ ![latency](assets/latency_vs_output_size.png)
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
+ ![reliability](assets/reliability_bfcl_v4.png)
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

  • SHA256: 29b8c2909cee0987b436bbeee1a9160f43120e9cfd849680fc12a12240934ef9
  • Pointer size: 131 Bytes
  • Size of remote file: 341 kB
assets/how-it-works.jpg ADDED

Git LFS Details

  • SHA256: 91d16c15fd5db3341eb0f383914bc6dddac003c13e45c33663aa8cbbcbe0b002
  • Pointer size: 131 Bytes
  • Size of remote file: 379 kB
assets/latency_vs_output_size.png ADDED

Git LFS Details

  • SHA256: 789c77aaf678f8d861397a3da41151c7fd72b02d70244eb2887a7a238ddeb784
  • Pointer size: 131 Bytes
  • Size of remote file: 170 kB
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
+ }