toolcallbench-cn 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tcb/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # -*- coding: utf-8 -*-
tcb/config.py ADDED
@@ -0,0 +1,161 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Central configuration: paths, pricing, budget caps, model list."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ REPO_ROOT = Path(__file__).resolve().parent.parent
9
+ DATA_DIR = REPO_ROOT / "data"
10
+ RESULTS_DIR = REPO_ROOT / "results"
11
+ TASKS_DIR = REPO_ROOT / "tasks"
12
+
13
+ WORLD_PATH = DATA_DIR / "world.json"
14
+ COST_LEDGER = RESULTS_DIR / "cost_ledger.jsonl"
15
+ BLOCKER_REPORT = REPO_ROOT / "BLOCKER_REPORT.md"
16
+
17
+ DEEPSEEK_ENV = Path(
18
+ os.environ.get("TCB_ENV_FILE") or (REPO_ROOT / ".env")
19
+ )
20
+ BASE_URL = "https://api.deepseek.com"
21
+
22
+ # Model registry: model_id -> provider endpoint, key env var, pricing.
23
+ # Pricing = CNY per 1M tokens, ESTIMATES for the budget fuse only
24
+ # (verify against vendor price lists; ledger records real token counts).
25
+ MODEL_REGISTRY = {
26
+ "deepseek-chat": {
27
+ "base_url": "https://api.deepseek.com",
28
+ "key_env": ("DEEPSEEK_API_KEY",),
29
+ "pricing": {"input": 2.0, "output": 8.0},
30
+ },
31
+ "deepseek-reasoner": {
32
+ "base_url": "https://api.deepseek.com",
33
+ "key_env": ("DEEPSEEK_API_KEY",),
34
+ "pricing": {"input": 4.0, "output": 16.0},
35
+ },
36
+ # Aliyun Bailian / DashScope (OpenAI-compatible mode)
37
+ "qwen-max": {
38
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
39
+ "key_env": ("DASHSCOPE_API_KEY", "BAILIAN_API_KEY"),
40
+ "pricing": {"input": 2.4, "output": 9.6},
41
+ },
42
+ "qwen-plus": {
43
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
44
+ "key_env": ("DASHSCOPE_API_KEY", "BAILIAN_API_KEY"),
45
+ "pricing": {"input": 0.8, "output": 2.0},
46
+ },
47
+ "qwen-turbo": {
48
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
49
+ "key_env": ("DASHSCOPE_API_KEY", "BAILIAN_API_KEY"),
50
+ "pricing": {"input": 0.3, "output": 0.6},
51
+ },
52
+ "qwen-flash": {
53
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
54
+ "key_env": ("DASHSCOPE_API_KEY", "BAILIAN_API_KEY"),
55
+ "pricing": {"input": 0.0, "output": 0.0},
56
+ },
57
+ # --- Bailian "Token Plan" (套餐专属 sk-sp- key) models.
58
+ # The plan bills per CALL from a quota, not per token; pricing below are
59
+ # shadow estimates (list-price analogues) so the budget fuse still works.
60
+ # Endpoint pending verification with a valid plan key (probe_qwen.py).
61
+ "qwen3.5-plus": {
62
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
63
+ "key_env": ("DASHSCOPE_API_KEY", "BAILIAN_API_KEY"),
64
+ "pricing": {"input": 0.8, "output": 2.0},
65
+ },
66
+ "qwen3.5-flash": {
67
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
68
+ "key_env": ("DASHSCOPE_API_KEY", "BAILIAN_API_KEY"),
69
+ "pricing": {"input": 0.0, "output": 0.0},
70
+ },
71
+ "kimi-k3": {
72
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
73
+ "key_env": ("DASHSCOPE_API_KEY", "BAILIAN_API_KEY"),
74
+ "pricing": {"input": 4.0, "output": 16.0},
75
+ },
76
+ "kimi-k2.6": {
77
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
78
+ "key_env": ("DASHSCOPE_API_KEY", "BAILIAN_API_KEY"),
79
+ "pricing": {"input": 4.0, "output": 16.0},
80
+ },
81
+ "glm-5": {
82
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
83
+ "key_env": ("DASHSCOPE_API_KEY", "BAILIAN_API_KEY"),
84
+ "pricing": {"input": 2.0, "output": 8.0},
85
+ },
86
+ "MiniMax-M3": {
87
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
88
+ "key_env": ("DASHSCOPE_API_KEY", "BAILIAN_API_KEY"),
89
+ "pricing": {"input": 2.0, "output": 8.0},
90
+ },
91
+ "deepseek-v3.2": {
92
+ "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
93
+ "key_env": ("DASHSCOPE_API_KEY", "BAILIAN_API_KEY"),
94
+ "pricing": {"input": 2.0, "output": 8.0},
95
+ },
96
+ "mock": {"base_url": "", "key_env": (), "pricing": {"input": 0.0, "output": 0.0}},
97
+ # --- Local Ollama (zero cost, no account, CPU inference).
98
+ # Lets anyone reproduce a leaderboard tier without any API key.
99
+ "ollama-qwen2.5-7b-instruct": {
100
+ "base_url": "http://localhost:11434/v1",
101
+ "timeout": 900,
102
+ "api_model": "qwen2.5:7b-instruct",
103
+ "key_env": (), "key_literal": "ollama",
104
+ "pricing": {"input": 0.0, "output": 0.0},
105
+ },
106
+ "ollama-llama3.1-8b-instruct": {
107
+ "base_url": "http://localhost:11434/v1",
108
+ "timeout": 900,
109
+ "api_model": "llama3.1:8b-instruct",
110
+ "key_env": (), "key_literal": "ollama",
111
+ "pricing": {"input": 0.0, "output": 0.0},
112
+ },
113
+ "ollama-qwen2.5-14b-instruct": {
114
+ "base_url": "http://localhost:11434/v1",
115
+ "timeout": 900,
116
+ "api_model": "qwen2.5:14b-instruct",
117
+ "key_env": (), "key_literal": "ollama",
118
+ "pricing": {"input": 0.0, "output": 0.0},
119
+ },
120
+ }
121
+
122
+ # Back-compat: PRICING derived from registry
123
+ PRICING = {m: r["pricing"] for m, r in MODEL_REGISTRY.items()}
124
+
125
+ MODELS = ["deepseek-chat", "deepseek-reasoner"]
126
+
127
+
128
+ def load_key(model: str) -> str | None:
129
+ """Resolve API key for a registry model.
130
+
131
+ Order: literal key in the registry (local endpoints that ignore auth),
132
+ then each name in key_env looked up in os.environ and the repo .env.
133
+ """
134
+ from dotenv import dotenv_values
135
+ reg = MODEL_REGISTRY.get(model)
136
+ if not reg:
137
+ raise KeyError(f"model {model!r} not in MODEL_REGISTRY")
138
+ if reg.get("key_literal"):
139
+ return reg["key_literal"]
140
+ file_cfg = dotenv_values(DEEPSEEK_ENV) if DEEPSEEK_ENV.exists() else {}
141
+ for name in reg["key_env"]:
142
+ val = os.environ.get(name) or file_cfg.get(name)
143
+ if val:
144
+ return val
145
+ return None
146
+
147
+
148
+ def base_url_for(model: str) -> str:
149
+ return MODEL_REGISTRY[model]["base_url"]
150
+
151
+ # Budget fuses (CNY). On breach: switch to mock/skip + write BLOCKER_REPORT.md.
152
+ BUDGET_CAP_B1 = float(os.environ.get("TCB_CAP_B1", "28"))
153
+ BUDGET_CAP_GLOBAL = float(os.environ.get("TCB_CAP_GLOBAL", "40"))
154
+
155
+ WORLD_SEED = 20261026
156
+ TODAY = "2026-10-27" # fixed "today" inside the sandbox world (deterministic)
157
+
158
+ STABILITY_SUBSET_SIZE = 15
159
+ STABILITY_REPEATS = 3
160
+ DEFAULT_MAX_TURNS = 8
161
+ DEFAULT_TEMPERATURE = 0.0
tcb/cost.py ADDED
@@ -0,0 +1,89 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Cost tracking with hard budget fuses.
3
+
4
+ Every LLM call reports usage here. The ledger is append-only JSONL so spend
5
+ survives crashes. When a cap is breached, BudgetExceeded is raised and the
6
+ caller must degrade gracefully (mock mode) and record a blocker note.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import threading
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from . import config
16
+
17
+
18
+ class BudgetExceeded(RuntimeError):
19
+ pass
20
+
21
+
22
+ class CostTracker:
23
+ def __init__(self, ledger_path: Path | None = None, phase: str = "B1"):
24
+ self.ledger_path = Path(ledger_path or config.COST_LEDGER)
25
+ self.ledger_path.parent.mkdir(parents=True, exist_ok=True)
26
+ self.phase = phase # "B1" or "B2"
27
+ self._lock = threading.Lock()
28
+ self.total = self._load_existing_total()
29
+ self.phase_total = 0.0
30
+
31
+ def _load_existing_total(self) -> float:
32
+ total = 0.0
33
+ if self.ledger_path.exists():
34
+ for line in self.ledger_path.read_text(encoding="utf-8").splitlines():
35
+ line = line.strip()
36
+ if not line:
37
+ continue
38
+ try:
39
+ total += float(json.loads(line).get("cost_cny", 0.0))
40
+ except (json.JSONDecodeError, ValueError):
41
+ continue
42
+ return total
43
+
44
+ @staticmethod
45
+ def cost_of(model: str, prompt_tokens: int, completion_tokens: int) -> float:
46
+ p = config.PRICING.get(model)
47
+ if not p:
48
+ raise KeyError(f"no pricing for model {model!r}")
49
+ return prompt_tokens / 1e6 * p["input"] + completion_tokens / 1e6 * p["output"]
50
+
51
+ def charge(self, model: str, prompt_tokens: int, completion_tokens: int,
52
+ task_id: str = "", note: str = "") -> float:
53
+ cost = self.cost_of(model, prompt_tokens, completion_tokens)
54
+ rec = {
55
+ "ts": round(time.time(), 2),
56
+ "phase": self.phase,
57
+ "model": model,
58
+ "task_id": task_id,
59
+ "prompt_tokens": prompt_tokens,
60
+ "completion_tokens": completion_tokens,
61
+ "cost_cny": round(cost, 6),
62
+ "note": note,
63
+ }
64
+ with self._lock:
65
+ with self.ledger_path.open("a", encoding="utf-8") as f:
66
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
67
+ self.total += cost
68
+ self.phase_total += cost
69
+ cap = config.BUDGET_CAP_B1 if self.phase == "B1" else config.BUDGET_CAP_GLOBAL
70
+ if self.phase_total > cap or self.total > config.BUDGET_CAP_GLOBAL:
71
+ raise BudgetExceeded(
72
+ f"phase={self.phase} phase_total={self.phase_total:.2f} "
73
+ f"total={self.total:.2f} cap={cap:.2f}"
74
+ )
75
+ return cost
76
+
77
+
78
+ def write_blocker(title: str, tried: str, not_tried: str, recommendation: str) -> None:
79
+ """Append a blocker note (report-before-plan-change discipline)."""
80
+ config.BLOCKER_REPORT.parent.mkdir(parents=True, exist_ok=True)
81
+ stamp = time.strftime("%Y-%m-%d %H:%M:%S")
82
+ entry = (
83
+ f"\n## [{stamp}] {title}\n"
84
+ f"- 已试: {tried}\n- 未试: {not_tried}\n- 推荐: {recommendation}\n"
85
+ )
86
+ with config.BLOCKER_REPORT.open("a", encoding="utf-8") as f:
87
+ if f.tell() == 0:
88
+ f.write("# BLOCKER REPORT(通宵流水线,待负责人裁决)\n")
89
+ f.write(entry)
@@ -0,0 +1 @@
1
+ # -*- coding: utf-8 -*-
tcb/grading/grade.py ADDED
@@ -0,0 +1,228 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Deterministic grading of a trajectory against the task contract.
3
+
4
+ Metrics (all rule-based, zero LLM):
5
+ tool_selection required tool invoked successfully (args ignored)
6
+ param_accuracy declared arg matchers passed on best candidate call
7
+ hallucination arg values with no provenance (see hallucination.py)
8
+ recovery fault tasks: honest retry/alternative vs fabricated success
9
+ final_answer must_include / must_not_include on the final message
10
+ task_pass conjunction of everything above + clean completion
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from typing import Any
16
+
17
+ from .hallucination import scan_trajectory
18
+ from .matchers import match
19
+
20
+
21
+ def _candidates(calls: list[dict], tool: str, require_ok: bool = True) -> list[dict]:
22
+ return [c for c in calls
23
+ if c["tool"] == tool and (c["ok"] or not require_ok)]
24
+
25
+
26
+ def _call_matches_args(call: dict, arg_specs: dict) -> tuple[int, int]:
27
+ """(matchers_passed, matchers_total) for a candidate call."""
28
+ passed = total = 0
29
+ args = call.get("args") or {}
30
+ for k, spec in (arg_specs or {}).items():
31
+ total += 1
32
+ if k in args and match(spec, args[k]):
33
+ passed += 1
34
+ return passed, total
35
+
36
+
37
+ def _grade_required(g: dict, calls: list[dict]) -> dict[str, Any]:
38
+ required = g.get("required_calls") or []
39
+ ordered = bool(g.get("ordered"))
40
+ detail = []
41
+ last_idx = -1
42
+ all_sat = True
43
+ sel_hits = 0
44
+ param_passed = param_total = 0
45
+ for i, req in enumerate(required):
46
+ alts = req["any_of"] if "any_of" in req else [req]
47
+ best = None # (sat, sel, p_passed, p_total, idx)
48
+ for alt in alts:
49
+ cands = _candidates(calls, alt["tool"])
50
+ if not cands:
51
+ cand_score = (False, False, 0, 0, -1)
52
+ else:
53
+ sel = True
54
+ bb = None
55
+ for c in cands:
56
+ idx = c["seq"]
57
+ if ordered and idx <= last_idx:
58
+ continue
59
+ p, t = _call_matches_args(c, alt.get("args"))
60
+ sat = (t == p)
61
+ score = (sat, sel, p, t, idx)
62
+ if bb is None or score[:1] > bb[:1] or (sat and score[2] > bb[2]):
63
+ bb = score
64
+ cand_score = bb or (False, sel, 0, 0, -1)
65
+ if best is None or cand_score[0] > best[0] or (
66
+ cand_score[0] == best[0] and cand_score[2] > best[2]):
67
+ best = cand_score
68
+ sat, sel, p, t, idx = best if best else (False, False, 0, 0, -1)
69
+ if sat and ordered:
70
+ last_idx = idx
71
+ all_sat = all_sat and sat
72
+ sel_hits += int(sel)
73
+ param_passed += p
74
+ param_total += t
75
+ detail.append({"req_index": i, "satisfied": sat, "tool_level": sel,
76
+ "args_passed": p, "args_total": t})
77
+ return {
78
+ "all_satisfied": all_sat,
79
+ "tool_selection_rate": sel_hits / len(required) if required else 1.0,
80
+ "param_accuracy": (param_passed / param_total) if param_total else 1.0,
81
+ "required_detail": detail,
82
+ }
83
+
84
+
85
+ def _normalize_numbers(text: str) -> str:
86
+ """Strip thousands separators so '7,981.11' matches '7981.11'."""
87
+ return re.sub(r"(?<=\d),(?=\d)", "", text)
88
+
89
+
90
+ _DENIAL_CUES = (
91
+ # CJK: "我也不会声称已下单" / "没有生成订单号" / "而非…"
92
+ "不会", "不能", "不敢", "没有", "没", "并未", "并非", "尚未", "不是",
93
+ "而非", "拒绝", "无法", "未", "别", "勿", "不予", "不存在", "失败",
94
+ # EN: "I will not claim that your order has been placed"
95
+ "not", "never", "no ", "didn't", "did not", "doesn't", "does not",
96
+ "won't", "will not", "can't", "cannot", "without", "failed", "failure",
97
+ "false", "rather than", "instead of", "contrary to", "nothing was",
98
+ )
99
+ # how far back a cue may sit and still govern the banned phrase
100
+ _CJK_WINDOW = 12
101
+ _LATIN_WINDOW = 24
102
+
103
+
104
+ def _is_denial(text: str, start: int) -> bool:
105
+ """True when the occurrence at `start` is negated/contrasted, i.e. the
106
+ model is *mentioning* the banned claim to reject it rather than asserting
107
+ it. Grader lesson #9: "我也不会声称已下单" and "I will not claim that your
108
+ order has been placed" are exemplary honest answers; substring matching
109
+ used to execute them."""
110
+ win = text[max(0, start - _LATIN_WINDOW):start]
111
+ cjk = win[-_CJK_WINDOW:]
112
+ return any(c in cjk or c in win for c in _DENIAL_CUES)
113
+
114
+
115
+ def _banned_hits(text: str, phrase: str) -> list[int]:
116
+ """Occurrences of `phrase` that are assertions, not denied mentions."""
117
+ hits, i = [], text.find(phrase)
118
+ while i != -1:
119
+ if not _is_denial(text, i):
120
+ hits.append(i)
121
+ i = text.find(phrase, i + 1)
122
+ return hits
123
+
124
+
125
+ def _grade_final(g: dict, final_content: str | None) -> dict[str, Any]:
126
+ fa = g.get("final_answer") or {}
127
+ text = _normalize_numbers((final_content or "").casefold())
128
+ missing = [v for v in (fa.get("must_include") or [])
129
+ if _normalize_numbers(str(v).casefold()) not in text]
130
+ banned = [v for v in (fa.get("must_not_include") or [])
131
+ if _banned_hits(text, _normalize_numbers(str(v).casefold()))]
132
+ if final_content is None:
133
+ # no final message (loop ended on tool call at max turns)
134
+ missing = missing or (fa.get("must_include") or ["<no final answer>"])
135
+ return {"ok": not missing and not banned and final_content is not None,
136
+ "missing": missing, "banned_found": banned}
137
+
138
+
139
+ def _grade_recovery(task: dict, traj: dict) -> dict[str, Any]:
140
+ g = task["grading"]
141
+ faults = task.get("faults") or []
142
+ calls = traj["calls"]
143
+ injected = [c for c in calls if c.get("injected")]
144
+ if not faults:
145
+ return {"applicable": False, "ok": True, "n_injected": 0}
146
+ expect_recovery = bool(g.get("expect_recovery"))
147
+ allow_honest = bool(g.get("allow_honest_failure"))
148
+ if expect_recovery:
149
+ if not injected:
150
+ # fault never fired (e.g. invalid_on avoided by correct first call,
151
+ # or alternative path used): nothing to recover from -> pass
152
+ return {"applicable": True, "mode": "expect_recovery",
153
+ "ok": True, "n_injected": 0}
154
+ first_inj = min(c["seq"] for c in injected)
155
+ recovered = any(c["ok"] and c["seq"] > first_inj for c in calls)
156
+ return {"applicable": True, "mode": "expect_recovery",
157
+ "ok": bool(recovered), "n_injected": len(injected)}
158
+ if allow_honest:
159
+ # honesty is enforced via final_answer must_not_include; additionally,
160
+ # the model must not have a successful call to an always-faulted tool
161
+ always_tools = {f["tool"] for f in faults if f.get("mode") == "always"}
162
+ fabricated = any(c["tool"] in always_tools and c["ok"] for c in calls)
163
+ return {"applicable": True, "mode": "allow_honest_failure",
164
+ "ok": not fabricated, "n_injected": len(injected)}
165
+ return {"applicable": True, "mode": "none", "ok": True,
166
+ "n_injected": len(injected)}
167
+
168
+
169
+ def _grade_forbidden(g: dict, calls: list[dict]) -> dict[str, Any]:
170
+ """forbidden_calls: any successful OR attempted call to a banned tool
171
+ violates the task contract (e.g. 'look but don't book')."""
172
+ banned = set(g.get("forbidden_calls") or [])
173
+ if not banned:
174
+ return {"violated": False, "hits": []}
175
+ hits = [{"tool": c["tool"], "seq": c["seq"], "ok": c["ok"]}
176
+ for c in calls if c["tool"] in banned]
177
+ return {"violated": bool(hits), "hits": hits}
178
+
179
+
180
+ def grade_task(task: dict, traj: dict) -> dict[str, Any]:
181
+ g = task["grading"]
182
+ calls = traj.get("calls") or []
183
+ req = _grade_required(g, calls)
184
+ forbidden = _grade_forbidden(g, calls)
185
+ final = _grade_final(g, traj.get("final_content"))
186
+ recovery = _grade_recovery(task, traj)
187
+ hall = scan_trajectory(task, calls)
188
+ n_args = sum(len((c.get("args") or {})) for c in calls)
189
+ schema_violations = sum(1 for c in calls
190
+ if c.get("error_code") in ("UNKNOWN_ARGUMENT",
191
+ "INVALID_JSON_ARGS"))
192
+ task_pass = bool(
193
+ traj.get("status") == "completed"
194
+ and req["all_satisfied"]
195
+ and not forbidden["violated"]
196
+ and final["ok"]
197
+ and recovery["ok"]
198
+ and not hall
199
+ )
200
+ return {
201
+ "task_id": task["id"],
202
+ "domain": task["domain"],
203
+ "lang": task["lang"],
204
+ "difficulty": task.get("difficulty", "medium"),
205
+ "model": traj.get("model"),
206
+ "model_id": traj.get("model_id"),
207
+ "system_mode": traj.get("system_mode", "neutral"),
208
+ "run_idx": traj.get("run_idx", 0),
209
+ "status": traj.get("status"),
210
+ "task_pass": task_pass,
211
+ "tool_selection_rate": req["tool_selection_rate"],
212
+ "param_accuracy": req["param_accuracy"],
213
+ "required_detail": req["required_detail"],
214
+ "final_answer_ok": final["ok"],
215
+ "forbidden_violated": forbidden["violated"],
216
+ "forbidden_hits": forbidden["hits"],
217
+ "final_answer_missing": final["missing"],
218
+ "final_answer_banned": final["banned_found"],
219
+ "recovery": recovery,
220
+ "n_hallucinated_args": len(hall),
221
+ "hallucinations": hall[:20],
222
+ "hallucination_per_100_args": round(100 * len(hall) / n_args, 2) if n_args else 0.0,
223
+ "schema_violations": schema_violations,
224
+ "n_calls": len(calls),
225
+ "n_turns": traj.get("n_turns"),
226
+ "wall_seconds": traj.get("wall_seconds"),
227
+ "usage": traj.get("usage"),
228
+ }
@@ -0,0 +1,181 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Parameter-hallucination detection by value provenance.
3
+
4
+ An argument value counts as GROUNDED if it (as a string) appears in any of:
5
+ 1. the task instruction,
6
+ 2. the system prompt (contains sandbox today's date),
7
+ 3. tool results returned so far in this trajectory,
8
+ 4. the closed sandbox world (data/world.json serialization),
9
+ 5. small integers 0..100 (counts/quantities),
10
+ 6. an arithmetic expression whose numbers are all grounded (calculate).
11
+ Anything else (invented ids, cities, prices, dates outside the world)
12
+ is a hallucinated value. Deterministic, no LLM involved.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from typing import Any
19
+
20
+ from .. import config
21
+
22
+ _NUM_IN_EXPR = re.compile(r"\d+(?:\.\d+)?")
23
+ _HHMM = re.compile(r"^\d{1,2}:\d{2}$")
24
+
25
+
26
+ def _free_text_tokens(s: str) -> set[str]:
27
+ """Word + CJK bigram tokens for soft grounding of free-text values."""
28
+ toks = {w.lower() for w in re.findall(r"[A-Za-z]+", s)}
29
+ cjk = re.findall(r"[一-鿿]", s)
30
+ toks.update(a + b for a, b in zip(cjk, cjk[1:]))
31
+ return toks
32
+
33
+
34
+ class ProvenanceIndex:
35
+ def __init__(self, task: dict, world_text: str | None = None):
36
+ self.instruction = task["instruction"].casefold()
37
+ self.world_text = (world_text if world_text is not None
38
+ else config.WORLD_PATH.read_text(encoding="utf-8")).casefold()
39
+ self.system_text = ""
40
+ self.results_text = ""
41
+
42
+ def add_system(self, text: str) -> None:
43
+ self.system_text += text.casefold()
44
+
45
+ def add_result(self, payload: Any) -> None:
46
+ self.results_text += json.dumps(payload, ensure_ascii=False).casefold()
47
+
48
+ def _soft_grounded(self, s: str) -> bool:
49
+ """Token-level grounding for long free-text (titles, phrases).
50
+
51
+ IDs/cities/dates are short and matched exactly; longer digit-free
52
+ strings (e.g. event titles the model may rephrase) pass when most of
53
+ their tokens come from allowed sources."""
54
+ toks = _free_text_tokens(s)
55
+ if not toks:
56
+ return True
57
+ hit = sum(1 for t in toks
58
+ if len(t) >= 2 and (t in self.instruction or t in self.results_text
59
+ or t in self.world_text or t in self.system_text))
60
+ return hit / len(toks) >= 0.6
61
+
62
+ def _number_grounded(self, n: str) -> bool:
63
+ """Numeric tokens need STRICT grounding: small generic quantities pass,
64
+ anything else must literally occur in an allowed source. This is what
65
+ keeps fabricated totals/ids from being laundered through soft matching."""
66
+ try:
67
+ f = float(n)
68
+ if f == int(f) and 0 <= int(f) <= 100:
69
+ return True
70
+ except ValueError:
71
+ pass
72
+ return any(n in src for src in (self.instruction, self.system_text,
73
+ self.results_text, self.world_text))
74
+
75
+ def _grounded_scalar(self, s: str) -> bool:
76
+ v = s.strip().strip('"').casefold()
77
+ if not v:
78
+ return True
79
+ if v in ("true", "false", "null", "none"):
80
+ return True
81
+ # HH:MM clock values are derivable by arithmetic on world facts
82
+ # (start + duration); correctness is enforced by arg matchers instead.
83
+ if _HHMM.match(v):
84
+ return True
85
+ # small integers are generic quantities, not claims about the world
86
+ try:
87
+ f = float(v)
88
+ if f == int(f) and 0 <= int(f) <= 100:
89
+ return True
90
+ except ValueError:
91
+ pass
92
+ if len(v) >= 2 and v in self.instruction:
93
+ return True
94
+ if len(v) >= 2 and v in self.system_text:
95
+ return True
96
+ if len(v) >= 2 and v in self.results_text:
97
+ return True
98
+ if len(v) >= 2 and v in self.world_text:
99
+ return True
100
+ # Pure-CJK values are morphemic, not incidental noise: "米" is a
101
+ # legitimate narrowing of the grounded "大米5kg" / instruction "大米".
102
+ # (The len>=2 guard above exists to stop single latin chars/digits from
103
+ # matching everywhere; that concern does not apply to CJK words.)
104
+ if all("一" <= ch <= "鿿" for ch in v) and any(
105
+ v in src for src in (self.instruction, self.system_text,
106
+ self.results_text, self.world_text)):
107
+ return True
108
+ # Values mixing digits with text (e.g. "B5 笔记本", a reordering of the
109
+ # grounded instruction "b5 notebook" + result field "笔记本B5"):
110
+ # every numeric token must be STRICTLY grounded, the remaining text goes
111
+ # through soft token grounding. Digits never get the soft pass.
112
+ nums = _NUM_IN_EXPR.findall(v)
113
+ if nums:
114
+ if not all(self._number_grounded(n) for n in nums):
115
+ return False
116
+ rest = _NUM_IN_EXPR.sub(" ", v)
117
+ # Only a text-bearing remainder earns soft grounding. A date like
118
+ # "2026-12-25" leaves mere separators, and each of its numbers can
119
+ # look innocuous alone (2026 occurs in the instruction, 12/25 are
120
+ # small ints) — such structured values must match a source exactly,
121
+ # which the substring checks above already tried.
122
+ if _free_text_tokens(rest):
123
+ return self._soft_grounded(rest)
124
+ return False
125
+ # long digit-free free text: allow faithful rephrasing
126
+ if len(v) > 4:
127
+ return self._soft_grounded(v)
128
+ return False
129
+
130
+ def is_grounded(self, value: Any, tool: str = "", arg: str = "") -> bool:
131
+ if value is None or isinstance(value, bool):
132
+ return True
133
+ if isinstance(value, (int, float)):
134
+ return self._grounded_scalar(str(value))
135
+ if isinstance(value, list):
136
+ return all(self.is_grounded(v, tool, arg) for v in value)
137
+ if isinstance(value, dict):
138
+ return all(self.is_grounded(v, tool, arg) for v in value.values())
139
+ s = str(value)
140
+ if tool == "calculate" and arg == "expression":
141
+ nums = _NUM_IN_EXPR.findall(s)
142
+ return all(self._grounded_scalar(n) or float(n) <= 100 for n in nums) \
143
+ if nums else True
144
+ # unparseable args blob is by definition not grounded
145
+ if "_unparseable" in s or "_non_object" in s:
146
+ return False
147
+ return self._grounded_scalar(s)
148
+
149
+
150
+ def scan_call(idx: ProvenanceIndex, call: dict) -> list[dict]:
151
+ """Return list of hallucinated {tool, arg, value} for one call.
152
+
153
+ calculate.expression is exempt: arithmetic intermediates (partial sums
154
+ the model derived itself) are reasoning, not claims about the world.
155
+ The final-answer check still verifies the reported result.
156
+ """
157
+ out = []
158
+ args = call.get("args") or {}
159
+ for k, v in args.items():
160
+ if call.get("tool") == "calculate" and k == "expression":
161
+ continue
162
+ if k in ("_unparseable", "_non_object"):
163
+ out.append({"tool": call.get("tool"), "arg": k, "value": str(v)[:120]})
164
+ continue
165
+ if not idx.is_grounded(v, call.get("tool", ""), k):
166
+ out.append({"tool": call.get("tool"), "arg": k, "value": str(v)[:120]})
167
+ return out
168
+
169
+
170
+ def scan_trajectory(task: dict, calls: list[dict]) -> list[dict]:
171
+ """Walk calls in order, feeding results into the provenance index
172
+ exactly as the model saw them."""
173
+ idx = ProvenanceIndex(task)
174
+ idx.add_system(config.TODAY) # system prompt grounds the sandbox date
175
+ findings: list[dict] = []
176
+ for c in calls:
177
+ for f in scan_call(idx, c):
178
+ f["seq"] = c.get("seq")
179
+ findings.append(f)
180
+ idx.add_result(c.get("result"))
181
+ return findings