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 +1 -0
- tcb/config.py +161 -0
- tcb/cost.py +89 -0
- tcb/grading/__init__.py +1 -0
- tcb/grading/grade.py +228 -0
- tcb/grading/hallucination.py +181 -0
- tcb/grading/matchers.py +41 -0
- tcb/harness/__init__.py +1 -0
- tcb/harness/llm.py +126 -0
- tcb/harness/mock.py +131 -0
- tcb/harness/runner.py +184 -0
- tcb/report/__init__.py +1 -0
- tcb/report/gallery.py +94 -0
- tcb/report/leaderboard.py +71 -0
- tcb/sandbox/__init__.py +1 -0
- tcb/sandbox/faults.py +54 -0
- tcb/sandbox/tools.py +384 -0
- tcb/sandbox/world.py +218 -0
- tcb/tasks/__init__.py +1 -0
- tcb/tasks/loader.py +138 -0
- toolcallbench_cn-0.1.0.dist-info/METADATA +210 -0
- toolcallbench_cn-0.1.0.dist-info/RECORD +25 -0
- toolcallbench_cn-0.1.0.dist-info/WHEEL +5 -0
- toolcallbench_cn-0.1.0.dist-info/licenses/LICENSE +21 -0
- toolcallbench_cn-0.1.0.dist-info/top_level.txt +1 -0
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)
|
tcb/grading/__init__.py
ADDED
|
@@ -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
|