tevro 0.1.0__tar.gz

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.
tevro-0.1.0/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ *.egg-info
7
+
8
+ # Virtual environments
9
+ .venv
10
+
11
+ # Training runs (LoRA + head checkpoints, metrics)
12
+ runs/
13
+
14
+ # Local environments and data
15
+ .env
16
+ data/
17
+ .DS_Store
tevro-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kartik Gadad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tevro-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.5
2
+ Name: tevro
3
+ Version: 0.1.0
4
+ Summary: A small decision model: picks one option and gives a probability for every option, in one forward pass
5
+ Project-URL: Homepage, https://github.com/kartikg-gk/tevro
6
+ Project-URL: Model, https://huggingface.co/kartikg1/tevro-decider
7
+ Author: Kartik Gadad
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: calibration,classification,decision model,lora,qwen3
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: huggingface-hub>=0.25.0
15
+ Requires-Dist: peft>=0.21.0
16
+ Requires-Dist: safetensors>=0.6.0
17
+ Requires-Dist: torch>=2.14.0
18
+ Requires-Dist: transformers>=5.17.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Tevro
22
+
23
+ Tevro is a small decision model, and this repository holds its training code and a pip package for running it. It reads a state, a question and a fixed set of options, and returns one probability per option in a single forward pass. It handles three question types: multiple choice, yes/no, and ordinal scores.
24
+
25
+ ![Tevro architecture](https://raw.githubusercontent.com/kartikg-gk/tevro/main/arch.png)
26
+
27
+ ## Use the model
28
+
29
+ The trained model is on Hugging Face: **[kartikg1/tevro-decider](https://huggingface.co/kartikg1/tevro-decider)**. It needs Python 3.12 and about 3 GB of free memory; a GPU is optional.
30
+
31
+ ```bash
32
+ pip install tevro
33
+ tevro predict request.json --device cpu
34
+ ```
35
+
36
+ `tevro predict` reads one request from a JSON file (examples below) and prints the answer. The weights and the base model are downloaded on the first run. Use `--device cuda` on an NVIDIA GPU. From Python:
37
+
38
+ ```python
39
+ import tevro
40
+
41
+ model = tevro.load() # downloads kartikg1/tevro-decider on first use
42
+ model.answer("Parcel A is on shelf B2. Parcel B is on shelf C3.",
43
+ {"type": "choice", "instructions": "Which shelf holds Parcel A?",
44
+ "criteria": {"a": "B2", "b": "C3"}})
45
+ ```
46
+
47
+ The pip package only runs the model. Training code, data tools and evaluation scripts live in this repository.
48
+
49
+ **Multiple choice**
50
+
51
+ ```json
52
+ {"state": "Parcel A is on shelf B2. Parcel B is on shelf C3.",
53
+ "question": {"type": "choice", "instructions": "Which shelf holds Parcel A?",
54
+ "criteria": {"a": "B2", "b": "C3"}}}
55
+ ```
56
+ ```json
57
+ {"confidence": 0.99996, "abstain": false, "choice": "a",
58
+ "probabilities": {"a": 0.99996, "b": 0.00004}}
59
+ ```
60
+
61
+ **Yes/no**
62
+
63
+ ```json
64
+ {"state": "Refunds need a receipt and a purchase within 30 days. The customer bought it 12 days ago and has no receipt.",
65
+ "question": {"type": "noul", "instructions": "Should the refund be issued?"}}
66
+ ```
67
+ ```json
68
+ {"confidence": 0.933, "abstain": false, "noul": 0.067}
69
+ ```
70
+
71
+ `noul` is the probability of yes.
72
+
73
+ **Score**
74
+
75
+ ```json
76
+ {"state": "The app crashes for every user when they open the payments page. No data is lost.",
77
+ "question": {"type": "score", "instructions": "How severe is this bug?",
78
+ "criteria": ["Cosmetic only", "Minor, with a workaround", "A core feature is blocked", "Data loss or harm"]}}
79
+ ```
80
+ ```json
81
+ {"confidence": 0.431, "abstain": false, "score": 1.65,
82
+ "probabilities": [0.019, 0.431, 0.428, 0.122]}
83
+ ```
84
+
85
+ `score` is the expected level (0 to 3 here). A low `confidence` like this one means the model is unsure; send those cases to a person.
86
+
87
+ ## Results
88
+
89
+ | Evaluation | Accuracy |
90
+ | --- | ---: |
91
+ | Separate held-out test split (5,000) | 80.2% |
92
+ | — multiple choice / yes-no / score | 80.8% / 88.0% / 65.8% |
93
+ | Training dataset's test split (5,000) | 80.9% |
94
+ | JevBench public (231) | 57.6% |
95
+ | — easy / standard / hard | 97.9% / 66.7% / 34.2% |
96
+
97
+ On the held-out split, yes/no AUC is 0.953 and expected calibration error is 0.037. At confidence 0.95 or higher the model answers 48.9% of questions and is right on 98.0% of them.
98
+
99
+ ## Set up for training
100
+
101
+ ```bash
102
+ git clone https://github.com/kartikg-gk/tevro
103
+ cd tevro
104
+ uv sync
105
+ uv run pytest -q tests
106
+ ```
107
+
108
+ Python 3.12, managed with [uv](https://docs.astral.sh/uv/). On Windows, `uv sync` installs the CUDA 13.0 build of PyTorch; on Linux it uses the default PyPI build. The tests use a tiny randomly initialised model and run on CPU.
109
+
110
+ ## Train
111
+
112
+ ```bash
113
+ uv run python train.py configs/smoke.yaml
114
+ uv run python train.py configs/check_20k.yaml
115
+ uv run python train.py configs/release_12k.yaml
116
+ ```
117
+
118
+ | Config | What it does | Hardware |
119
+ | --- | --- | --- |
120
+ | `smoke.yaml` | Overfits 10 questions; train accuracy should reach 1.0 | any GPU |
121
+ | `check_20k.yaml` | 20,000 questions, 1,000 test, bf16 weights, micro-batch 2 × 32 | 6 GB laptop GPU, ~45 min |
122
+ | `release_12k.yaml` | The released model: 12,000 updates of 64 questions, fp32 | A100 80 GB, ~6.5 h |
123
+ | `finetune_rules.yaml`, `finetune_v2.yaml` | Short fine-tunes of the released model on a 20K rule-reasoning mix (`--resume` the release checkpoint); ~10 min | A100 80 GB |
124
+
125
+ Each run writes `runs/<name>-<timestamp>/` with the config, `metrics.jsonl`, `summary.json` and checkpoints (`best`, `latest`, `final`; LoRA adapter and head only). Every training log line shows `eta_min`, the projected minutes left. `--resume <run or checkpoint>` reloads the weights and starts a fresh optimizer.
126
+
127
+ ### On Modal
128
+
129
+ ```bash
130
+ modal run --detach modal_app.py --config configs/release_12k.yaml
131
+ modal run modal_app.py::evaluate --checkpoint runs/<run>/checkpoints/final --out eval.txt
132
+ ```
133
+
134
+ `modal_app.py` uploads this folder, keeps `runs/` and the Hugging Face cache on Modal Volumes, runs on an A100 80 GB (set `TEVRO_GPU` to change it), and caps a training job at 9 hours.
135
+
136
+ ## Evaluate
137
+
138
+ ```bash
139
+ uv run python inference.py runs/<run>/checkpoints/final --max_questions 5000
140
+ uv run python jevbench_eval.py runs/<run>/checkpoints/final <jevbench>/datasets/public
141
+ ```
142
+
143
+ The first scores a checkpoint on the training dataset's test split: accuracy per question type, yes/no AUC, ECE, Brier score, reliability and resolution, and accuracy at confidence 0.9 and 0.95. The second scores the 231 public [JevBench](https://github.com/fstandhartinger/jevbench) items by tier.
144
+
145
+ ## Data
146
+
147
+ Each row is one state with one or more questions:
148
+
149
+ ```json
150
+ {
151
+ "state": "Parcel A is on shelf B2. Parcel B is on shelf C3.",
152
+ "questions_json": "{\"q1\": {\"type\": \"choice\", \"instructions\": \"Which shelf holds Parcel A?\", \"criteria\": {\"a\": \"B2\", \"b\": \"C3\"}, \"label\": \"a\"}}"
153
+ }
154
+ ```
155
+
156
+ - `choice`: `criteria` maps option keys to descriptions; `label` is a key.
157
+ - `noul` (yes/no): `label` is `true` or `false`.
158
+ - `score`: `criteria` is an ordered list of levels; `label` is the level index.
159
+
160
+ By default the code loads [kartikg1/tevro-decision-v1](https://huggingface.co/datasets/kartikg1/tevro-decision-v1) (250,000 training and 5,000 test questions; private). Point `data.question_files` at your own parquet files (`train` and `test`) to train on your data. `audit.py` reports label-position, length and negation shortcuts before you spend GPU time.
161
+
162
+ ## Model
163
+
164
+ | Part | Setting |
165
+ | --- | --- |
166
+ | Base | Qwen3-0.6B, first 20 of 28 layers |
167
+ | Adapter | LoRA rank 8, alpha 16, on q/k/v/o of layers 6–19 |
168
+ | Input | task hint + question + state, then each option as its own span starting at the same position, then `The answer is:` |
169
+ | Head | question-type embedding on every token, 2 bidirectional transformer layers (1024-d, 16 heads, dropout 0.1), scalar scorer at each option's last token |
170
+ | Loss | cross-entropy over the options |
171
+ | Optimizer | AdamW, LoRA lr 1e-4, head lr 1e-3, weight decay 0.01, cosine schedule, 5% warmup, gradient clip 1.0 |
172
+
173
+ Options cannot attend to each other, so reordering them only reorders the probabilities. The test suite checks this.
174
+
175
+ ## Repository layout
176
+
177
+ ```
178
+ tevro/ the pip package: copies of network.py and tokenization.py, plus predict.py and cli.py
179
+ tokenization.py input layout: positions, attention mask, read indices
180
+ network.py base model, LoRA, decision head
181
+ dataset.py loading, question expansion, batching
182
+ train.py training loop
183
+ inference.py evaluation metrics and single answers
184
+ calibrate.py temperature fitting, abstention thresholds, calibration metrics
185
+ jevbench_eval.py JevBench public evaluation
186
+ audit.py data shortcut checks
187
+ synthetic.py procedural decision questions
188
+ data_variants.py paired question variations
189
+ reasoning_data.py procedural rule-reasoning questions (policies, payouts, counting)
190
+ build_mix.py builds a 20K fine-tuning mix from generated, public and replayed questions
191
+ llm_items.py writes hard items with an LLM through OpenRouter and keeps those a blind second pass agrees with (needs `pydantic` and `OPENROUTER_API_KEY`)
192
+ modal_app.py Modal training and evaluation
193
+ configs/ run settings
194
+ tests/ layout, invariance, loss and data tests
195
+ ```
196
+
197
+ ## License
198
+
199
+ MIT. The base model, Qwen3-0.6B, is released under its own license.
tevro-0.1.0/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # Tevro
2
+
3
+ Tevro is a small decision model, and this repository holds its training code and a pip package for running it. It reads a state, a question and a fixed set of options, and returns one probability per option in a single forward pass. It handles three question types: multiple choice, yes/no, and ordinal scores.
4
+
5
+ ![Tevro architecture](https://raw.githubusercontent.com/kartikg-gk/tevro/main/arch.png)
6
+
7
+ ## Use the model
8
+
9
+ The trained model is on Hugging Face: **[kartikg1/tevro-decider](https://huggingface.co/kartikg1/tevro-decider)**. It needs Python 3.12 and about 3 GB of free memory; a GPU is optional.
10
+
11
+ ```bash
12
+ pip install tevro
13
+ tevro predict request.json --device cpu
14
+ ```
15
+
16
+ `tevro predict` reads one request from a JSON file (examples below) and prints the answer. The weights and the base model are downloaded on the first run. Use `--device cuda` on an NVIDIA GPU. From Python:
17
+
18
+ ```python
19
+ import tevro
20
+
21
+ model = tevro.load() # downloads kartikg1/tevro-decider on first use
22
+ model.answer("Parcel A is on shelf B2. Parcel B is on shelf C3.",
23
+ {"type": "choice", "instructions": "Which shelf holds Parcel A?",
24
+ "criteria": {"a": "B2", "b": "C3"}})
25
+ ```
26
+
27
+ The pip package only runs the model. Training code, data tools and evaluation scripts live in this repository.
28
+
29
+ **Multiple choice**
30
+
31
+ ```json
32
+ {"state": "Parcel A is on shelf B2. Parcel B is on shelf C3.",
33
+ "question": {"type": "choice", "instructions": "Which shelf holds Parcel A?",
34
+ "criteria": {"a": "B2", "b": "C3"}}}
35
+ ```
36
+ ```json
37
+ {"confidence": 0.99996, "abstain": false, "choice": "a",
38
+ "probabilities": {"a": 0.99996, "b": 0.00004}}
39
+ ```
40
+
41
+ **Yes/no**
42
+
43
+ ```json
44
+ {"state": "Refunds need a receipt and a purchase within 30 days. The customer bought it 12 days ago and has no receipt.",
45
+ "question": {"type": "noul", "instructions": "Should the refund be issued?"}}
46
+ ```
47
+ ```json
48
+ {"confidence": 0.933, "abstain": false, "noul": 0.067}
49
+ ```
50
+
51
+ `noul` is the probability of yes.
52
+
53
+ **Score**
54
+
55
+ ```json
56
+ {"state": "The app crashes for every user when they open the payments page. No data is lost.",
57
+ "question": {"type": "score", "instructions": "How severe is this bug?",
58
+ "criteria": ["Cosmetic only", "Minor, with a workaround", "A core feature is blocked", "Data loss or harm"]}}
59
+ ```
60
+ ```json
61
+ {"confidence": 0.431, "abstain": false, "score": 1.65,
62
+ "probabilities": [0.019, 0.431, 0.428, 0.122]}
63
+ ```
64
+
65
+ `score` is the expected level (0 to 3 here). A low `confidence` like this one means the model is unsure; send those cases to a person.
66
+
67
+ ## Results
68
+
69
+ | Evaluation | Accuracy |
70
+ | --- | ---: |
71
+ | Separate held-out test split (5,000) | 80.2% |
72
+ | — multiple choice / yes-no / score | 80.8% / 88.0% / 65.8% |
73
+ | Training dataset's test split (5,000) | 80.9% |
74
+ | JevBench public (231) | 57.6% |
75
+ | — easy / standard / hard | 97.9% / 66.7% / 34.2% |
76
+
77
+ On the held-out split, yes/no AUC is 0.953 and expected calibration error is 0.037. At confidence 0.95 or higher the model answers 48.9% of questions and is right on 98.0% of them.
78
+
79
+ ## Set up for training
80
+
81
+ ```bash
82
+ git clone https://github.com/kartikg-gk/tevro
83
+ cd tevro
84
+ uv sync
85
+ uv run pytest -q tests
86
+ ```
87
+
88
+ Python 3.12, managed with [uv](https://docs.astral.sh/uv/). On Windows, `uv sync` installs the CUDA 13.0 build of PyTorch; on Linux it uses the default PyPI build. The tests use a tiny randomly initialised model and run on CPU.
89
+
90
+ ## Train
91
+
92
+ ```bash
93
+ uv run python train.py configs/smoke.yaml
94
+ uv run python train.py configs/check_20k.yaml
95
+ uv run python train.py configs/release_12k.yaml
96
+ ```
97
+
98
+ | Config | What it does | Hardware |
99
+ | --- | --- | --- |
100
+ | `smoke.yaml` | Overfits 10 questions; train accuracy should reach 1.0 | any GPU |
101
+ | `check_20k.yaml` | 20,000 questions, 1,000 test, bf16 weights, micro-batch 2 × 32 | 6 GB laptop GPU, ~45 min |
102
+ | `release_12k.yaml` | The released model: 12,000 updates of 64 questions, fp32 | A100 80 GB, ~6.5 h |
103
+ | `finetune_rules.yaml`, `finetune_v2.yaml` | Short fine-tunes of the released model on a 20K rule-reasoning mix (`--resume` the release checkpoint); ~10 min | A100 80 GB |
104
+
105
+ Each run writes `runs/<name>-<timestamp>/` with the config, `metrics.jsonl`, `summary.json` and checkpoints (`best`, `latest`, `final`; LoRA adapter and head only). Every training log line shows `eta_min`, the projected minutes left. `--resume <run or checkpoint>` reloads the weights and starts a fresh optimizer.
106
+
107
+ ### On Modal
108
+
109
+ ```bash
110
+ modal run --detach modal_app.py --config configs/release_12k.yaml
111
+ modal run modal_app.py::evaluate --checkpoint runs/<run>/checkpoints/final --out eval.txt
112
+ ```
113
+
114
+ `modal_app.py` uploads this folder, keeps `runs/` and the Hugging Face cache on Modal Volumes, runs on an A100 80 GB (set `TEVRO_GPU` to change it), and caps a training job at 9 hours.
115
+
116
+ ## Evaluate
117
+
118
+ ```bash
119
+ uv run python inference.py runs/<run>/checkpoints/final --max_questions 5000
120
+ uv run python jevbench_eval.py runs/<run>/checkpoints/final <jevbench>/datasets/public
121
+ ```
122
+
123
+ The first scores a checkpoint on the training dataset's test split: accuracy per question type, yes/no AUC, ECE, Brier score, reliability and resolution, and accuracy at confidence 0.9 and 0.95. The second scores the 231 public [JevBench](https://github.com/fstandhartinger/jevbench) items by tier.
124
+
125
+ ## Data
126
+
127
+ Each row is one state with one or more questions:
128
+
129
+ ```json
130
+ {
131
+ "state": "Parcel A is on shelf B2. Parcel B is on shelf C3.",
132
+ "questions_json": "{\"q1\": {\"type\": \"choice\", \"instructions\": \"Which shelf holds Parcel A?\", \"criteria\": {\"a\": \"B2\", \"b\": \"C3\"}, \"label\": \"a\"}}"
133
+ }
134
+ ```
135
+
136
+ - `choice`: `criteria` maps option keys to descriptions; `label` is a key.
137
+ - `noul` (yes/no): `label` is `true` or `false`.
138
+ - `score`: `criteria` is an ordered list of levels; `label` is the level index.
139
+
140
+ By default the code loads [kartikg1/tevro-decision-v1](https://huggingface.co/datasets/kartikg1/tevro-decision-v1) (250,000 training and 5,000 test questions; private). Point `data.question_files` at your own parquet files (`train` and `test`) to train on your data. `audit.py` reports label-position, length and negation shortcuts before you spend GPU time.
141
+
142
+ ## Model
143
+
144
+ | Part | Setting |
145
+ | --- | --- |
146
+ | Base | Qwen3-0.6B, first 20 of 28 layers |
147
+ | Adapter | LoRA rank 8, alpha 16, on q/k/v/o of layers 6–19 |
148
+ | Input | task hint + question + state, then each option as its own span starting at the same position, then `The answer is:` |
149
+ | Head | question-type embedding on every token, 2 bidirectional transformer layers (1024-d, 16 heads, dropout 0.1), scalar scorer at each option's last token |
150
+ | Loss | cross-entropy over the options |
151
+ | Optimizer | AdamW, LoRA lr 1e-4, head lr 1e-3, weight decay 0.01, cosine schedule, 5% warmup, gradient clip 1.0 |
152
+
153
+ Options cannot attend to each other, so reordering them only reorders the probabilities. The test suite checks this.
154
+
155
+ ## Repository layout
156
+
157
+ ```
158
+ tevro/ the pip package: copies of network.py and tokenization.py, plus predict.py and cli.py
159
+ tokenization.py input layout: positions, attention mask, read indices
160
+ network.py base model, LoRA, decision head
161
+ dataset.py loading, question expansion, batching
162
+ train.py training loop
163
+ inference.py evaluation metrics and single answers
164
+ calibrate.py temperature fitting, abstention thresholds, calibration metrics
165
+ jevbench_eval.py JevBench public evaluation
166
+ audit.py data shortcut checks
167
+ synthetic.py procedural decision questions
168
+ data_variants.py paired question variations
169
+ reasoning_data.py procedural rule-reasoning questions (policies, payouts, counting)
170
+ build_mix.py builds a 20K fine-tuning mix from generated, public and replayed questions
171
+ llm_items.py writes hard items with an LLM through OpenRouter and keeps those a blind second pass agrees with (needs `pydantic` and `OPENROUTER_API_KEY`)
172
+ modal_app.py Modal training and evaluation
173
+ configs/ run settings
174
+ tests/ layout, invariance, loss and data tests
175
+ ```
176
+
177
+ ## License
178
+
179
+ MIT. The base model, Qwen3-0.6B, is released under its own license.
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tevro"
7
+ version = "0.1.0"
8
+ description = "A small decision model: picks one option and gives a probability for every option, in one forward pass"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Kartik Gadad" }]
14
+ keywords = ["decision model", "classification", "lora", "qwen3", "calibration"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
18
+ ]
19
+ dependencies = [
20
+ "huggingface_hub>=0.25.0",
21
+ "peft>=0.21.0",
22
+ "safetensors>=0.6.0",
23
+ "torch>=2.14.0",
24
+ "transformers>=5.17.0",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/kartikg-gk/tevro"
29
+ Model = "https://huggingface.co/kartikg1/tevro-decider"
30
+
31
+ [project.scripts]
32
+ tevro = "tevro.cli:main"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["tevro"]
36
+
37
+ [tool.hatch.build.targets.sdist]
38
+ include = ["tevro", "README.md", "LICENSE", "pyproject.toml"]
39
+
40
+ # Training and data tools; not part of the published package. uv installs both groups by default.
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=8.0.0",
44
+ ]
45
+ train = [
46
+ "datasets>=5.0.1",
47
+ "pyyaml>=6.0.3",
48
+ ]
49
+
50
+ [tool.uv]
51
+ default-groups = ["dev", "train"]
52
+
53
+ # Windows has no CUDA torch on PyPI; take it from the PyTorch CUDA 13.0 index (Linux keeps PyPI's CUDA build)
54
+ [tool.uv.sources]
55
+ torch = [{ index = "pytorch-cu130", marker = "sys_platform == 'win32'" }]
56
+
57
+ [[tool.uv.index]]
58
+ name = "pytorch-cu130"
59
+ url = "https://download.pytorch.org/whl/cu130"
60
+ explicit = true
@@ -0,0 +1,13 @@
1
+ """Tevro: a small decision model. Give it a state, a question and options; it returns a probability per option."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["DEFAULT_MODEL", "Decider", "load"]
5
+
6
+
7
+ def __getattr__(name):
8
+ # Lazy, so `import tevro` and `tevro --help` do not load the model stack or the tokenizer
9
+ if name in __all__:
10
+ from tevro import predict
11
+
12
+ return getattr(predict, name)
13
+ raise AttributeError(f"module 'tevro' has no attribute {name!r}")
@@ -0,0 +1,24 @@
1
+ """Command line: `tevro predict request.json`."""
2
+ import argparse
3
+ import json
4
+ import sys
5
+
6
+
7
+ def main(argv=None):
8
+ parser = argparse.ArgumentParser(prog="tevro", description="Tevro decision model")
9
+ sub = parser.add_subparsers(dest="command", required=True)
10
+ p = sub.add_parser("predict", help="answer one question from a JSON file")
11
+ p.add_argument("input", help='JSON file with "state" and "question" (use - to read stdin)')
12
+ p.add_argument("--model", default=None, help="Hugging Face repo id or local folder (default: kartikg1/tevro-decider)")
13
+ p.add_argument("--device", default=None, help="cpu, cuda or mps (default: best available)")
14
+ args = parser.parse_args(argv)
15
+
16
+ from tevro.predict import DEFAULT_MODEL, load # imported here so --help does not load the model stack
17
+
18
+ request = json.load(sys.stdin if args.input == "-" else open(args.input, encoding="utf-8"))
19
+ model = load(args.model or DEFAULT_MODEL, args.device)
20
+ print(json.dumps(model.answer(request["state"], request["question"]), indent=2))
21
+
22
+
23
+ if __name__ == "__main__":
24
+ main()
@@ -0,0 +1,64 @@
1
+ """Turns question dicts into model inputs. Same logic the training code uses, without the dataset dependencies."""
2
+ import torch
3
+
4
+ from tevro.tokenization import tokenizer
5
+
6
+ TASK_TYPES = {"choice": 0, "noul": 1, "score": 2}
7
+ TASK_NAMES = {v: k for k, v in TASK_TYPES.items()}
8
+
9
+
10
+ def question_to_choices(question):
11
+ """Turns any of the 3 question types into a list of option strings (+ label index if present)."""
12
+ if question["type"] == "choice":
13
+ keys = list(question["criteria"])
14
+ choices = [f"{key}: {question['criteria'][key]}" if question["criteria"][key] else key for key in keys]
15
+ label = keys.index(question["label"]) if "label" in question else None
16
+ elif question["type"] == "score":
17
+ # Choices share position ids, so the level index is written into the text to keep the ordering
18
+ choices = [f"{i}: {level}" for i, level in enumerate(question["criteria"])]
19
+ label = question.get("label")
20
+ else: # noul -> P(Yes)
21
+ choices = ["No", "Yes"]
22
+ label = int(question["label"]) if "label" in question else None
23
+ return choices, label
24
+
25
+
26
+ def collate_fn(batch):
27
+ B = len(batch)
28
+ L = max(len(ex["input_ids"]) for ex in batch)
29
+ C = max(len(ex["choice_read_idx"]) for ex in batch)
30
+
31
+ input_ids = torch.full((B, L), tokenizer.pad_token_id, dtype=torch.long)
32
+ position_ids = torch.zeros((B, L), dtype=torch.long)
33
+ # Padded query rows attend only to themselves, otherwise their softmax row is all -inf -> NaN
34
+ attention_mask = torch.eye(L).repeat(B, 1, 1)
35
+ token_mask = torch.zeros((B, L), dtype=torch.bool)
36
+ choice_read_idx = torch.zeros((B, C), dtype=torch.long)
37
+ choice_mask = torch.zeros((B, C), dtype=torch.bool)
38
+ answer_end_idx = torch.zeros(B, dtype=torch.long)
39
+
40
+ for b, ex in enumerate(batch):
41
+ n, c = len(ex["input_ids"]), len(ex["choice_read_idx"])
42
+ token_mask[b, :n] = True
43
+ input_ids[b, :n] = torch.tensor(ex["input_ids"])
44
+ position_ids[b, :n] = torch.tensor(ex["position_ids"])
45
+ attention_mask[b, :n, :n] = ex["attention_mask"]
46
+ choice_read_idx[b, :c] = torch.tensor(ex["choice_read_idx"])
47
+ choice_mask[b, :c] = True
48
+ answer_end_idx[b] = ex["answer_end_idx"]
49
+
50
+ attention_mask = torch.where(attention_mask == 0, -float("inf"), 0.0).unsqueeze(1) # [B, 1, L, L]
51
+
52
+ out = {
53
+ "input_ids": input_ids,
54
+ "position_ids": position_ids,
55
+ "attention_mask": attention_mask,
56
+ "token_mask": token_mask,
57
+ "choice_read_idx": choice_read_idx,
58
+ "choice_mask": choice_mask,
59
+ "answer_end_idx": answer_end_idx,
60
+ "task_type": torch.tensor([ex["task_type"] for ex in batch], dtype=torch.long),
61
+ }
62
+ if all(ex["label"] is not None for ex in batch):
63
+ out["labels"] = torch.tensor([ex["label"] for ex in batch], dtype=torch.long)
64
+ return out
@@ -0,0 +1,137 @@
1
+ import math
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+ from peft import LoraConfig, get_peft_model
6
+ from transformers import Qwen3Model
7
+
8
+
9
+ class ChoiceHead(nn.Module):
10
+ """Self-attention over [task token, choice vectors..., answer vector], then a bilinear score per choice.
11
+
12
+ No positional encoding: permuting the choices permutes the logits, so any number of choices works.
13
+ """
14
+
15
+ def __init__(self, hidden_dim, num_task_types, new_dim=512, num_layers=2, num_heads=8):
16
+ super().__init__()
17
+ self.new_dim = new_dim
18
+ self.task_embedding = nn.Embedding(num_task_types, new_dim)
19
+ self.input_proj = nn.Linear(hidden_dim, new_dim)
20
+ layer = nn.TransformerEncoderLayer(new_dim, num_heads, 4 * new_dim, dropout=0.0,
21
+ activation="gelu", batch_first=True, norm_first=True)
22
+ self.layers = nn.TransformerEncoder(layer, num_layers, enable_nested_tensor=False)
23
+ self.final_norm = nn.LayerNorm(new_dim)
24
+ self.answer_proj = nn.Linear(new_dim, new_dim)
25
+ self.choice_proj = nn.Linear(new_dim, new_dim)
26
+
27
+ def forward(self, choice_embeddings, answer_embedding, choice_mask, task_type):
28
+ # choice_embeddings: [B, C, H], answer_embedding: [B, H], choice_mask: [B, C], task_type: [B]
29
+ B = choice_embeddings.shape[0]
30
+ x = torch.cat([
31
+ self.task_embedding(task_type).unsqueeze(1),
32
+ self.input_proj(choice_embeddings),
33
+ self.input_proj(answer_embedding).unsqueeze(1),
34
+ ], dim=1) # [B, C+2, D]
35
+
36
+ always_valid = torch.ones(B, 1, dtype=torch.bool, device=choice_mask.device)
37
+ ignore = ~torch.cat([always_valid, choice_mask, always_valid], dim=1)
38
+ x = self.final_norm(self.layers(x, src_key_padding_mask=ignore))
39
+
40
+ answer = self.answer_proj(x[:, -1]) # [B, D]
41
+ choices = self.choice_proj(x[:, 1:-1]) # [B, C, D]
42
+ logits = torch.einsum("bd,bcd->bc", answer, choices) / math.sqrt(self.new_dim)
43
+ return logits.masked_fill(~choice_mask, -float("inf"))
44
+
45
+ def score(self, hidden, token_mask, choice_read_idx, choice_mask, answer_end_idx, task_type):
46
+ rows = torch.arange(hidden.shape[0], device=hidden.device)
47
+ return self(hidden[rows[:, None], choice_read_idx], hidden[rows, answer_end_idx], choice_mask, task_type)
48
+
49
+
50
+ class PostHead(nn.Module):
51
+ """Decision head over every token: type embedding added to all hidden states, a few bidirectional
52
+ transformer layers over the whole sequence, then a scalar scorer at each choice's read token.
53
+
54
+ No answer token is needed: the bidirectional layers let every choice see the state and the other
55
+ choices before scoring. No positional encoding, and the trunk gives choices shared positions,
56
+ so permuting the choices permutes the logits.
57
+ """
58
+
59
+ def __init__(self, hidden_dim, num_task_types, num_layers=2, dropout=0.1):
60
+ super().__init__()
61
+ num_heads = max(1, hidden_dim // 64)
62
+ self.task_embedding = nn.Embedding(num_task_types, hidden_dim)
63
+ layer = nn.TransformerEncoderLayer(hidden_dim, num_heads, 4 * hidden_dim, dropout,
64
+ batch_first=True, norm_first=True)
65
+ self.layers = nn.TransformerEncoder(layer, num_layers, enable_nested_tensor=False)
66
+ self.scorer = nn.Sequential(nn.LayerNorm(hidden_dim), nn.Linear(hidden_dim, hidden_dim), nn.GELU(),
67
+ nn.Linear(hidden_dim, 1))
68
+
69
+ def score(self, hidden, token_mask, choice_read_idx, choice_mask, answer_end_idx, task_type):
70
+ h = hidden + self.task_embedding(task_type)[:, None, :]
71
+ h = self.layers(h, src_key_padding_mask=~token_mask)
72
+ rows = torch.arange(h.shape[0], device=h.device)
73
+ logits = self.scorer(h[rows[:, None], choice_read_idx]).squeeze(-1) # [B, C]
74
+ return logits.masked_fill(~choice_mask, -float("inf"))
75
+
76
+
77
+ class TevroNetwork(nn.Module):
78
+ def __init__(self, backbone, head):
79
+ super().__init__()
80
+ self.backbone = backbone
81
+ self.head = head
82
+
83
+ def forward(self, input_ids, position_ids, attention_mask, token_mask, choice_read_idx, choice_mask,
84
+ answer_end_idx, task_type, **kwargs):
85
+ output = self.backbone(input_ids=input_ids, position_ids=position_ids, attention_mask=attention_mask,
86
+ use_cache=False)
87
+ hidden = output.last_hidden_state.float()
88
+ # Head runs in fp32 even under bf16 autocast: bf16 logits are too coarse for a stable loss
89
+ with torch.autocast(device_type=hidden.device.type, enabled=False):
90
+ return self.head.score(hidden, token_mask, choice_read_idx, choice_mask, answer_end_idx, task_type)
91
+
92
+
93
+ def load_backbone(model_name, num_layers=None, dtype="float32"):
94
+ # Only the first num_layers decoder layers are built and loaded (null = all of them).
95
+ # bfloat16 halves the frozen weights for small GPUs; compute already runs in bf16 under autocast
96
+ overrides = {} if num_layers is None else {"num_hidden_layers": num_layers}
97
+ return Qwen3Model.from_pretrained(model_name, dtype=getattr(torch, dtype), **overrides)
98
+
99
+
100
+ def lora_target_regex(num_layers, last_k_layers):
101
+ layer_ids = "|".join(str(i) for i in range(num_layers - last_k_layers, num_layers))
102
+ return rf".*layers\.({layer_ids})\.self_attn\.(q_proj|k_proj|v_proj|o_proj)"
103
+
104
+
105
+ def add_lora(backbone, r, alpha, dropout, last_k_layers):
106
+ if last_k_layers == 0: # frozen Qwen, only the head trains
107
+ backbone.requires_grad_(False)
108
+ return backbone
109
+ config = LoraConfig(
110
+ r=r,
111
+ lora_alpha=alpha,
112
+ lora_dropout=dropout,
113
+ target_modules=lora_target_regex(backbone.config.num_hidden_layers, last_k_layers),
114
+ )
115
+ return get_peft_model(backbone, config)
116
+
117
+
118
+ def build_network(model_name, num_layers, lora_config, head_config, num_task_types, backbone=None,
119
+ dtype="float32"):
120
+ if backbone is None:
121
+ backbone = load_backbone(model_name, num_layers, dtype)
122
+ backbone = add_lora(backbone, lora_config["r"], lora_config["alpha"], lora_config["dropout"],
123
+ lora_config["last_k_layers"])
124
+ head = build_head(head_config, backbone.config.hidden_size, num_task_types)
125
+ return TevroNetwork(backbone, head)
126
+
127
+
128
+ HEADS = {"choicehead": ChoiceHead, "posthead": PostHead}
129
+
130
+
131
+ def build_head(head_config, hidden_dim, num_task_types):
132
+ """head.type: choicehead = attention over [task, choices, answer token] + bilinear score |
133
+ posthead = bidirectional layers over every token + scalar scorer per choice."""
134
+ head_config = dict(head_config)
135
+ head_type = head_config.pop("type", "choicehead")
136
+ head_config.pop("num_task_types", None)
137
+ return HEADS[head_type](hidden_dim, num_task_types, **head_config)
@@ -0,0 +1,85 @@
1
+ """Load a trained Tevro model and answer one question at a time."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import torch
6
+ from huggingface_hub import snapshot_download
7
+ from peft import PeftModel
8
+ from safetensors.torch import load_file
9
+
10
+ from tevro.inputs import TASK_TYPES, collate_fn, question_to_choices
11
+ from tevro.network import TevroNetwork, build_head, load_backbone
12
+ from tevro.tokenization import encode_example
13
+
14
+ DEFAULT_MODEL = "kartikg1/tevro-decider"
15
+
16
+
17
+ def get_device():
18
+ if torch.cuda.is_available():
19
+ return torch.device("cuda")
20
+ if torch.backends.mps.is_available():
21
+ return torch.device("mps")
22
+ return torch.device("cpu")
23
+
24
+
25
+ def resolve(model):
26
+ """A local folder is used as is; anything else is treated as a Hugging Face repo id."""
27
+ if Path(model).is_dir():
28
+ return Path(model)
29
+ return Path(snapshot_download(model, allow_patterns=["meta.json", "head.safetensors", "lora/*"]))
30
+
31
+
32
+ class Decider:
33
+ """A loaded model. `answer(state, question)` returns the chosen option and a probability for every option."""
34
+
35
+ def __init__(self, network, meta, device):
36
+ self.network = network.to(device).eval()
37
+ self.meta = meta
38
+ self.device = torch.device(device)
39
+
40
+ @torch.no_grad()
41
+ def answer(self, state, question):
42
+ """state: text or any JSON-able object. question: {"type": "choice"|"noul"|"score", "instructions": ...,
43
+ "criteria": {key: description} for choice, a list of ordered levels for score, optional for noul}."""
44
+ if not isinstance(state, str):
45
+ state = json.dumps(state, ensure_ascii=False)
46
+ choices, _ = question_to_choices(question)
47
+ example = encode_example(state, question["instructions"], choices, question["type"],
48
+ self.meta["max_state_tokens"], self.meta["max_choice_tokens"],
49
+ self.meta.get("use_answer_prompt", True))
50
+ example.update(task_type=TASK_TYPES[question["type"]], label=None)
51
+ batch = {k: v.to(self.device) for k, v in collate_fn([example]).items()}
52
+
53
+ autocast = torch.autocast(self.device.type, dtype=torch.bfloat16, enabled=self.device.type in ("cuda", "mps"))
54
+ with autocast:
55
+ logits = self.network(**batch)[0].float().cpu()
56
+ calibration = self.meta.get("calibration") or {}
57
+ logits = logits / calibration.get("temperatures", {}).get(question["type"], 1.0)
58
+ probs = torch.softmax(logits, -1)
59
+ confidence = probs.max().item()
60
+ threshold = calibration.get("thresholds", {}).get(question["type"], 0.0)
61
+ out = {"confidence": confidence, "abstain": confidence < threshold}
62
+ probs = probs.tolist()
63
+
64
+ if question["type"] == "choice":
65
+ keys = list(question["criteria"])
66
+ out.update(choice=keys[max(range(len(keys)), key=probs.__getitem__)], probabilities=dict(zip(keys, probs)))
67
+ elif question["type"] == "score":
68
+ out.update(score=sum(i * p for i, p in enumerate(probs)), probabilities=probs)
69
+ else:
70
+ out.update(noul=probs[1])
71
+ return out
72
+
73
+
74
+ def load(model=DEFAULT_MODEL, device=None):
75
+ """Loads the base model, the LoRA adapter and the decision head. `model` is a Hugging Face repo id or a folder."""
76
+ folder = resolve(model)
77
+ meta = json.loads((folder / "meta.json").read_text())
78
+ device = torch.device(device) if device else get_device()
79
+
80
+ backbone = load_backbone(meta["model_name"], meta.get("num_layers"), meta.get("dtype", "float32"))
81
+ if (folder / "lora").exists():
82
+ backbone = PeftModel.from_pretrained(backbone, folder / "lora")
83
+ head = build_head(meta["head_config"], backbone.config.hidden_size, meta["head_config"]["num_task_types"])
84
+ head.load_state_dict(load_file(str(folder / "head.safetensors"), device="cpu"))
85
+ return Decider(TevroNetwork(backbone, head), meta, device)
@@ -0,0 +1,87 @@
1
+ import os
2
+
3
+ import torch
4
+ from transformers import AutoTokenizer
5
+
6
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") # the tokenizer is shared with DataLoader worker processes
7
+
8
+ model_name = "Qwen/Qwen3-0.6B"
9
+ tokenizer = AutoTokenizer.from_pretrained(model_name)
10
+
11
+ OPTION_OPEN = tokenizer.encode("<option>")
12
+ OPTION_CLOSE = tokenizer.encode("</option>")
13
+ ANSWER_PROMPT = tokenizer.encode("The answer is:")
14
+
15
+ # Short natural-language task hint
16
+ TASK_PROMPTS = {
17
+ "choice": "Choose the best option.",
18
+ "score": "Rate it on the given scale.",
19
+ "noul": "Answer yes or no.",
20
+ }
21
+
22
+
23
+ def truncate(text, max_tokens):
24
+ tokens = tokenizer.encode(text)
25
+ if len(tokens) <= max_tokens:
26
+ return text
27
+ return tokenizer.decode(tokens[:max_tokens])
28
+
29
+
30
+ def build_question(state, instructions, task_type, max_state_tokens):
31
+ # Task hint and question come before the state, so every state token is read knowing what is asked
32
+ return f"{TASK_PROMPTS[task_type]}\nQuestion: {instructions}\n\n{truncate(state, max_state_tokens)}\n"
33
+
34
+
35
+ def build_layout(question_ids, choice_ids, use_answer_prompt=True):
36
+ """One sequence: question, then every choice wrapped in <option>...</option>, then "The answer is:".
37
+
38
+ Returns token ids, position ids, a [L, L] attention mask (1 = may attend) and the read indices.
39
+ Every choice starts at the same position and cannot see the other choices, so the choices are
40
+ read in parallel: reordering them only reorders the outputs. The answer tokens come after the
41
+ longest choice and see everything, which is where the choices are compared.
42
+ """
43
+ q = len(question_ids)
44
+ input_ids = list(question_ids)
45
+ position_ids = list(range(q))
46
+ spans = []
47
+ for ids in choice_ids:
48
+ wrapped = OPTION_OPEN + ids + OPTION_CLOSE
49
+ start = len(input_ids)
50
+ input_ids += wrapped
51
+ position_ids += range(q, q + len(wrapped))
52
+ spans.append((start, len(input_ids))) # [start, end)
53
+
54
+ answer_start = len(input_ids)
55
+ answer_pos = max(position_ids) + 1
56
+ answer_ids = ANSWER_PROMPT if use_answer_prompt else []
57
+ input_ids += answer_ids
58
+ position_ids += range(answer_pos, answer_pos + len(answer_ids))
59
+ L = len(input_ids)
60
+
61
+ mask = torch.zeros((L, L))
62
+ mask[:q, :q] = torch.tril(torch.ones(q, q)) # question: causal
63
+ for start, end in spans:
64
+ n = end - start
65
+ mask[start:end, :q] = 1 # each choice reads the question and state
66
+ mask[start:end, start:end] = torch.tril(torch.ones(n, n)) # and itself, causally
67
+ n = L - answer_start
68
+ mask[answer_start:, :answer_start] = 1 # answer reads everything before it
69
+ mask[answer_start:, answer_start:] = torch.tril(torch.ones(n, n))
70
+
71
+ # Choices are read at their last content token, not at the closing tag
72
+ choice_read_idx = [end - len(OPTION_CLOSE) - 1 for _, end in spans]
73
+ return {
74
+ "input_ids": input_ids,
75
+ "position_ids": position_ids,
76
+ "attention_mask": mask,
77
+ "choice_read_idx": choice_read_idx,
78
+ "answer_end_idx": L - 1,
79
+ }
80
+
81
+
82
+ def encode_example(state, instructions, choices, task_type, max_state_tokens, max_choice_tokens,
83
+ use_answer_prompt=True):
84
+ question = build_question(state, instructions, task_type, max_state_tokens)
85
+ choices = [truncate(choice, max_choice_tokens) for choice in choices]
86
+ return build_layout(tokenizer.encode(question), [tokenizer.encode(choice) for choice in choices],
87
+ use_answer_prompt)