podstack commited on
Commit
c1f5d12
·
verified ·
1 Parent(s): 4871bb8

Shaktimaan v1.0

Browse files
Files changed (15) hide show
  1. .gitattributes +1 -0
  2. README.md +206 -0
  3. build.py +93 -0
  4. chat_template.jinja +54 -0
  5. common.py +157 -0
  6. config.json +57 -0
  7. eval_results.json +127 -0
  8. evaluate.py +129 -0
  9. gen.py +198 -0
  10. generation_config.json +14 -0
  11. model.safetensors +3 -0
  12. orchestrate.py +72 -0
  13. tokenizer.json +3 -0
  14. tokenizer_config.json +30 -0
  15. train.py +19 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ base_model: Qwen/Qwen2.5-0.5B-Instruct
3
+ license: apache-2.0
4
+ language: [en]
5
+ datasets: [podstack/Shaktimaan-routing]
6
+ library_name: transformers
7
+ pipeline_tag: text-generation
8
+ tags: [router, llm-router, orchestration, multi-agent, model-selection, fugu]
9
+ model-index:
10
+ - name: Shaktimaan v1.0
11
+ results:
12
+ - task: {type: text-classification, name: LLM routing}
13
+ dataset: {type: podstack/Shaktimaan-routing, name: Shaktimaan routing (held-out test, 554 tasks)}
14
+ metrics:
15
+ - {type: accuracy, value: 0.8339, name: end-to-end task accuracy}
16
+ - {type: relative_cost, value: 0.4240, name: cost vs always-strongest agent}
17
+ ---
18
+ # Shaktimaan v1.0 (0.5B)
19
+
20
+ **Shaktimaan** is a 0.5B-parameter LLM orchestrator in the spirit of Sakana Fugu. It reads a task and decides which
21
+ agent(s) from a pool of six LLMs should handle it - one agent (`single`), or several that answer independently with
22
+ the last one aggregating (`ensemble`) - trading off cost, capability (image input, context length) and accuracy. It
23
+ does not answer tasks itself. Fine-tuned from [Qwen2.5-0.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct)
24
+ on [podstack/Shaktimaan-routing](https://huggingface.co/datasets/podstack/Shaktimaan-routing).
25
+
26
+ ## At a glance
27
+ - **83.4%** end-to-end accuracy on 554 held-out tasks (math, knowledge,
28
+ science, code, long context, charts) at **0.42x** the cost of always using the
29
+ strongest agent.
30
+ - Beats every single agent used alone (best: Always `glm-5.3-flash` at 79.2%) and the
31
+ strongest agent alone (78.5% at 1.00x).
32
+ - Learns capability routing from data: charts -> the only vision agent, very long inputs -> the long-context agents.
33
+ - **Does not beat hand-written capability rules overall**: Capability rules -> `glm-5.3-flash` reaches
34
+ 85.7% at 0.13x. See *Results* and *Findings*.
35
+ - 100% of outputs are valid route JSON (100.0% on the test set).
36
+
37
+ ## Output format
38
+ ```json
39
+ {"domain": "math", "difficulty": "easy|medium|hard", "strategy": "single|ensemble", "agents": ["glm-5.3-flash"]}
40
+ ```
41
+
42
+ ## Agent pool
43
+ | agent | model | context | image input | relative cost / token |
44
+ |---|---|---|---|---|
45
+ | `gemma-3-1b` | `google/gemma-3-1b-it` | 32k | no | 0.02 |
46
+ | `kimi-vl` | `Kimi-VL-A3B-Thinking` | 8k | yes | 0.05 |
47
+ | `nemotron-lightning` | `nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4` | 128k | no | 0.1 |
48
+ | `glm-5.3-flash` | `zai-org/GLM-5.3-Flash` | 32k | no | 0.2 |
49
+ | `qwen3.8-27b` | `Qwen/Qwen3.8-27B` | 32k | no | 0.5 |
50
+ | `nemotron-ultra` | `nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4` | 256k | no | 1.0 |
51
+
52
+ Agents are reached through OpenAI-compatible chat-completions endpoints. "Relative cost" is a per-token weight used
53
+ to compute routing cost (roughly relative serving cost), not a vendor price.
54
+
55
+ ## Quick start
56
+ ```python
57
+ import json
58
+ from huggingface_hub import hf_hub_download
59
+ from transformers import AutoModelForCausalLM, AutoTokenizer
60
+ import importlib.util, sys
61
+
62
+ repo = "podstack/Shaktimaan"
63
+ spec = importlib.util.spec_from_file_location("common", hf_hub_download(repo, "common.py"))
64
+ common = importlib.util.module_from_spec(spec); spec.loader.exec_module(common)
65
+
66
+ tok = AutoTokenizer.from_pretrained(repo)
67
+ model = AutoModelForCausalLM.from_pretrained(repo)
68
+ task = "Write a Python function that returns the n-th Fibonacci number."
69
+ msgs = [{"role": "system", "content": common.ROUTER_SYSTEM},
70
+ {"role": "user", "content": common.router_view(task, has_image=False)}]
71
+ enc = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt", return_dict=True)
72
+ out = model.generate(**enc, max_new_tokens=96, do_sample=False)
73
+ print(json.loads(tok.decode(out[0, enc["input_ids"].shape[1]:], skip_special_tokens=True)))
74
+ ```
75
+
76
+ Always build the input with `common.router_view(task, has_image)` - the router was trained on that exact format (a
77
+ `[input ~Nk tokens; 1 image attached]` header plus the first 5,000 and last 1,500 characters of the task) with
78
+ `common.ROUTER_SYSTEM` as the system prompt.
79
+
80
+ ### End-to-end orchestration
81
+ `orchestrate.py` routes with Shaktimaan, drops agents that cannot take the input, then calls the chosen agent(s) and
82
+ aggregates ensembles:
83
+
84
+ ```bash
85
+ export SHAKTIMAAN_ENDPOINTS='{"kimi-vl": "http://host-a:8000/v1", "nemotron-lightning": "http://host-b:8000/v1", ...}'
86
+ python orchestrate.py "If a train travels 120 km in 1.5 hours, what is its average speed?"
87
+ python orchestrate.py --code "Write a function that checks whether a string is a palindrome."
88
+ python orchestrate.py --image chart.png "What was the revenue in Q2 2021?"
89
+ ```
90
+
91
+ ## Training data
92
+ 4989 train / 554 test tasks across eight sources. Every capable agent attempted every
93
+ task once and was graded automatically; the label is the cheapest *reliable* agent that solved the task (or an
94
+ ensemble when none did). Full details, solve rates per agent and the label policy are in the
95
+ [dataset card](https://huggingface.co/datasets/podstack/Shaktimaan-routing).
96
+
97
+ | source | category | train | test | grading | license |
98
+ |---|---|---|---|---|---|
99
+ | [GSM8K](https://huggingface.co/datasets/openai/gsm8k) | math word problems | 895 | 105 | numeric match | MIT |
100
+ | [MATH](https://huggingface.co/datasets/EleutherAI/hendrycks_math) | competition math | 894 | 106 | normalised LaTeX / numeric match | MIT |
101
+ | [MMLU-Pro](https://huggingface.co/datasets/TIGER-Lab/MMLU-Pro) | knowledge MCQ (10 options) | 1080 | 120 | option letter | MIT |
102
+ | [ARC-Challenge](https://huggingface.co/datasets/allenai/ai2_arc) | science MCQ | 725 | 75 | option letter | CC-BY-SA-4.0 |
103
+ | [MBPP](https://huggingface.co/datasets/google-research-datasets/mbpp) | Python coding | 633 | 67 | unit tests executed in a network-less sandbox | CC-BY-4.0 |
104
+ | [HumanEval](https://huggingface.co/datasets/openai/openai_humaneval) | Python coding | 146 | 18 | unit tests executed in a network-less sandbox | MIT |
105
+ | [LongBench-v2](https://huggingface.co/datasets/THUDM/LongBench-v2) | long-context MCQ (up to ~260k tokens) | 347 | 32 | option letter | Apache-2.0 |
106
+ | [ChartQA](https://huggingface.co/datasets/HuggingFaceM4/ChartQA) | chart images | 269 | 31 | relaxed match (numbers within 5%) | GPL-3.0 |
107
+
108
+ ## Training
109
+ | setting | value |
110
+ |---|---|
111
+ | base model | Qwen2.5-0.5B-Instruct (494M parameters) |
112
+ | method | full fine-tune, supervised; loss on the route JSON only |
113
+ | epochs | 3 |
114
+ | learning rate | 2e-5, cosine schedule, 20 warm-up steps |
115
+ | effective batch size | 32 |
116
+ | max sequence length | 3,072 tokens |
117
+ | precision | bf16 |
118
+ | final eval loss | 0.0484 |
119
+
120
+ ## Results
121
+ Held-out test set: 554 tasks the router never saw. A policy's accuracy is whether the agent it picks
122
+ actually solved the task. Shaktimaan's 31 ensemble decisions were **executed live** (all agents
123
+ plus aggregator called and graded); single-agent decisions use the recorded outcomes. Cost is the sum of
124
+ `relative cost x output tokens`, normalised so that always using `nemotron-ultra` = 1.00.
125
+
126
+ | policy | accuracy | relative cost (always-Ultra = 1.00) |
127
+ |---|---|---|
128
+ | **Shaktimaan v1.0** | 83.4% | 0.424 |
129
+ | Capability rules -> `glm-5.3-flash` | 85.7% | 0.133 |
130
+ | Capability rules -> `qwen3.8-27b` | 85.4% | 0.403 |
131
+ | Capability rules -> `nemotron-ultra` | 82.7% | 1.001 |
132
+ | Always `gemma-3-1b` | 25.6% | 0.002 |
133
+ | Always `kimi-vl` | 61.4% | 0.074 |
134
+ | Always `nemotron-lightning` | 75.6% | 0.109 |
135
+ | Always `glm-5.3-flash` | 79.2% | 0.094 |
136
+ | Always `qwen3.8-27b` | 78.9% | 0.364 |
137
+ | Always `nemotron-ultra` | 78.5% | 1.000 |
138
+ | Oracle (hindsight-best label) | 89.7% | 0.490 |
139
+
140
+ **Capability rules** are a hand-written baseline: image -> `kimi-vl`; input too long for the default agent -> the
141
+ cheapest agent with a large enough context window; otherwise the default agent. **Oracle** is the hindsight-best
142
+ training label (an upper bound; its ensembles are scored as the aggregator's solo answer).
143
+
144
+ ### Accuracy by source
145
+ | source | n | Shaktimaan | rules -> glm | rules -> qwen | always Ultra | oracle |
146
+ |---|---|---|---|---|---|---|
147
+ | ARC-Challenge | 75 | 98.7% | 98.7% | 98.7% | 98.7% | 100.0% |
148
+ | ChartQA | 31 | 74.2% | 74.2% | 74.2% | 0.0% | 74.2% |
149
+ | GSM8K | 105 | 89.5% | 96.2% | 98.1% | 97.1% | 98.1% |
150
+ | HumanEval | 18 | 88.9% | 94.4% | 94.4% | 94.4% | 100.0% |
151
+ | LongBench-v2 | 32 | 59.4% | 46.9% | 50.0% | 59.4% | 59.4% |
152
+ | MATH | 106 | 75.5% | 74.5% | 75.5% | 65.1% | 82.1% |
153
+ | MBPP | 67 | 94.0% | 98.5% | 92.5% | 97.0% | 100.0% |
154
+ | MMLU-Pro | 120 | 77.5% | 83.3% | 81.7% | 74.2% | 87.5% |
155
+
156
+ ### Routes chosen on the test set
157
+ | route chosen by Shaktimaan | test tasks |
158
+ |---|---|
159
+ | `nemotron-lightning` | 218 |
160
+ | `glm-5.3-flash` | 192 |
161
+ | `kimi-vl` | 80 |
162
+ | `glm-5.3-flash,qwen3.8-27b,nemotron-ultra` | 32 |
163
+ | `nemotron-ultra` | 31 |
164
+ | `qwen3.8-27b` | 1 |
165
+
166
+ ## Findings
167
+ - **Routing beats any single model.** Shaktimaan is 4.1
168
+ points above the best single agent and 4.9
169
+ points above always using the strongest (and most expensive) one, at
170
+ 0.42x its cost.
171
+ - **Capability routing is learned from data.** On long-context tasks Shaktimaan matches the oracle
172
+ (59.4%) and beats the capability rules (46.9%),
173
+ sending inputs to the agent most likely to solve them rather than merely one that fits. On charts it routes to the
174
+ only vision agent, as the rules do.
175
+ - **Simple capability rules are stronger overall** (85.7% at
176
+ 0.13x). On ordinary text and code tasks the mid-size agents are close to
177
+ interchangeable, and which one solves a particular task is largely sampling noise that a router cannot predict from
178
+ the prompt. Shaktimaan sometimes picks the cheaper `nemotron-lightning` or `kimi-vl` where the rule's fixed
179
+ `glm-5.3-flash` default is slightly more reliable, and its ensembles (chosen for hard tasks) solved
180
+ 11 of the 31 hard tasks they were sent, at a high cost.
181
+ - **Practical recommendation:** for this pool, apply capability rules first and use Shaktimaan where its learned
182
+ judgement helps most - choosing among long-context agents and flagging hard tasks.
183
+
184
+ ## Limitations
185
+ - Routes only among the six agents above, with their context windows and image support; a different pool needs new
186
+ data and retraining.
187
+ - Labels come from a single sample per agent and inherit grading noise (e.g. answer extraction from free text).
188
+ - Not trained on agentic / tool-use, multi-turn or open-ended writing tasks; English only.
189
+ - `est_tokens` uses a conservative characters/3 estimate, so borderline inputs may be routed to a larger-context
190
+ agent than strictly needed.
191
+
192
+ ## Files
193
+ | file | purpose |
194
+ |---|---|
195
+ | `model.safetensors`, `config.json`, tokenizer files | the router |
196
+ | `common.py` | agent pool, prompts, `router_view`, capability checks, graders |
197
+ | `orchestrate.py` | end-to-end runtime (route -> call agents -> aggregate) |
198
+ | `gen.py` | builds tasks and collects graded agent outcomes |
199
+ | `build.py` | labels outcomes and builds the train / test split |
200
+ | `train.py` | fine-tuning script |
201
+ | `evaluate.py` | held-out evaluation incl. live ensembles and baselines |
202
+ | `eval_results.json` | raw evaluation output |
203
+
204
+ ## License
205
+ Model weights: Apache-2.0 (as the base model). Training data: see the
206
+ [dataset card](https://huggingface.co/datasets/podstack/Shaktimaan-routing) (CC-BY-SA-4.0; ChartQA rows GPL-3.0, not redistributed).
build.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 2: turn graded runs into routing labels (chat-format SFT data) with a held-out test split.
2
+
3
+ Label policy (cost-aware oracle):
4
+ * cheapest routable agent that solved the task, counting a win only if the agent is reliable on that source
5
+ (solve rate >= 70%). Wins by unreliable agents are mostly lucky single samples a router cannot predict: v1 sent
6
+ gemma-3-1b 68 held-out tasks and only 53% were solved, and kimi-vl solves just 63% of text tasks that >= 4 other
7
+ agents solve (lightning 95%, glm 98%). gemma-3-1b is excluded as a target outright;
8
+ * if no routable agent solved it -> ensemble of the capable agents among glm / qwen / ultra (aggregated by the
9
+ last one) when at least two can take the task, otherwise the strongest capable agent alone.
10
+ The router sees `router_view(...)`: a size / image header plus the start and end of the task.
11
+ """
12
+ import json, random
13
+ from collections import Counter
14
+ from datasets import Dataset, DatasetDict
15
+ from common import AGENTS, POOL, ROUTER_SYSTEM, STRONGEST, capable, est_tokens, router_view
16
+
17
+ random.seed(0)
18
+ ROUTABLE = [a for a in AGENTS if a != "gemma-3-1b"]
19
+ ENSEMBLE_POOL = ["glm-5.3-flash", "qwen3.8-27b", STRONGEST]
20
+ RELIABLE = 0.70
21
+ SOLVE_RATE = {} # (source, agent) -> solve rate, filled in main()
22
+
23
+
24
+ def cost(res, name):
25
+ return POOL[name]["cost"] * (res[name]["prompt_tokens"] + res[name]["completion_tokens"])
26
+
27
+
28
+ def can(task, agent):
29
+ """Capability check; long prompts are stored truncated, so trust the recorded skip instead of re-estimating."""
30
+ return not task["results"][agent].get("skipped") and capable(agent, "" if task.get("prompt_truncated")
31
+ else task["prompt"], task["has_image"])
32
+
33
+
34
+ def label(task):
35
+ res = task["results"]
36
+ n_correct = sum(res[a]["correct"] for a in AGENTS)
37
+ correct = [a for a in ROUTABLE if res[a]["correct"] and SOLVE_RATE[task["source"], a] >= RELIABLE]
38
+ difficulty = "easy" if n_correct >= 4 else "medium" if n_correct >= 2 else "hard"
39
+ if correct:
40
+ return dict(strategy="single", agents=[min(correct, key=lambda a: cost(res, a))], difficulty=difficulty)
41
+ ens = [a for a in ENSEMBLE_POOL if can(task, a)]
42
+ if len(ens) >= 2 and not task["has_image"]:
43
+ return dict(strategy="ensemble", agents=ens, difficulty="hard")
44
+ fallback = [a for a in ROUTABLE if can(task, a)] or [STRONGEST]
45
+ return dict(strategy="single", agents=[fallback[-1]], difficulty="hard")
46
+
47
+
48
+ def to_example(task):
49
+ lab = label(task)
50
+ target = json.dumps({"domain": task["domain"], "difficulty": lab["difficulty"],
51
+ "strategy": lab["strategy"], "agents": lab["agents"]})
52
+ view = router_view(task["prompt"], task["has_image"], task["est_tokens"])
53
+ return dict(id=task["id"], source=task["source"], domain=task["domain"], kind=task["kind"],
54
+ gold=task["gold"], prompt=task["prompt"], has_image=task["has_image"],
55
+ est_tokens=task["est_tokens"], prompt_truncated=bool(task.get("prompt_truncated")),
56
+ router_input=view, route=target,
57
+ agent_results=json.dumps({a: {k: task["results"][a].get(k) for k in
58
+ ("correct", "completion_tokens", "latency", "skipped")}
59
+ for a in AGENTS}),
60
+ messages=[{"role": "system", "content": ROUTER_SYSTEM},
61
+ {"role": "user", "content": view},
62
+ {"role": "assistant", "content": target}])
63
+
64
+
65
+ def main():
66
+ rows = [json.loads(l) for l in open("data/runs_v3.jsonl")]
67
+ n_all = len(rows)
68
+ rows = [r for r in rows if not any(v.get("error") for v in r["results"].values())]
69
+ print(f"{n_all} runs, {n_all - len(rows)} dropped for transient API errors")
70
+ for r in rows:
71
+ r.setdefault("has_image", False)
72
+ r.setdefault("est_tokens", est_tokens(r["prompt"]))
73
+ for src in {r["source"] for r in rows}:
74
+ sub = [r for r in rows if r["source"] == src]
75
+ for a in AGENTS:
76
+ SOLVE_RATE[src, a] = sum(r["results"][a]["correct"] for r in sub) / len(sub)
77
+ random.shuffle(rows)
78
+ ex = [to_example(r) for r in rows]
79
+ n_test = min(max(200, len(ex) // 10), len(ex) // 5)
80
+ dd = DatasetDict(train=Dataset.from_list(ex[n_test:]), test=Dataset.from_list(ex[:n_test]))
81
+ dd.save_to_disk("data/shaktimaan_routing")
82
+ print(dd)
83
+ routes = Counter(json.loads(e["route"])["strategy"] + ":" + ",".join(json.loads(e["route"])["agents"])
84
+ for e in ex)
85
+ print("label distribution:", *routes.most_common(), sep="\n ")
86
+ for src in sorted({r["source"] for r in rows}):
87
+ sub = [r for r in rows if r["source"] == src]
88
+ print(f"{src:10s} n={len(sub):4d} " + " ".join(
89
+ f"{a}={sum(r['results'][a]['correct'] for r in sub) / len(sub):.2f}" for a in AGENTS))
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
chat_template.jinja ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- if tools %}
2
+ {{- '<|im_start|>system\n' }}
3
+ {%- if messages[0]['role'] == 'system' %}
4
+ {{- messages[0]['content'] }}
5
+ {%- else %}
6
+ {{- 'You are Qwen, created by Alibaba Cloud. You are a helpful assistant.' }}
7
+ {%- endif %}
8
+ {{- "\n\n# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
9
+ {%- for tool in tools %}
10
+ {{- "\n" }}
11
+ {{- tool | tojson }}
12
+ {%- endfor %}
13
+ {{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
14
+ {%- else %}
15
+ {%- if messages[0]['role'] == 'system' %}
16
+ {{- '<|im_start|>system\n' + messages[0]['content'] + '<|im_end|>\n' }}
17
+ {%- else %}
18
+ {{- '<|im_start|>system\nYou are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>\n' }}
19
+ {%- endif %}
20
+ {%- endif %}
21
+ {%- for message in messages %}
22
+ {%- if (message.role == "user") or (message.role == "system" and not loop.first) or (message.role == "assistant" and not message.tool_calls) %}
23
+ {{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>' + '\n' }}
24
+ {%- elif message.role == "assistant" %}
25
+ {{- '<|im_start|>' + message.role }}
26
+ {%- if message.content %}
27
+ {{- '\n' + message.content }}
28
+ {%- endif %}
29
+ {%- for tool_call in message.tool_calls %}
30
+ {%- if tool_call.function is defined %}
31
+ {%- set tool_call = tool_call.function %}
32
+ {%- endif %}
33
+ {{- '\n<tool_call>\n{"name": "' }}
34
+ {{- tool_call.name }}
35
+ {{- '", "arguments": ' }}
36
+ {{- tool_call.arguments | tojson }}
37
+ {{- '}\n</tool_call>' }}
38
+ {%- endfor %}
39
+ {{- '<|im_end|>\n' }}
40
+ {%- elif message.role == "tool" %}
41
+ {%- if (loop.index0 == 0) or (messages[loop.index0 - 1].role != "tool") %}
42
+ {{- '<|im_start|>user' }}
43
+ {%- endif %}
44
+ {{- '\n<tool_response>\n' }}
45
+ {{- message.content }}
46
+ {{- '\n</tool_response>' }}
47
+ {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
48
+ {{- '<|im_end|>\n' }}
49
+ {%- endif %}
50
+ {%- endif %}
51
+ {%- endfor %}
52
+ {%- if add_generation_prompt %}
53
+ {{- '<|im_start|>assistant\n' }}
54
+ {%- endif %}
common.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared config for Shaktimaan: the agent pool, prompts, capabilities and graders."""
2
+ import json, os, re
3
+
4
+ # agent -> served model id, max concurrent requests we send, relative cost per token
5
+ POOL = {
6
+ "gemma-3-1b": dict(model="google/gemma-3-1b-it", concurrency=32, cost=0.02),
7
+ "kimi-vl": dict(model="Kimi-VL-A3B-Thinking", concurrency=64, cost=0.05),
8
+ "nemotron-lightning": dict(model="nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4", concurrency=64, cost=0.10),
9
+ "glm-5.3-flash": dict(model="zai-org/GLM-5.3-Flash", concurrency=32, cost=0.20),
10
+ "qwen3.8-27b": dict(model="Qwen/Qwen3.8-27B", concurrency=12, cost=0.50),
11
+ "nemotron-ultra": dict(model="nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4", concurrency=64, cost=1.00),
12
+ }
13
+ AGENTS = list(POOL) # ordered cheapest -> most expensive
14
+ STRONGEST = "nemotron-ultra"
15
+ CONTEXT = {"gemma-3-1b": 32768, "kimi-vl": 8192, "nemotron-lightning": 131072, "glm-5.3-flash": 32768,
16
+ "qwen3.8-27b": 32768, "nemotron-ultra": 262144}
17
+ VISION = {"kimi-vl"} # only agent served with image input enabled
18
+ MAX_OUT = 8192
19
+
20
+
21
+ def endpoints() -> dict:
22
+ """Agent -> OpenAI-compatible base URL (".../v1"), from $SHAKTIMAAN_ENDPOINTS: inline JSON or a path to a JSON file."""
23
+ raw = os.environ["SHAKTIMAAN_ENDPOINTS"]
24
+ return json.load(open(raw)) if os.path.exists(raw) else json.loads(raw)
25
+
26
+ SOLVER_SYSTEM = ("Solve the task. Be concise. End your response with a final line exactly of the form:\n"
27
+ "Final answer: <answer>\nFor multiple-choice questions, the answer is the option letter only.")
28
+ CODE_SYSTEM = ("Write a correct, self-contained Python solution. Put the complete code (imports, required function "
29
+ "names and signatures) in a single ```python code block. Do not include tests or example usage.")
30
+
31
+ ROUTER_SYSTEM = (
32
+ "You are Shaktimaan, an orchestrator. You do not answer the task yourself. You read the task and decide which "
33
+ "agent(s) from the pool should handle it, minimising cost while maximising the chance of a correct answer.\n"
34
+ "Agents (cheapest first): " + ", ".join(AGENTS) + ".\n"
35
+ "Capabilities: only kimi-vl can see images; context limits: nemotron-ultra 262k tokens, nemotron-lightning 131k, "
36
+ "kimi-vl 8k, all others 32k.\n"
37
+ "Strategies: single = one agent answers; ensemble = several agents answer independently and the last one "
38
+ "aggregates.\n"
39
+ "Reply with JSON only: {\"domain\": ..., \"difficulty\": \"easy|medium|hard\", \"strategy\": \"single|ensemble\", "
40
+ "\"agents\": [...]}"
41
+ )
42
+
43
+
44
+ def est_tokens(text: str) -> int:
45
+ """Conservative token estimate (real tokenizers average ~3.5-4 chars/token on English)."""
46
+ return len(text) // 3 + 1
47
+
48
+
49
+ def max_out(agent: str, prompt_tokens: int) -> int:
50
+ return min(MAX_OUT, CONTEXT[agent] - prompt_tokens - 256)
51
+
52
+
53
+ def capable(agent: str, prompt: str, has_image: bool) -> bool:
54
+ if has_image and agent not in VISION:
55
+ return False
56
+ return max_out(agent, est_tokens(prompt) + 1500 * has_image) >= 1024
57
+
58
+
59
+ def truncate(prompt: str, head: int = 5000, tail: int = 1500) -> str:
60
+ return prompt if len(prompt) <= head + tail + 7 else prompt[:head] + "\n[...]\n" + prompt[-tail:]
61
+
62
+
63
+ def router_view(prompt: str, has_image: bool = False, n_tokens: int | None = None) -> str:
64
+ """What the router reads: a size/attachment header plus the start and end of the task."""
65
+ n = est_tokens(prompt) if n_tokens is None else n_tokens
66
+ return f"[input ~{n // 1000}k tokens{'; 1 image attached' if has_image else ''}]\n" + truncate(prompt)
67
+
68
+
69
+ def strip_think(text: str) -> str:
70
+ text = text or ""
71
+ if "</think>" in text:
72
+ text = text.rsplit("</think>", 1)[1]
73
+ text = re.sub(r"◁think▷.*?◁/think▷", "", text, flags=re.S) # Kimi-VL thinking markers
74
+ return text.strip()
75
+
76
+
77
+ def extract_final(text: str) -> str:
78
+ text = strip_think(text)
79
+ m = re.findall(r"final answer\s*[::]\s*(.+)", text, flags=re.I)
80
+ if m:
81
+ return m[-1].strip()
82
+ m = re.findall(r"^\s*answer\s*[::]\s*(.+)", text, flags=re.I | re.M)
83
+ if m:
84
+ return m[-1].strip()
85
+ boxed = last_boxed(text)
86
+ if boxed is not None:
87
+ return boxed
88
+ lines = [l for l in text.splitlines() if l.strip()]
89
+ return lines[-1].strip() if lines else ""
90
+
91
+
92
+ def extract_code(text: str) -> str:
93
+ text = strip_think(text)
94
+ blocks = re.findall(r"```(?:python|py)?\s*\n(.*?)```", text, flags=re.S)
95
+ return blocks[-1] if blocks else ""
96
+
97
+
98
+ def last_boxed(s: str):
99
+ i = s.rfind("\\boxed{")
100
+ if i < 0:
101
+ return None
102
+ i += len("\\boxed{")
103
+ depth, j = 1, i
104
+ while j < len(s) and depth:
105
+ depth += {"{": 1, "}": -1}.get(s[j], 0)
106
+ j += 1
107
+ return s[i:j - 1] if depth == 0 else None
108
+
109
+
110
+ def _norm_math(s: str) -> str:
111
+ s = s.strip().strip("$").strip()
112
+ b = last_boxed(s)
113
+ if b is not None:
114
+ s = b
115
+ for a, b2 in [("\\left", ""), ("\\right", ""), ("\\!", ""), ("\\,", ""), ("\\;", ""), ("dfrac", "frac"),
116
+ ("tfrac", "frac"), ("^\\circ", ""), ("^{\\circ}", ""), ("\\%", ""), ("%", ""), ("\\text{", "{"),
117
+ ("\\mbox{", "{"), (" ", ""), ("\\$", "")]:
118
+ s = s.replace(a, b2)
119
+ s = re.sub(r"\\frac(\d)(\d)", r"\\frac{\1}{\2}", s)
120
+ s = s.rstrip(".")
121
+ if s.startswith("{") and s.endswith("}") and s.count("{") == 1:
122
+ s = s[1:-1]
123
+ return s
124
+
125
+
126
+ def _num(s: str):
127
+ s = s.replace(",", "").replace("$", "").strip()
128
+ m = re.findall(r"-?\d+(?:\.\d+)?", s)
129
+ return float(m[-1]) if m else None
130
+
131
+
132
+ def grade(kind: str, pred: str, gold: str) -> bool:
133
+ """Grades extracted answers. Code is graded by execution (see gen.run_tests), not here."""
134
+ if not pred:
135
+ return False
136
+ if kind == "mc":
137
+ m = re.search(r"\b([A-J])\b", pred.upper().replace("(", " ").replace(")", " "))
138
+ return bool(m) and m.group(1) == gold.upper()
139
+ if kind == "num":
140
+ p, g = _num(pred), _num(gold)
141
+ return p is not None and g is not None and abs(p - g) < 1e-6 * max(1, abs(g))
142
+ if kind == "relaxed": # ChartQA convention: numbers within 5%, otherwise case-insensitive exact match
143
+ pred = last_boxed(pred) or pred
144
+ pred = re.sub(r"\\(?:mathbf|textbf|text|mathrm)\{([^}]*)\}", r"\1", pred).replace("\\(", "").replace("\\)", "")
145
+ p, g = _num(pred), _num(gold)
146
+ if g is not None and p is not None and re.fullmatch(r"[-\d.,%$ ]+", gold.strip()):
147
+ return abs(p - g) <= 0.05 * abs(g) if g else p == 0
148
+ return pred.strip().strip(".").lower() == gold.strip().lower()
149
+ if kind == "math":
150
+ p, g = _norm_math(pred), _norm_math(gold)
151
+ if p == g:
152
+ return True
153
+ try:
154
+ return abs(float(p) - float(g)) < 1e-6
155
+ except ValueError:
156
+ return False
157
+ raise ValueError(kind)
config.json ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Qwen2ForCausalLM"
4
+ ],
5
+ "attention_dropout": 0.0,
6
+ "bos_token_id": 151643,
7
+ "dtype": "float32",
8
+ "eos_token_id": 151645,
9
+ "hidden_act": "silu",
10
+ "hidden_size": 896,
11
+ "initializer_range": 0.02,
12
+ "intermediate_size": 4864,
13
+ "layer_types": [
14
+ "full_attention",
15
+ "full_attention",
16
+ "full_attention",
17
+ "full_attention",
18
+ "full_attention",
19
+ "full_attention",
20
+ "full_attention",
21
+ "full_attention",
22
+ "full_attention",
23
+ "full_attention",
24
+ "full_attention",
25
+ "full_attention",
26
+ "full_attention",
27
+ "full_attention",
28
+ "full_attention",
29
+ "full_attention",
30
+ "full_attention",
31
+ "full_attention",
32
+ "full_attention",
33
+ "full_attention",
34
+ "full_attention",
35
+ "full_attention",
36
+ "full_attention",
37
+ "full_attention"
38
+ ],
39
+ "max_position_embeddings": 32768,
40
+ "max_window_layers": 21,
41
+ "model_type": "qwen2",
42
+ "num_attention_heads": 14,
43
+ "num_hidden_layers": 24,
44
+ "num_key_value_heads": 2,
45
+ "pad_token_id": 151643,
46
+ "rms_norm_eps": 1e-06,
47
+ "rope_parameters": {
48
+ "rope_theta": 1000000.0,
49
+ "rope_type": "default"
50
+ },
51
+ "sliding_window": null,
52
+ "tie_word_embeddings": true,
53
+ "transformers_version": "5.19.0",
54
+ "use_cache": false,
55
+ "use_sliding_window": false,
56
+ "vocab_size": 151936
57
+ }
eval_results.json ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "n_test": 554,
3
+ "valid_json": 1.0,
4
+ "route_match": 0.5306859205776173,
5
+ "live_ensembles": 31,
6
+ "route_counts": {
7
+ "nemotron-lightning": 218,
8
+ "glm-5.3-flash": 192,
9
+ "kimi-vl": 80,
10
+ "glm-5.3-flash,qwen3.8-27b,nemotron-ultra": 32,
11
+ "nemotron-ultra": 31,
12
+ "qwen3.8-27b": 1
13
+ },
14
+ "policies": {
15
+ "shaktimaan": {
16
+ "accuracy": 0.8339,
17
+ "relative_cost": 0.424
18
+ },
19
+ "oracle": {
20
+ "accuracy": 0.8971,
21
+ "relative_cost": 0.49
22
+ },
23
+ "always:gemma-3-1b": {
24
+ "accuracy": 0.2563,
25
+ "relative_cost": 0.0024
26
+ },
27
+ "always:kimi-vl": {
28
+ "accuracy": 0.6137,
29
+ "relative_cost": 0.0736
30
+ },
31
+ "always:nemotron-lightning": {
32
+ "accuracy": 0.7563,
33
+ "relative_cost": 0.1089
34
+ },
35
+ "always:glm-5.3-flash": {
36
+ "accuracy": 0.7924,
37
+ "relative_cost": 0.0944
38
+ },
39
+ "always:qwen3.8-27b": {
40
+ "accuracy": 0.7888,
41
+ "relative_cost": 0.3641
42
+ },
43
+ "always:nemotron-ultra": {
44
+ "accuracy": 0.7852,
45
+ "relative_cost": 1.0
46
+ },
47
+ "rule:glm-5.3-flash": {
48
+ "accuracy": 0.8574,
49
+ "relative_cost": 0.1331
50
+ },
51
+ "rule:qwen3.8-27b": {
52
+ "accuracy": 0.8538,
53
+ "relative_cost": 0.4028
54
+ },
55
+ "rule:nemotron-ultra": {
56
+ "accuracy": 0.8267,
57
+ "relative_cost": 1.0009
58
+ }
59
+ },
60
+ "accuracy_by_source": {
61
+ "arc": {
62
+ "n": 75,
63
+ "shaktimaan": 0.987,
64
+ "oracle": 1.0,
65
+ "always:nemotron-ultra": 0.987,
66
+ "rule:glm-5.3-flash": 0.987,
67
+ "rule:qwen3.8-27b": 0.987
68
+ },
69
+ "chartqa": {
70
+ "n": 31,
71
+ "shaktimaan": 0.742,
72
+ "oracle": 0.742,
73
+ "always:nemotron-ultra": 0.0,
74
+ "rule:glm-5.3-flash": 0.742,
75
+ "rule:qwen3.8-27b": 0.742
76
+ },
77
+ "gsm8k": {
78
+ "n": 105,
79
+ "shaktimaan": 0.895,
80
+ "oracle": 0.981,
81
+ "always:nemotron-ultra": 0.971,
82
+ "rule:glm-5.3-flash": 0.962,
83
+ "rule:qwen3.8-27b": 0.981
84
+ },
85
+ "humaneval": {
86
+ "n": 18,
87
+ "shaktimaan": 0.889,
88
+ "oracle": 1.0,
89
+ "always:nemotron-ultra": 0.944,
90
+ "rule:glm-5.3-flash": 0.944,
91
+ "rule:qwen3.8-27b": 0.944
92
+ },
93
+ "longbench": {
94
+ "n": 32,
95
+ "shaktimaan": 0.594,
96
+ "oracle": 0.594,
97
+ "always:nemotron-ultra": 0.594,
98
+ "rule:glm-5.3-flash": 0.469,
99
+ "rule:qwen3.8-27b": 0.5
100
+ },
101
+ "math": {
102
+ "n": 106,
103
+ "shaktimaan": 0.755,
104
+ "oracle": 0.821,
105
+ "always:nemotron-ultra": 0.651,
106
+ "rule:glm-5.3-flash": 0.745,
107
+ "rule:qwen3.8-27b": 0.755
108
+ },
109
+ "mbpp": {
110
+ "n": 67,
111
+ "shaktimaan": 0.94,
112
+ "oracle": 1.0,
113
+ "always:nemotron-ultra": 0.97,
114
+ "rule:glm-5.3-flash": 0.985,
115
+ "rule:qwen3.8-27b": 0.925
116
+ },
117
+ "mmlu_pro": {
118
+ "n": 120,
119
+ "shaktimaan": 0.775,
120
+ "oracle": 0.875,
121
+ "always:nemotron-ultra": 0.742,
122
+ "rule:glm-5.3-flash": 0.833,
123
+ "rule:qwen3.8-27b": 0.817
124
+ }
125
+ },
126
+ "live_ensembles_solved": 11
127
+ }
evaluate.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 4: score the router on held-out tasks.
2
+
3
+ Single-agent routes are scored from the recorded agent outcomes (an agent that cannot take a task - no image input,
4
+ context too small - counts as a failure at zero cost). Shaktimaan's ensemble routes on text/code tasks are executed
5
+ live (agents + aggregator via their OpenAI-compatible endpoints) and graded; oracle ensemble labels are scored conservatively as
6
+ the aggregator's recorded outcome.
7
+
8
+ Baselines: every always-one-agent policy, plus capability rules ("rule:X" = image -> kimi-vl, too long for X ->
9
+ the cheapest agent with enough context, otherwise X). A useful router has to beat the rules, not just fixed agents.
10
+ """
11
+ import asyncio, json, sys
12
+ from collections import Counter, defaultdict
13
+ import httpx
14
+ import torch
15
+ from datasets import load_from_disk
16
+ from transformers import AutoModelForCausalLM, AutoTokenizer
17
+ from common import (AGENTS, CODE_SYSTEM, POOL, ROUTER_SYSTEM, SOLVER_SYSTEM, STRONGEST, capable, endpoints,
18
+ extract_code, extract_final, grade, strip_think)
19
+ from gen import call, run_tests
20
+
21
+ path = sys.argv[1] if len(sys.argv) > 1 else "shaktimaan-0.5b"
22
+ tok = AutoTokenizer.from_pretrained(path)
23
+ tok.padding_side = "left"
24
+ model = AutoModelForCausalLM.from_pretrained(path, dtype=torch.bfloat16).cuda().eval()
25
+ test = load_from_disk("data/shaktimaan_routing")["test"]
26
+ BY_CONTEXT = ["kimi-vl", "glm-5.3-flash", "nemotron-lightning", STRONGEST] # cheapest first, growing context
27
+
28
+
29
+ def parse(text):
30
+ try:
31
+ r = json.loads(text.strip())
32
+ agents = [a for a in r.get("agents", []) if a in POOL]
33
+ return r, agents or [STRONGEST], True
34
+ except Exception: # noqa: BLE001
35
+ return {}, [STRONGEST], False
36
+
37
+
38
+ def score(row, agents):
39
+ res = json.loads(row["agent_results"])
40
+ return bool(res[agents[-1]]["correct"]), sum(POOL[a]["cost"] * res[a]["completion_tokens"] for a in agents)
41
+
42
+
43
+ def rule(row, default):
44
+ res = json.loads(row["agent_results"])
45
+ if row["has_image"]:
46
+ return ["kimi-vl"]
47
+ if not res[default]["skipped"]:
48
+ return [default]
49
+ return [next((a for a in BY_CONTEXT if not res[a]["skipped"]), STRONGEST)]
50
+
51
+
52
+ async def run_ensembles(jobs):
53
+ """jobs: list of (row, agents). Returns [(correct, cost)] from real agent + aggregator calls."""
54
+ urls = {a: f"{base.rstrip('/')}/chat/completions" for a, base in endpoints().items()}
55
+ sems = {a: asyncio.Semaphore(POOL[a]["concurrency"]) for a in POOL}
56
+
57
+ async def one(client, row, agents):
58
+ code = row["kind"] == "code"
59
+ system = CODE_SYSTEM if code else SOLVER_SYSTEM
60
+ msgs = [{"role": "system", "content": system}, {"role": "user", "content": row["prompt"]}]
61
+ drafts = await asyncio.gather(*[call(client, urls[a], POOL[a]["model"], sems[a], msgs) for a in agents])
62
+ joined = "\n\n".join(f"### Candidate {i + 1}\n{strip_think(d['text'])}" for i, d in enumerate(drafts))
63
+ agg = await call(client, urls[agents[-1]], POOL[agents[-1]]["model"], sems[agents[-1]], [
64
+ {"role": "system", "content": system},
65
+ {"role": "user", "content": f"{row['prompt']}\n\nIndependent candidate solutions:\n{joined}\n\n"
66
+ "Check them, resolve disagreements, and give the best final answer."}])
67
+ cost = sum(POOL[a]["cost"] * d["completion_tokens"] for a, d in zip(agents, drafts))
68
+ cost += POOL[agents[-1]]["cost"] * agg["completion_tokens"]
69
+ ok = await run_tests(extract_code(agg["text"]), row["gold"]) if code else \
70
+ grade(row["kind"], extract_final(agg["text"]), row["gold"])
71
+ return ok, cost
72
+
73
+ async with httpx.AsyncClient(timeout=httpx.Timeout(1800)) as client:
74
+ return await asyncio.gather(*[one(client, r, a) for r, a in jobs])
75
+
76
+
77
+ preds = []
78
+ for i in range(0, len(test), 32):
79
+ batch = test[i:i + 32]
80
+ msgs = [[{"role": "system", "content": ROUTER_SYSTEM}, {"role": "user", "content": v}]
81
+ for v in batch["router_input"]]
82
+ enc = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt", padding=True,
83
+ return_dict=True).to("cuda")
84
+ with torch.no_grad():
85
+ out = model.generate(**enc, max_new_tokens=96, do_sample=False)
86
+ preds += tok.batch_decode(out[:, enc["input_ids"].shape[1]:], skip_special_tokens=True)
87
+
88
+ parsed = [parse(p) for p in preds]
89
+ ens = [(row, [a for a in ag if capable(a, row["prompt"], False)])
90
+ for row, (r, ag, ok) in zip(test, parsed)
91
+ if r.get("strategy") == "ensemble" and len(ag) > 1 and not row["has_image"] and not row["prompt_truncated"]]
92
+ ens = [(row, ag) for row, ag in ens if len(ag) > 1]
93
+ ens_results = dict(zip([row["id"] for row, _ in ens], asyncio.run(run_ensembles(ens))))
94
+ print(f"ran {len(ens)} live ensembles, solved {sum(c for c, _ in ens_results.values())}", flush=True)
95
+
96
+ policies = ["shaktimaan", "oracle"] + [f"always:{a}" for a in AGENTS] + \
97
+ ["rule:glm-5.3-flash", "rule:qwen3.8-27b", f"rule:{STRONGEST}"]
98
+ stats = {k: [0, 0.0] for k in policies}
99
+ by_src = defaultdict(lambda: defaultdict(list))
100
+ valid = exact = 0
101
+ for row, (r, agents, ok) in zip(test, parsed):
102
+ valid += ok
103
+ exact += ok and agents == json.loads(row["route"])["agents"]
104
+ choices = {"shaktimaan": None, "oracle": json.loads(row["route"])["agents"],
105
+ **{f"always:{a}": [a] for a in AGENTS},
106
+ **{f"rule:{a}": rule(row, a) for a in ["glm-5.3-flash", "qwen3.8-27b", STRONGEST]}}
107
+ for key, ag in choices.items():
108
+ if key == "shaktimaan":
109
+ c, cost = ens_results[row["id"]] if row["id"] in ens_results else score(row, agents)
110
+ else:
111
+ c, cost = score(row, ag)
112
+ stats[key][0] += c
113
+ stats[key][1] += cost
114
+ by_src[row["source"]][key].append(c)
115
+
116
+ n = len(test)
117
+ base_cost = stats[f"always:{STRONGEST}"][1]
118
+ report = {"n_test": n, "valid_json": valid / n, "route_match": exact / n, "live_ensembles": len(ens),
119
+ "live_ensembles_solved": sum(c for c, _ in ens_results.values()),
120
+ "route_counts": dict(Counter(",".join(ag) for _, ag, _ in parsed).most_common()),
121
+ "policies": {k: {"accuracy": round(v[0] / n, 4), "relative_cost": round(v[1] / base_cost, 4)}
122
+ for k, v in stats.items()},
123
+ "accuracy_by_source": {s: {"n": len(d["shaktimaan"]),
124
+ **{k: round(sum(v) / len(v), 3) for k, v in d.items()
125
+ if k in ("shaktimaan", "oracle", "rule:qwen3.8-27b", "rule:glm-5.3-flash",
126
+ f"always:{STRONGEST}")}}
127
+ for s, d in sorted(by_src.items())}}
128
+ print(json.dumps(report, indent=2))
129
+ json.dump(report, open(f"{path}/eval_results.json", "w"), indent=2)
gen.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 1: build a diverse task set with checkable answers and run every capable agent in the pool on it.
2
+
3
+ Sources: GSM8K, MATH, MMLU-Pro, ARC (text reasoning), MBPP + HumanEval (code, graded by running the tests in a
4
+ no-network Docker sandbox), ChartQA (images), LongBench-v2 (long context, up to ~260k tokens).
5
+ Agents that cannot take a task (no image input, context too small) are recorded as failures without being called.
6
+ Reuses results already in data/runs.jsonl and only runs missing (task, agent) pairs. Writes data/runs_v3.jsonl.
7
+ """
8
+ import asyncio, base64, io, json, os, random, sys, time
9
+ import httpx
10
+ from datasets import load_dataset
11
+ from tqdm import tqdm
12
+ from common import (AGENTS, CODE_SYSTEM, POOL, SOLVER_SYSTEM, VISION, capable, endpoints, est_tokens, extract_code,
13
+ extract_final, grade, max_out, truncate)
14
+
15
+ OLD, OUT = "data/runs.jsonl", os.environ.get("OUT", "data/runs_v3.jsonl")
16
+ SIZES = {"gsm8k": 1000, "math": 1000, "mmlu_pro": 1200, "arc": 800, "mbpp": 700, "chartqa": 300}
17
+ LONG_TOKENS = 32768 # prompts above this get their own small concurrency budget
18
+ random.seed(0)
19
+
20
+
21
+ def load_tasks():
22
+ tasks = []
23
+ # --- text reasoning (identical to v1/v2 so existing results are reused by id) ---
24
+ ds = load_dataset("openai/gsm8k", "main", split="train").shuffle(seed=0).select(range(SIZES["gsm8k"]))
25
+ for i, r in enumerate(ds):
26
+ tasks.append(dict(id=f"gsm8k-{i}", source="gsm8k", domain="math", kind="num",
27
+ prompt=r["question"], gold=r["answer"].split("####")[-1].strip()))
28
+ rows = []
29
+ for cfg in ["algebra", "counting_and_probability", "geometry", "intermediate_algebra",
30
+ "number_theory", "prealgebra", "precalculus"]:
31
+ rows += list(load_dataset("EleutherAI/hendrycks_math", cfg, split="train"))
32
+ from common import last_boxed
33
+ rows = [r for r in rows if last_boxed(r["solution"])]
34
+ random.shuffle(rows)
35
+ for i, r in enumerate(rows[:SIZES["math"]]):
36
+ tasks.append(dict(id=f"math-{i}", source="math", domain="math", kind="math", level=r["level"],
37
+ prompt=r["problem"], gold=last_boxed(r["solution"])))
38
+ ds = load_dataset("TIGER-Lab/MMLU-Pro", split="test").shuffle(seed=0).select(range(SIZES["mmlu_pro"]))
39
+ for i, r in enumerate(ds):
40
+ opts = "\n".join(f"{chr(65 + k)}. {o}" for k, o in enumerate(r["options"]))
41
+ tasks.append(dict(id=f"mmlupro-{i}", source="mmlu_pro", domain=r["category"], kind="mc",
42
+ prompt=f"{r['question']}\n\n{opts}", gold=r["answer"]))
43
+ ds = load_dataset("allenai/ai2_arc", "ARC-Challenge", split="train").shuffle(seed=0)
44
+ ds = ds.select(range(min(SIZES["arc"], len(ds))))
45
+ for i, r in enumerate(ds):
46
+ labels = r["choices"]["label"]
47
+ opts = "\n".join(f"{chr(65 + k)}. {t}" for k, t in enumerate(r["choices"]["text"]))
48
+ gold = chr(65 + labels.index(r["answerKey"]))
49
+ tasks.append(dict(id=f"arc-{i}", source="arc", domain="science", kind="mc",
50
+ prompt=f"{r['question']}\n\n{opts}", gold=gold))
51
+ # --- code ---
52
+ mbpp = []
53
+ for split in ["train", "test", "validation", "prompt"]:
54
+ mbpp += list(load_dataset("google-research-datasets/mbpp", "full", split=split))
55
+ random.Random(2).shuffle(mbpp)
56
+ for r in mbpp[:SIZES["mbpp"]]:
57
+ tasks.append(dict(id=f"mbpp-{r['task_id']}", source="mbpp", domain="code", kind="code",
58
+ prompt=f"{r['text']}\nYour code should pass these tests:\n" + "\n".join(r["test_list"]),
59
+ gold=(r["test_setup_code"] or "") + "\n" + "\n".join(r["test_list"])))
60
+ for r in load_dataset("openai/openai_humaneval", split="test"):
61
+ tasks.append(dict(id=f"humaneval-{r['task_id'].split('/')[-1]}", source="humaneval", domain="code",
62
+ kind="code", prompt=f"Complete the following Python function:\n```python\n{r['prompt']}```",
63
+ gold=f"{r['test']}\ncheck({r['entry_point']})"))
64
+ # --- vision ---
65
+ ds = load_dataset("HuggingFaceM4/ChartQA", split="test").shuffle(seed=0).select(range(SIZES["chartqa"]))
66
+ for i, r in enumerate(ds):
67
+ buf = io.BytesIO()
68
+ r["image"].convert("RGB").save(buf, "PNG")
69
+ tasks.append(dict(id=f"chartqa-{i}", source="chartqa", domain="vision", kind="relaxed", has_image=True,
70
+ image="data:image/png;base64," + base64.b64encode(buf.getvalue()).decode(),
71
+ prompt=r["query"], gold=str(r["label"][0])))
72
+ # --- long context ---
73
+ for r in load_dataset("THUDM/LongBench-v2", split="train"):
74
+ prompt = (f"Read the following text and answer the question.\n\n{r['context']}\n\nQuestion: {r['question']}\n"
75
+ f"A. {r['choice_A']}\nB. {r['choice_B']}\nC. {r['choice_C']}\nD. {r['choice_D']}")
76
+ if any(capable(a, prompt, False) for a in AGENTS):
77
+ tasks.append(dict(id=f"longbench-{r['_id']}", source="longbench", domain=f"long:{r['domain']}",
78
+ kind="mc", prompt=prompt, gold=r["answer"]))
79
+ for t in tasks:
80
+ t.setdefault("has_image", False)
81
+ t["est_tokens"] = est_tokens(t["prompt"])
82
+ return tasks
83
+
84
+
85
+ async def call(client, url, model, sem, messages, max_tokens=8192):
86
+ """POST a chat completion. HTTP 400 (e.g. context overflow) is returned immediately as `rejected`."""
87
+ if isinstance(messages, str):
88
+ messages = [{"role": "system", "content": SOLVER_SYSTEM}, {"role": "user", "content": messages}]
89
+ async with sem:
90
+ t0, err = time.time(), None
91
+ for attempt in range(4):
92
+ try:
93
+ r = await client.post(url, json={"model": model, "max_tokens": max_tokens, "temperature": 0.6,
94
+ "messages": messages})
95
+ if r.status_code == 400:
96
+ return dict(text="", rejected=r.text[:200], prompt_tokens=0, completion_tokens=0,
97
+ latency=time.time() - t0)
98
+ r.raise_for_status()
99
+ j = r.json()
100
+ return dict(text=j["choices"][0]["message"].get("content") or "",
101
+ finish=j["choices"][0].get("finish_reason"),
102
+ prompt_tokens=j["usage"]["prompt_tokens"],
103
+ completion_tokens=j["usage"]["completion_tokens"], latency=time.time() - t0)
104
+ except Exception as e: # noqa: BLE001
105
+ err = repr(e)[:200]
106
+ await asyncio.sleep(2 ** attempt)
107
+ return dict(text="", error=err, prompt_tokens=0, completion_tokens=0, latency=time.time() - t0)
108
+
109
+
110
+ SANDBOX = asyncio.Semaphore(24)
111
+
112
+
113
+ async def run_tests(code: str, tests: str) -> bool:
114
+ """Run model code + tests in a throwaway container: no network, 512 MB RAM, 1 CPU, 15 s."""
115
+ if not code.strip():
116
+ return False
117
+ async with SANDBOX:
118
+ proc = await asyncio.create_subprocess_exec(
119
+ "docker", "run", "--rm", "-i", "--network", "none", "--memory", "512m", "--cpus", "1",
120
+ "--pids-limit", "64", "--read-only", "--tmpfs", "/tmp:size=64m", "python:3.12-slim",
121
+ "timeout", "15", "python", "-", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.DEVNULL,
122
+ stderr=asyncio.subprocess.DEVNULL)
123
+ try:
124
+ await asyncio.wait_for(proc.communicate((code + "\n\n" + tests).encode()), timeout=60)
125
+ except asyncio.TimeoutError:
126
+ proc.kill()
127
+ return False
128
+ return proc.returncode == 0
129
+
130
+
131
+ def messages_for(task):
132
+ if task["kind"] == "code":
133
+ return [{"role": "system", "content": CODE_SYSTEM}, {"role": "user", "content": task["prompt"]}]
134
+ user = task["prompt"]
135
+ if task["has_image"]:
136
+ user = [{"type": "image_url", "image_url": {"url": task["image"]}}, {"type": "text", "text": task["prompt"]}]
137
+ return [{"role": "system", "content": SOLVER_SYSTEM}, {"role": "user", "content": user}]
138
+
139
+
140
+ async def run_agent(client, urls, sems, task, agent):
141
+ if not capable(agent, task["prompt"], task["has_image"]):
142
+ reason = "no_vision" if task["has_image"] and agent not in VISION else "context"
143
+ return dict(pred="", correct=False, prompt_tokens=0, completion_tokens=0, latency=0, finish=None,
144
+ error=None, skipped=reason)
145
+ sem = sems["long" if task["est_tokens"] > LONG_TOKENS else "short"][agent]
146
+ o = await call(client, urls[agent], POOL[agent]["model"], sem, messages_for(task),
147
+ max_out(agent, task["est_tokens"] + 1500 * task["has_image"]))
148
+ if task["kind"] == "code":
149
+ code = extract_code(o["text"])
150
+ pred, correct = code[:300], await run_tests(code, task["gold"])
151
+ else:
152
+ pred = extract_final(o["text"])
153
+ correct = grade(task["kind"], pred, task["gold"])
154
+ return dict(pred=pred[:300], correct=correct, **{k: o[k] for k in ("prompt_tokens", "completion_tokens",
155
+ "latency")},
156
+ finish=o.get("finish"), error=o.get("error"), skipped="rejected" if o.get("rejected") else None)
157
+
158
+
159
+ async def run_task(client, urls, sems, task, prior):
160
+ todo = [a for a in AGENTS if a not in prior]
161
+ outs = await asyncio.gather(*[run_agent(client, urls, sems, task, a) for a in todo])
162
+ task = {k: v for k, v in task.items() if k != "image"}
163
+ if task["source"] == "longbench": # keep the dataset small: store only what the router reads of huge contexts
164
+ task["prompt"] = truncate(task["prompt"])
165
+ task["prompt_truncated"] = True
166
+ task["results"] = {**prior, **dict(zip(todo, outs))}
167
+ return task
168
+
169
+
170
+ async def main():
171
+ os.makedirs("data", exist_ok=True)
172
+ tasks = load_tasks()
173
+ old = {}
174
+ if os.path.exists(OLD):
175
+ old = {r["id"]: r["results"] for r in map(json.loads, open(OLD))
176
+ if not any(v.get("error") for v in r["results"].values())}
177
+ done = {json.loads(l)["id"] for l in open(OUT)} if os.path.exists(OUT) else set()
178
+ todo = [t for t in tasks if t["id"] not in done]
179
+ if os.environ.get("SOURCES"):
180
+ todo = [t for t in todo if t["source"] in os.environ["SOURCES"].split(",")]
181
+ if os.environ.get("LIMIT"):
182
+ todo = random.Random(1).sample(todo, int(os.environ["LIMIT"]))
183
+ print(f"{len(tasks)} tasks ({sum(t['source'] == 'longbench' for t in tasks)} long), {len(done)} done, "
184
+ f"{len(todo)} to run, {sum(t['id'] in old for t in todo)} reuse prior results", flush=True)
185
+ urls = {n: f"{base.rstrip('/')}/chat/completions" for n, base in endpoints().items()}
186
+ sems = {"short": {n: asyncio.Semaphore(POOL[n]["concurrency"]) for n in POOL},
187
+ "long": {n: asyncio.Semaphore(4) for n in POOL}}
188
+ limits = httpx.Limits(max_connections=400, max_keepalive_connections=400)
189
+ async with httpx.AsyncClient(timeout=httpx.Timeout(1800), limits=limits) as client:
190
+ with open(OUT, "a") as f:
191
+ jobs = [run_task(client, urls, sems, t, old.get(t["id"], {})) for t in todo]
192
+ for fut in tqdm(asyncio.as_completed(jobs), total=len(jobs), mininterval=30, file=sys.stdout):
193
+ f.write(json.dumps(await fut) + "\n")
194
+ f.flush()
195
+
196
+
197
+ if __name__ == "__main__":
198
+ asyncio.run(main())
generation_config.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token_id": 151643,
3
+ "do_sample": true,
4
+ "eos_token_id": [
5
+ 151645,
6
+ 151643
7
+ ],
8
+ "pad_token_id": 151643,
9
+ "repetition_penalty": 1.1,
10
+ "temperature": 0.7,
11
+ "top_k": 20,
12
+ "top_p": 0.8,
13
+ "transformers_version": "5.19.0"
14
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7e9af94dfa0109e0d3fc82dc339210890b3eeaa2c9261a2adf84c202a71d7805
3
+ size 1976163472
orchestrate.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shaktimaan runtime: route a task with the 0.5B router, then call the chosen agent(s) via OpenAI-compatible APIs.
2
+
3
+ Usage:
4
+ SHAKTIMAAN_ENDPOINTS='{"glm-5.3-flash": "http://host:8000/v1", ...}' \
5
+ python orchestrate.py [--image chart.png] [--code] "task"
6
+ """
7
+ import argparse, asyncio, base64, json, mimetypes, os, sys
8
+ import httpx
9
+ import torch
10
+ from transformers import AutoModelForCausalLM, AutoTokenizer
11
+ from common import (CODE_SYSTEM, POOL, ROUTER_SYSTEM, SOLVER_SYSTEM, STRONGEST, capable, endpoints, est_tokens,
12
+ max_out, router_view, strip_think)
13
+
14
+ ROUTER = os.environ.get("SHAKTIMAAN_ROUTER", "podstack/Shaktimaan")
15
+ ENDPOINTS = endpoints()
16
+
17
+
18
+ def route(task, has_image):
19
+ tok = AutoTokenizer.from_pretrained(ROUTER)
20
+ model = AutoModelForCausalLM.from_pretrained(ROUTER, dtype=torch.bfloat16)
21
+ enc = tok.apply_chat_template([{"role": "system", "content": ROUTER_SYSTEM},
22
+ {"role": "user", "content": router_view(task, has_image)}],
23
+ add_generation_prompt=True, return_tensors="pt", return_dict=True)
24
+ out = model.generate(**enc, max_new_tokens=96, do_sample=False)
25
+ try:
26
+ plan = json.loads(tok.decode(out[0, enc["input_ids"].shape[1]:], skip_special_tokens=True))
27
+ except Exception: # noqa: BLE001
28
+ plan = {"strategy": "single", "agents": []}
29
+ # keep only agents that are reachable and can actually take this input
30
+ ok = [a for a in plan.get("agents", []) if a in ENDPOINTS and capable(a, task, has_image)]
31
+ plan["agents"] = ok or [a for a in [STRONGEST, "kimi-vl"] if a in ENDPOINTS and capable(a, task, has_image)][:1]
32
+ return plan
33
+
34
+
35
+ async def ask(client, agent, messages, n_tokens):
36
+ r = await client.post(f"{ENDPOINTS[agent]}/chat/completions",
37
+ json={"model": POOL[agent]["model"], "messages": messages,
38
+ "max_tokens": max_out(agent, n_tokens)})
39
+ r.raise_for_status()
40
+ return strip_think(r.json()["choices"][0]["message"]["content"])
41
+
42
+
43
+ async def run(task, image=None, code=False):
44
+ has_image = image is not None
45
+ plan = route(task, has_image)
46
+ print("route:", json.dumps(plan), file=sys.stderr)
47
+ system = CODE_SYSTEM if code else SOLVER_SYSTEM
48
+ user = [{"type": "image_url", "image_url": {"url": image}}, {"type": "text", "text": task}] if has_image else task
49
+ msgs = [{"role": "system", "content": system}, {"role": "user", "content": user}]
50
+ n = est_tokens(task) + 1500 * has_image
51
+ async with httpx.AsyncClient(timeout=1800) as client:
52
+ if plan.get("strategy") != "ensemble" or len(plan["agents"]) == 1:
53
+ return await ask(client, plan["agents"][0], msgs, n)
54
+ drafts = await asyncio.gather(*[ask(client, a, msgs, n) for a in plan["agents"]])
55
+ joined = "\n\n".join(f"### Candidate {i + 1}\n{d}" for i, d in enumerate(drafts))
56
+ agg = f"{task}\n\nIndependent candidate solutions:\n{joined}\n\n" \
57
+ "Check them, resolve disagreements, and give the best final answer."
58
+ return await ask(client, plan["agents"][-1], [{"role": "system", "content": system},
59
+ {"role": "user", "content": agg}], est_tokens(agg))
60
+
61
+
62
+ if __name__ == "__main__":
63
+ p = argparse.ArgumentParser()
64
+ p.add_argument("task", nargs="+")
65
+ p.add_argument("--image", help="path to an image to attach")
66
+ p.add_argument("--code", action="store_true", help="ask agents for a Python code solution")
67
+ args = p.parse_args()
68
+ uri = None
69
+ if args.image:
70
+ mime = mimetypes.guess_type(args.image)[0] or "image/png"
71
+ uri = f"data:{mime};base64," + base64.b64encode(open(args.image, "rb").read()).decode()
72
+ print(asyncio.run(run(" ".join(args.task), uri, args.code)))
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3fd169731d2cbde95e10bf356d66d5997fd885dd8dbb6fb4684da3f23b2585d8
3
+ size 11421892
tokenizer_config.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "backend": "tokenizers",
4
+ "bos_token": null,
5
+ "clean_up_tokenization_spaces": false,
6
+ "eos_token": "<|im_end|>",
7
+ "errors": "replace",
8
+ "extra_special_tokens": [
9
+ "<|im_start|>",
10
+ "<|im_end|>",
11
+ "<|object_ref_start|>",
12
+ "<|object_ref_end|>",
13
+ "<|box_start|>",
14
+ "<|box_end|>",
15
+ "<|quad_start|>",
16
+ "<|quad_end|>",
17
+ "<|vision_start|>",
18
+ "<|vision_end|>",
19
+ "<|vision_pad|>",
20
+ "<|image_pad|>",
21
+ "<|video_pad|>"
22
+ ],
23
+ "is_local": false,
24
+ "local_files_only": false,
25
+ "model_max_length": 131072,
26
+ "pad_token": "<|endoftext|>",
27
+ "split_special_tokens": false,
28
+ "tokenizer_class": "Qwen2Tokenizer",
29
+ "unk_token": null
30
+ }
train.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 3: full fine-tune of Qwen2.5-0.5B-Instruct into the Shaktimaan router (loss on the route only)."""
2
+ import os
3
+ from datasets import load_from_disk
4
+ from trl import SFTConfig, SFTTrainer
5
+
6
+ BASE = os.environ.get("BASE", "Qwen/Qwen2.5-0.5B-Instruct")
7
+ ds = load_from_disk("data/shaktimaan_routing").map(
8
+ lambda r: {"prompt": r["messages"][:2], "completion": r["messages"][2:]}).select_columns(["prompt", "completion"])
9
+
10
+ cfg = SFTConfig(
11
+ output_dir="ckpt", num_train_epochs=3, per_device_train_batch_size=8, gradient_accumulation_steps=4,
12
+ learning_rate=2e-5, lr_scheduler_type="cosine", warmup_steps=20, weight_decay=0.0, bf16=True,
13
+ max_length=3072, completion_only_loss=True, logging_steps=10, eval_strategy="epoch", save_strategy="epoch",
14
+ save_total_limit=1, report_to=[], gradient_checkpointing=True, dataloader_num_workers=4,
15
+ )
16
+ trainer = SFTTrainer(model=BASE, args=cfg, train_dataset=ds["train"], eval_dataset=ds["test"])
17
+ trainer.train()
18
+ trainer.save_model("shaktimaan-0.5b")
19
+ trainer.processing_class.save_pretrained("shaktimaan-0.5b")