Skip to content

Commit 03c8a0c

Browse files
committed
Add the Tacit evaluation harness and results with escalation
- scripts/eval_tacit.py: JevBench public set and bev-decision test through Tacit against vllm serve, one forward or adaptive; every README Tacit number now comes from it (bench/results_tacit/2026-10-06/) - README (EN/zh): adaptive results for Tacit-9B and Tacit-4B - Tacit: first_level=0 for score levels numbered from 0
1 parent 1ffa57b commit 03c8a0c

26 files changed

Lines changed: 753 additions & 132 deletions

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Ground rules
44

5-
1. **No fabricated data, ever.** Published numbers come from committed result JSON under `bench/results_*/<date>/` (the runs behind the shipped tables are `bench/results_v01/2026-09-22/`, `bench/results_layout/2026-09-27/` and `bench/results_tacit/2026-10-05/`). If a run did not happen, the cell is empty. Every number in prose names its JSON, and `tests/test_readme_numbers.py` checks the README tables against theirs.
5+
1. **No fabricated data, ever.** Published numbers come from committed result JSON under `bench/results_*/<date>/` (the runs behind the shipped tables are `bench/results_v01/2026-09-22/`, `bench/results_layout/2026-09-27/` and `bench/results_tacit/2026-10-06/`). If a run did not happen, the cell is empty. Every number in prose names its JSON, and `tests/test_readme_numbers.py` checks the README tables against theirs.
66
2. **No hidden generation.** `Decider` never samples tokens. `Tacit` generates only for an escalated decision (`adaptive=True`), and every escalation is reported (`route="cot"`) and counted against the cap. Any other code path that samples tokens is a bug.
77
3. **Level and route are mandatory.** Every `Decision` carries `level` (`raw` / `L0`); every Tacit decision carries `route` (`one_forward` / `cot`). Tests assert both.
88
4. **Backends are thin.** A backend implements `next_token_logprobs(prompts, token_ids)`; the transformers backend adds the optional methods the Decider probes for (`score_shared` for shared-prefix scoring, `hidden_states` / `hidden_states_to` for reading an intermediate block). Nothing else goes in `anyjev/backends/`: debiasing and calibration live above the backend and are tested once. Tacit's engines live in `anyjev/tacit.py` and implement `read` and `reason_read`.

‎CHANGELOG.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- **New: `scripts/eval_tacit.py`, the evaluation harness behind the README's Tacit numbers.** It decides
6+
every item of the JevBench public set (from a checkout of github.com/fstandhartinger/jevbench) or the
7+
bev-decision test split through `Tacit` against one or more `vllm serve` replicas, one forward or
8+
adaptive, in blocks of 1,000 decisions (one window of the escalation cap each), and writes the summary
9+
JSON the README tables are read from. The item mapping is the one the published numbers used (checked
10+
item by item on all 46,551 items). `pip install "anyjev[eval]"` adds what it needs.
11+
- **README: results with escalation.** Tacit-9B and Tacit-4B at the default `adaptive=True` settings;
12+
every Tacit number now comes from `bench/results_tacit/2026-10-06/` (`scripts/eval_tacit.py`), which
13+
replaces `bench/results_tacit/2026-10-05/one_forward.json` (the maintainers' transformers evaluation).
14+
Through `vllm serve` one forward stays within bf16 noise of that evaluation on bev-decision (−31 to
15+
+80 decisions of 46,320) and differs by at most three items of 231 on JevBench (Tacit-8B 0.749 → 0.736,
16+
Tacit-1.7B 0.623 → 0.619; the other three unchanged). With escalation: Tacit-9B JevBench 0.887, bev-decision
17+
0.755; Tacit-4B 0.758 and 0.704, with 14–18% of decisions sent to reasoning.
18+
- `Tacit`: a `score` decision can number its levels from 0 (`first_level=0`, also in the gateway's
19+
JSON), for rubrics that count from 0; the prompt shows and reads `0..K-1`. Default unchanged (1).
20+
321
## 0.3.0 (2026-10-06)
422

523
AnyJev now has two directions: **training-free** (`Decider`, levels `raw` and `L0`, any open LLM) and

‎README.md‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ d["answer"], d["probs"], d["route"] # an option, {option: probability}, "one_
5151
```
5252

5353
`kind` is `"choice"` (default), `"yes_no"` or `"score"` (`options` are ordered levels, lowest
54-
first). `decide_batch([...])` takes a list of such dicts.
54+
first, numbered from 1; `first_level=0` numbers them from 0). `decide_batch([...])` takes a list of such dicts.
5555

5656
**Send the hardest decisions to reasoning, with a cap.**
5757

@@ -161,22 +161,24 @@ automate at ≤5% error goes from **7.7% to 46.3%**, with no labels at all.
161161

162162
<div align="center">
163163

164-
| model | base | JevBench public (231) | bev-decision test (46,320) |
165-
|:--|:--|:--:|:--:|
166-
| [Tacit-9B](https://huggingface.co/morriszjm/Tacit-9B) | Qwen3.5-9B | **0.823** | **0.725** |
167-
| [Tacit-8B](https://huggingface.co/morriszjm/Tacit-8B) | Qwen3-8B | 0.749 | 0.662 |
168-
| [Tacit-4B](https://huggingface.co/morriszjm/Tacit-4B) | Qwen3-4B | 0.723 | 0.663 |
169-
| [Tacit-2B](https://huggingface.co/morriszjm/Tacit-2B) | Qwen3.5-2B | 0.671 | 0.626 |
170-
| [Tacit-1.7B](https://huggingface.co/morriszjm/Tacit-1.7B) | Qwen3-1.7B | 0.623 | 0.598 |
164+
| model | base | JevBench public (231) | adaptive | bev-decision test (46,320) | adaptive |
165+
|:--|:--|:--:|:--:|:--:|:--:|
166+
| [Tacit-9B](https://huggingface.co/morriszjm/Tacit-9B) | Qwen3.5-9B | 0.823 | **0.887** <sub>15.2%</sub> | 0.727 | **0.755** <sub>18.1%</sub> |
167+
| [Tacit-8B](https://huggingface.co/morriszjm/Tacit-8B) | Qwen3-8B | 0.736 | — | 0.663 | — |
168+
| [Tacit-4B](https://huggingface.co/morriszjm/Tacit-4B) | Qwen3-4B | 0.723 | **0.758** <sub>14.3%</sub> | 0.663 | **0.704** <sub>17.8%</sub> |
169+
| [Tacit-2B](https://huggingface.co/morriszjm/Tacit-2B) | Qwen3.5-2B | 0.671 | — | 0.626 | — |
170+
| [Tacit-1.7B](https://huggingface.co/morriszjm/Tacit-1.7B) | Qwen3-1.7B | 0.619 | — | 0.598 | — |
171171

172-
<sub>Accuracy with one forward pass per decision (`adaptive=False`), the options in the benchmark's
173-
order, on every item of both test sets ([JevBench](https://github.com/fstandhartinger/jevbench) public
174-
set; [bev-decision](https://huggingface.co/datasets/avbiswas/bev-decision) test split) ·
175-
`bench/results_tacit/2026-10-05/one_forward.json`</sub>
172+
<sub>Accuracy on every item of both sets ([JevBench](https://github.com/fstandhartinger/jevbench) public
173+
set; [bev-decision](https://huggingface.co/datasets/avbiswas/bev-decision) test split), in the order they
174+
are stored. First column: one forward pass per decision (`adaptive=False`). Adaptive: `adaptive=True` at
175+
the defaults (`tau=0.5`, at most 20% of every 1,000 decisions), with the share of decisions sent to
176+
reasoning in small print. Served by `vllm serve` (vLLM 0.17.1), run with `scripts/eval_tacit.py` · `bench/results_tacit/2026-10-06/`</sub>
176177

177178
</div>
178179

179-
Results with escalation (`adaptive=True`) will be published together with the evaluation harness.
180+
On average, escalation adds 567 (JevBench) and 458 (bev-decision) generated tokens per decision for Tacit-9B,
181+
and 305 and 187 for Tacit-4B. Results with escalation for Tacit-8B, Tacit-2B and Tacit-1.7B come next.
180182

181183
## 🧠 How it works
182184

@@ -202,7 +204,8 @@ Every Tacit decision carries its `route`.
202204
- [x] `choice`, `noul` and `score` from one prefill; L0 with zero labels
203205
- [x] The rotation budget: L0 at about 7 of 18 rotations, with a stopping threshold certified without labels
204206
- [x] **Tacit-1.7B, 2B, 4B, 8B and 9B** on Hugging Face; `Tacit` in the library on transformers, vLLM and `vllm serve`, with capped escalation to reasoning and an HTTP gateway
205-
- [ ] 🚧 Results with escalation on JevBench and bev-decision, released with the evaluation harness
207+
- [x] The evaluation harness (`scripts/eval_tacit.py`); results with escalation for Tacit-9B and Tacit-4B
208+
- [ ] 🚧 Results with escalation for Tacit-8B, Tacit-2B and Tacit-1.7B
206209
- [ ] 🚧 Label-free early exit: read a decision from part of the model's depth, choosing the depth by agreement with the full model
207210
- [ ] **Agent-loop evaluation**: the same decisions inside a real agent
208211
- [ ] More log-prob backends (SGLang, llama.cpp, MLX, Ollama), span readout beyond 26 options

‎README.zh-CN.md‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ d = tacit.decide(state="Customer: my package was due last Monday and it still ha
5050
d["answer"], d["probs"], d["route"] # 一个选项、{选项: 概率}、"one_forward" 或 "cot"
5151
```
5252

53-
`kind` 可以是 `"choice"`(默认)、`"yes_no"` 或 `"score"`(这时 `options` 是从低到高排好的档位)。
53+
`kind` 可以是 `"choice"`(默认)、`"yes_no"` 或 `"score"`(这时 `options` 是从低到高排好的档位,从 1 开始编号;`first_level=0` 则从 0 开始)。
5454
`decide_batch([...])` 接收由这样的 dict 组成的列表。
5555

5656
**把最难的决策交给推理,并设上限。**
@@ -152,21 +152,22 @@ vLLM 上每秒决策数是原来的 2.2×,transformers 上是 2.3×–2.7×,
152152

153153
<div align="center">
154154

155-
| 模型 | 基座 | JevBench 公开集(231) | bev-decision 测试集(46,320) |
156-
|:--|:--|:--:|:--:|
157-
| [Tacit-9B](https://huggingface.co/morriszjm/Tacit-9B) | Qwen3.5-9B | **0.823** | **0.725** |
158-
| [Tacit-8B](https://huggingface.co/morriszjm/Tacit-8B) | Qwen3-8B | 0.749 | 0.662 |
159-
| [Tacit-4B](https://huggingface.co/morriszjm/Tacit-4B) | Qwen3-4B | 0.723 | 0.663 |
160-
| [Tacit-2B](https://huggingface.co/morriszjm/Tacit-2B) | Qwen3.5-2B | 0.671 | 0.626 |
161-
| [Tacit-1.7B](https://huggingface.co/morriszjm/Tacit-1.7B) | Qwen3-1.7B | 0.623 | 0.598 |
155+
| 模型 | 基座 | JevBench 公开集(231) | 分流 | bev-decision 测试集(46,320) | 分流 |
156+
|:--|:--|:--:|:--:|:--:|:--:|
157+
| [Tacit-9B](https://huggingface.co/morriszjm/Tacit-9B) | Qwen3.5-9B | 0.823 | **0.887** <sub>15.2%</sub> | 0.727 | **0.755** <sub>18.1%</sub> |
158+
| [Tacit-8B](https://huggingface.co/morriszjm/Tacit-8B) | Qwen3-8B | 0.736 | — | 0.663 | — |
159+
| [Tacit-4B](https://huggingface.co/morriszjm/Tacit-4B) | Qwen3-4B | 0.723 | **0.758** <sub>14.3%</sub> | 0.663 | **0.704** <sub>17.8%</sub> |
160+
| [Tacit-2B](https://huggingface.co/morriszjm/Tacit-2B) | Qwen3.5-2B | 0.671 | — | 0.626 | — |
161+
| [Tacit-1.7B](https://huggingface.co/morriszjm/Tacit-1.7B) | Qwen3-1.7B | 0.619 | — | 0.598 | — |
162162

163-
<sub>每个决策一次前向(`adaptive=False`),选项按基准里给的顺序,两个测试集全部样本上的准确率
164-
([JevBench](https://github.com/fstandhartinger/jevbench) 公开集;[bev-decision](https://huggingface.co/datasets/avbiswas/bev-decision) 测试集)·
165-
`bench/results_tacit/2026-10-05/one_forward.json`</sub>
163+
<sub>两个测试集全部样本上的准确率([JevBench](https://github.com/fstandhartinger/jevbench) 公开集;
164+
[bev-decision](https://huggingface.co/datasets/avbiswas/bev-decision) 测试集),按数据集存储顺序。第一列:每个决策一次前向
165+
(`adaptive=False`)。分流:`adaptive=True`,默认参数(`tau=0.5`,每 1,000 个决策里最多 20% 转去推理),小字是转去推理的比例。
166+
用 `vllm serve`(vLLM 0.17.1)服务,`scripts/eval_tacit.py` 运行 · `bench/results_tacit/2026-10-06/`</sub>
166167

167168
</div>
168169

169-
打开转推理(`adaptive=True`)的结果,会和评测代码一起发布。
170+
打开转推理后,平均每个决策多生成的 token:Tacit-9B 在 JevBench 上 567 个、bev-decision 上 458 个;Tacit-4B 分别是 305 和 187 个。Tacit-8B、2B、1.7B 打开转推理的结果随后补上。
170171

171172
## 🧠 怎么读出一个决策
172173

@@ -191,7 +192,8 @@ vLLM 上每秒决策数是原来的 2.2×,transformers 上是 2.3×–2.7×,
191192
- [x] `choice`、`noul`、`score` 一次 prefill 读出;零标签的 L0
192193
- [x] 旋转预算:L0 平均读 18 次轮换里的 7 次左右,停止门槛不用标签就能证明
193194
- [x] **Tacit-1.7B、2B、4B、8B、9B** 发布在 Hugging Face;库里的 `Tacit` 支持 transformers、vLLM 和 `vllm serve`,带有上限的转推理和 HTTP 网关
194-
- [ ] 🚧 JevBench 和 bev-decision 上打开转推理的结果,和评测代码一起发布
195+
- [x] 评测代码(`scripts/eval_tacit.py`);Tacit-9B 和 Tacit-4B 打开转推理的结果
196+
- [ ] 🚧 Tacit-8B、2B、1.7B 打开转推理的结果
195197
- [ ] 🚧 无标签的提前退出:只用模型的一部分层读出决策,层数按与完整模型的一致率来选
196198
- [ ] **Agent 循环里的评测**:把同样的决策放进真实的 agent 里
197199
- [ ] 更多 log-prob 后端(SGLang、llama.cpp、MLX、Ollama),以及超过 26 个选项的 span 读法

‎ROADMAP.md‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,16 @@ The version in brackets is the one an item first shipped in.
3030
Opt-in until the L0 tables are regenerated at the new defaults.
3131
- [x] **Tacit models on Hugging Face** (0.3). Tacit-1.7B, 2B, 4B, 8B and 9B, one forward per decision,
3232
in the [Tacit collection](https://huggingface.co/collections/morriszjm/tacit-6ac41d0b50af9e5417c5c234);
33-
accuracy on JevBench (public, 231) and bev-decision (test, 46,320) in
34-
`bench/results_tacit/2026-10-05/one_forward.json`.
33+
accuracy on JevBench (public, 231) and bev-decision (test, 46,320) in `bench/results_tacit/2026-10-06/`.
3534
- [x] **`Tacit` in the library** (0.3). `anyjev.Tacit` on transformers, in-process vLLM and a running
3635
`vllm serve`; `adaptive=True` sends decisions whose top-two margin is under `tau` to the model's own
3736
reasoning and reads the answer as a label distribution afterwards; at most `max_cot_share` of the
3837
last `cot_window` decisions escalate. `python -m anyjev.serve` puts an HTTP gateway with one shared
3938
cap in front of a vLLM server. Tested on CPU with a fake engine (`tests/test_tacit.py`).
39+
- [x] **The evaluation harness, and results with escalation** (unreleased). `scripts/eval_tacit.py` decides
40+
every item of the JevBench public set and the bev-decision test split through `Tacit` against `vllm serve`,
41+
one forward or adaptive, and writes the JSON the README tables are read from (`bench/results_tacit/2026-10-06/`):
42+
one forward for all five Tacit models, adaptive for Tacit-9B and Tacit-4B.
4043
- [x] **Releases on PyPI** (0.1.0, 0.2.0). Each was installed from the index into a fresh venv, with
4144
`__version__` matching `pyproject.toml`.
4245

@@ -46,11 +49,9 @@ test-time adaptation, `observe`), the shipped heads, the demos and the benchmark
4649

4750
## Now
4851

49-
- [ ] **Results with escalation, and the evaluation harness.** Why: the published Tacit numbers are one
50-
forward; `adaptive=True` is the setting most deployments will use, and every number must be
51-
reproducible from shipped code. *Done:* the harness runs JevBench and bev-decision from one command
52-
per model, its JSON is committed with the share of decisions escalated and the generated tokens per
53-
decision, and both READMEs quote it.
52+
- [ ] **Results with escalation for Tacit-8B, Tacit-2B and Tacit-1.7B.** Why: the READMEs show escalation
53+
for Tacit-9B and Tacit-4B only. *Done:* `scripts/eval_tacit.py --adaptive` on both sets for each, the JSON
54+
in `bench/results_tacit/`, and the empty cells filled.
5455
- [ ] **Label-free early exit** (0.3.1). Why: a decision often does not need the model's full depth. A
5556
map into the final layer's basis, fitted without labels, reads the decision from a truncated
5657
forward, and the depth is chosen by agreement with the full model's answer. The hidden-state

‎THIRD_PARTY.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
| What | Where | License | Used for |
44
|---|---|---|---|
5-
| JevBench, public set | https://github.com/fstandhartinger/jevbench (`datasets/public`: easy, original, hard; 231 items) | MIT | Tacit results, `bench/results_tacit/` |
6-
| bev-decision | https://huggingface.co/datasets/avbiswas/bev-decision (default config, test split) | not stated on the dataset card (`license: unknown`) | Tacit results, `bench/results_tacit/`; evaluation only |
5+
| JevBench, public set | https://github.com/fstandhartinger/jevbench (`datasets/public`: easy, original, hard; 231 items) | MIT | Tacit results, `scripts/eval_tacit.py` (from a local checkout), `bench/results_tacit/` |
6+
| bev-decision | https://huggingface.co/datasets/avbiswas/bev-decision (default config, test split) | not stated on the dataset card (`license: unknown`) | Tacit results, `scripts/eval_tacit.py`, `bench/results_tacit/`; evaluation only |
77
| banking77 (mteb parquet mirror) | https://huggingface.co/datasets/mteb/banking77 | CC-BY-4.0 | the raw / L0 table (`banking20`, `bench/results_v01/`) and the README GIF |
88
| 20 Newsgroups (SetFit mirror) | https://huggingface.co/datasets/SetFit/20_newsgroups | see dataset card | `newsgroups` in `bench/results_v01/` and `bench/results_layout/` |
99
| deepset/prompt-injections | https://huggingface.co/datasets/deepset/prompt-injections | Apache-2.0 | `injection` in `bench/results_v01/` |

‎anyjev/tacit.py‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,14 @@
4343
import numpy as np
4444

4545
from anyjev.question import Question
46-
from anyjev.readout import DEFAULT_SYSTEM, build_prompt, label_ids_for_perm, render_chat, resolve_labels
46+
from anyjev.readout import (
47+
DEFAULT_SYSTEM,
48+
build_prompt,
49+
label_ids_for_perm,
50+
map_label_tokens,
51+
render_chat,
52+
resolve_labels,
53+
)
4754

4855
COT_SYSTEM = "You are a careful assistant that answers questions about a given context."
4956
KINDS = {"choice": "choice", "yes_no": "noul", "noul": "noul", "score": "score"}
@@ -349,10 +356,12 @@ def from_pretrained(cls, repo: str, *, engine: str = "transformers", dtype: str
349356

350357
# ------------------------------------------------------------------ public API
351358
def decide(self, state: str, question: str, options: Optional[Sequence[str]] = None,
352-
kind: str = "choice") -> Dict[str, Any]:
359+
kind: str = "choice", first_level: int = 1) -> Dict[str, Any]:
353360
"""One decision. kind: "choice" (pick one of `options`), "yes_no", or "score" (`options` are
354-
ordered levels, lowest first)."""
355-
return self.decide_batch([dict(state=state, question=question, options=options, kind=kind)])[0]
361+
ordered levels, lowest first, numbered from `first_level`: 1 by default, 0 for a rubric that
362+
counts from 0)."""
363+
return self.decide_batch([dict(state=state, question=question, options=options, kind=kind,
364+
first_level=first_level)])[0]
356365

357366
def decide_batch(self, items: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
358367
"""Each item: dict(state=..., question=..., options=[...], kind=...). Within a batch the least
@@ -414,7 +423,15 @@ def _normalise(self, it: Dict[str, Any]) -> Dict[str, Any]:
414423
if "question" not in it:
415424
raise ValueError("a decision needs a question")
416425
q = make_question(it["question"], it.get("options"), it.get("kind", "choice"))
417-
labels, ids = resolve_labels(self.tok, q)
426+
first = it.get("first_level", 1)
427+
if first not in (0, 1):
428+
raise ValueError("first_level must be 0 or 1")
429+
if q.kind == "score" and first == 0:
430+
# levels shown and read as 0..K-1, for rubrics that number them that way
431+
labels = [str(j) for j in range(q.k)]
432+
ids = map_label_tokens(self.tok, labels)
433+
else:
434+
labels, ids = resolve_labels(self.tok, q)
418435
return dict(state=str(it.get("state", "")), q=q, labels=labels,
419436
ids=label_ids_for_perm(q, ids, list(range(q.k))))
420437

0 commit comments

Comments
 (0)