bclermo commited on
Commit
0b1f228
·
verified ·
1 Parent(s): 73a15ec

Upload folder using huggingface_hub

Browse files
Files changed (9) hide show
  1. __init__.py +16 -0
  2. agent.py +196 -0
  3. chat_template.py +102 -0
  4. config.py +210 -0
  5. inference.py +334 -0
  6. kv_cache.py +311 -0
  7. litert_model.py +176 -0
  8. model.py +248 -0
  9. quantization.py +301 -0
__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hermes Edge — Package Init
3
+ """
4
+
5
+ __version__ = "0.2.0"
6
+ __author__ = "Barry Clerjuste"
7
+ __email__ = "bclerjuste@gmail.com"
8
+
9
+ from hermes.config import HermesConfig, get_config, PRESETS
10
+ from hermes.chat_template import build_prompt, Message
11
+ from hermes.litert_model import LiteRTModel
12
+
13
+ try:
14
+ from hermes.agent import HermesAgent, AgentConfig
15
+ except ImportError:
16
+ pass
agent.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hermes Edge Agent — On-Device AI Agent Framework
3
+
4
+ Combines DeepSeek-style reasoning + Hermes tool calling + LiteRT-LM runtime
5
+ into a coherent agent loop for on-device inference.
6
+
7
+ Usage:
8
+ from hermes.agent import HermesAgent
9
+ from hermes.tools import ToolRegistry
10
+ from hermes.litert_model import LiteRTModel
11
+
12
+ model = LiteRTModel("/path/to/model.litertlm")
13
+ agent = HermesAgent(model)
14
+ response = agent.run("What's the weather?")
15
+ """
16
+
17
+ import logging
18
+ import time
19
+ from dataclasses import dataclass, field
20
+
21
+ from hermes.chat_template import build_prompt, Message
22
+ from scripts.deepseek_reasoning_template import ReasoningPipeline, ReasoningResult
23
+ from scripts.hermes_tool_format import ToolRegistry, HermesToolFormatter
24
+ from scripts.dspark_draft import DSparkDraftEngine, DSparkConfig, NGramDraftModel
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class AgentConfig:
31
+ max_tool_rounds: int = 5
32
+ max_tokens: int = 512
33
+ temperature: float = 0.7
34
+ top_k: int = 40
35
+ use_reasoning: bool = True
36
+ use_speculative_decoding: bool = True
37
+ draft_k: int = 4
38
+ system_prompt: str = ""
39
+
40
+
41
+ DEFAULT_SYSTEM = (
42
+ "You are Hermes Edge, an on-device AI agent powered by Raven AI ecosystem. "
43
+ "You run fully offline via LiteRT-LM on iPhone 16 / Android. "
44
+ "You have access to tools and can reason step by step. "
45
+ "Always prefer local computation. Be helpful, concise, and accurate."
46
+ )
47
+
48
+
49
+ @dataclass
50
+ class AgentTurn:
51
+ user_input: str = ""
52
+ assistant_response: str = ""
53
+ thinking: str = ""
54
+ tool_calls: list[dict] = field(default_factory=list)
55
+ tool_results: list[dict] = field(default_factory=list)
56
+ latency_ms: float = 0.0
57
+ tokens_used: int = 0
58
+
59
+
60
+ @dataclass
61
+ class Conversation:
62
+ messages: list[Message] = field(default_factory=list)
63
+ turns: list[AgentTurn] = field(default_factory=list)
64
+
65
+ def add_user(self, text: str) -> None:
66
+ self.messages.append(Message(role="user", content=text))
67
+
68
+ def add_assistant(self, text: str) -> None:
69
+ self.messages.append(Message(role="assistant", content=text))
70
+
71
+ def add_tool_result(self, name: str, content: str) -> None:
72
+ self.messages.append(Message(role="tool", content=f"<tool_response>{name}: {content}</tool_response>"))
73
+
74
+
75
+ class HermesAgent:
76
+ """Full agent loop combining reasoning, tool calling, and speculative decoding."""
77
+
78
+ def __init__(
79
+ self,
80
+ model=None,
81
+ tool_registry: ToolRegistry | None = None,
82
+ config: AgentConfig | None = None,
83
+ ):
84
+ self.model = model
85
+ self.config = config or AgentConfig()
86
+ self.tools = tool_registry or ToolRegistry()
87
+ self.conversation = Conversation()
88
+ self.reasoning = ReasoningPipeline(use_reasoning=self.config.use_reasoning)
89
+ self.tool_formatter = HermesToolFormatter()
90
+ self.draft_engine: DSparkDraftEngine | None = None
91
+ self._init_draft_engine()
92
+
93
+ def _init_draft_engine(self) -> None:
94
+ if self.config.use_speculative_decoding and self.model is not None:
95
+ vocab_size = getattr(self.model, "vocab_size", 32000)
96
+ draft = NGramDraftModel(vocab_size=vocab_size, max_order=3)
97
+ dconfig = DSparkConfig(
98
+ draft_k=self.config.draft_k,
99
+ temperature=self.config.temperature,
100
+ top_k=self.config.top_k,
101
+ )
102
+ self.draft_engine = DSparkDraftEngine(self.model, draft, dconfig)
103
+
104
+ def set_model(self, model) -> None:
105
+ self.model = model
106
+ self._init_draft_engine()
107
+
108
+ def register_tool(self, name: str, description: str, func, parameters: dict | None = None) -> None:
109
+ self.tools.register(name, description, func, parameters)
110
+
111
+ def run(self, user_input: str, context: str | None = None) -> str:
112
+ """Process a user input through the full agent pipeline."""
113
+ if not self.model:
114
+ return "Error: No model loaded."
115
+
116
+ turn = AgentTurn(user_input=user_input)
117
+ start = time.perf_counter()
118
+
119
+ if self.config.use_reasoning:
120
+ prompt = self.reasoning.build_reasoning_prompt(user_input, context)
121
+ else:
122
+ tool_defs = self.tools.get_defs()
123
+ self.tool_formatter.set_tools(tool_defs)
124
+ prompt = self.tool_formatter.build_tool_prompt(user_input, context=context)
125
+
126
+ raw_output = self._generate(prompt)
127
+ turn.tokens_used = len(raw_output) // 4
128
+
129
+ parsed = self.reasoning.parse_response(raw_output)
130
+ turn.thinking = parsed.thinking
131
+ turn.assistant_response = parsed.answer
132
+ turn.tool_calls = parsed.tool_calls
133
+
134
+ tool_round = 0
135
+ while parsed.tool_calls and tool_round < self.config.max_tool_rounds:
136
+ tool_round += 1
137
+ for call in parsed.tool_calls:
138
+ name = call.get("name", "")
139
+ args = call.get("arguments", {})
140
+ result = self.tools.execute(name, args)
141
+ turn.tool_results.append({"name": name, "content": result.content, "success": result.success})
142
+ self.conversation.add_tool_result(name, result.content)
143
+
144
+ tool_prompt = self.reasoning.build_tool_result_prompt(
145
+ tool_name=name if parsed.tool_calls else "unknown",
146
+ tool_content=result.content if parsed.tool_calls else "",
147
+ original_prompt=prompt,
148
+ )
149
+ raw_output = self._generate(tool_prompt)
150
+ parsed = self.reasoning.parse_response(raw_output)
151
+ turn.assistant_response += "\n" + parsed.answer
152
+ turn.tool_calls.extend(parsed.tool_calls)
153
+
154
+ turn.latency_ms = (time.perf_counter() - start) * 1000
155
+ self.conversation.turns.append(turn)
156
+ self.conversation.add_user(user_input)
157
+ self.conversation.add_assistant(turn.assistant_response)
158
+
159
+ log.info(
160
+ "Agent turn: %d ms, %d tokens, %d tool calls, reasoning=%s",
161
+ turn.latency_ms,
162
+ turn.tokens_used,
163
+ len(turn.tool_calls),
164
+ bool(turn.thinking),
165
+ )
166
+ return turn.assistant_response
167
+
168
+ def _generate(self, prompt: str) -> str:
169
+ """Generate text using the model, optionally with speculative decoding."""
170
+ try:
171
+ if self.draft_engine and self.model:
172
+ prompt_ids = self._encode(prompt)
173
+ result = self.draft_engine.speculative_generate(
174
+ prompt_ids=prompt_ids,
175
+ max_tokens=self.config.max_tokens,
176
+ tokenizer=getattr(self.model, "tokenizer", None),
177
+ )
178
+ if result.text:
179
+ return result.text
180
+ except Exception as exc:
181
+ log.warning("Speculative decoding failed, falling back: %s", exc)
182
+
183
+ if hasattr(self.model, "generate"):
184
+ return self.model.generate(prompt, max_tokens=self.config.max_tokens)
185
+ return f"[Model would generate response for: {prompt[:50]}...]"
186
+
187
+ @staticmethod
188
+ def _encode(text: str) -> list[int]:
189
+ return list(text.encode("utf-8")[:256])
190
+
191
+ def get_conversation_summary(self) -> str:
192
+ """Get a summary of the conversation."""
193
+ turns = len(self.conversation.turns)
194
+ total_tokens = sum(t.tokens_used for t in self.conversation.turns)
195
+ total_latency = sum(t.latency_ms for t in self.conversation.turns)
196
+ return f"{turns} turns, ~{total_tokens} tokens, ~{total_latency:.0f}ms total"
chat_template.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hermes chat + tool-calling prompt format.
2
+
3
+ The format follows the ChatML-style convention used by the original Hermes
4
+ models (`<|im_start|>role ... <|im_end|>`) and adds explicit tool-call markers
5
+ so the on-device model can emit structured function calls that the Google AI
6
+ Edge Gallery Agent Skills runtime can parse and dispatch.
7
+
8
+ A tool call is emitted as::
9
+
10
+ <tool_call>{"name": "calculator", "arguments": {"expression": "2+2"}}</tool_call>
11
+
12
+ Constrained decoding in LiteRT-LM can be anchored on the ``<tool_call>`` /
13
+ ``</tool_call>`` sentinels to guarantee well-formed JSON.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from dataclasses import dataclass
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ IM_START = "<|im_start|>"
23
+ IM_END = "<|im_end|>"
24
+ TOOL_CALL_START = "<tool_call>"
25
+ TOOL_CALL_END = "</tool_call>"
26
+ TOOL_RESPONSE_START = "<tool_response>"
27
+ TOOL_RESPONSE_END = "</tool_response>"
28
+
29
+ DEFAULT_SYSTEM_PROMPT = (
30
+ "You are Hermes, a helpful on-device AI agent. You can call tools when "
31
+ "they help answer the user. To call a tool, respond ONLY with a "
32
+ "<tool_call> block containing JSON: "
33
+ '{"name": <tool_name>, "arguments": <json_args>}. '
34
+ "After receiving a <tool_response>, use it to answer the user."
35
+ )
36
+
37
+
38
+ @dataclass
39
+ class Message:
40
+ role: str # "system" | "user" | "assistant" | "tool"
41
+ content: str
42
+
43
+
44
+ def render_tools(tools: List[Dict[str, Any]]) -> str:
45
+ """Render available tool schemas into the system context."""
46
+ if not tools:
47
+ return ""
48
+ lines = ["You have access to the following tools:"]
49
+ for tool in tools:
50
+ lines.append(json.dumps(tool, ensure_ascii=False))
51
+ return "\n".join(lines)
52
+
53
+
54
+ def build_prompt(
55
+ messages: List[Message],
56
+ tools: Optional[List[Dict[str, Any]]] = None,
57
+ system_prompt: str = DEFAULT_SYSTEM_PROMPT,
58
+ add_generation_prompt: bool = True,
59
+ ) -> str:
60
+ """Render a list of messages into the Hermes ChatML training/inference string."""
61
+ parts: List[str] = []
62
+
63
+ system_content = system_prompt
64
+ tool_block = render_tools(tools or [])
65
+ if tool_block:
66
+ system_content = f"{system_prompt}\n\n{tool_block}"
67
+ parts.append(f"{IM_START}system\n{system_content}{IM_END}")
68
+
69
+ for msg in messages:
70
+ if msg.role == "tool":
71
+ body = f"{TOOL_RESPONSE_START}\n{msg.content}\n{TOOL_RESPONSE_END}"
72
+ parts.append(f"{IM_START}tool\n{body}{IM_END}")
73
+ else:
74
+ parts.append(f"{IM_START}{msg.role}\n{msg.content}{IM_END}")
75
+
76
+ if add_generation_prompt:
77
+ parts.append(f"{IM_START}assistant\n")
78
+ return "\n".join(parts)
79
+
80
+
81
+ def format_tool_call(name: str, arguments: Dict[str, Any]) -> str:
82
+ """Serialize a tool call into the sentinel-wrapped JSON the model emits."""
83
+ payload = json.dumps({"name": name, "arguments": arguments}, ensure_ascii=False)
84
+ return f"{TOOL_CALL_START}{payload}{TOOL_CALL_END}"
85
+
86
+
87
+ def parse_tool_call(text: str) -> Optional[Dict[str, Any]]:
88
+ """Extract a tool call from model output, or None if absent/malformed."""
89
+ start = text.find(TOOL_CALL_START)
90
+ if start == -1:
91
+ return None
92
+ start += len(TOOL_CALL_START)
93
+ end = text.find(TOOL_CALL_END, start)
94
+ snippet = text[start:end] if end != -1 else text[start:]
95
+ try:
96
+ call = json.loads(snippet.strip())
97
+ except json.JSONDecodeError:
98
+ return None
99
+ if not isinstance(call, dict) or "name" not in call:
100
+ return None
101
+ call.setdefault("arguments", {})
102
+ return call
config.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Architecture configuration for the Hermes mobile transformer.
2
+
3
+ The configuration intentionally mirrors the knobs exposed by
4
+ ``ai_edge_torch.generative.layers.model_config`` so that the same numbers can
5
+ drive both the reference PyTorch implementation (used for training) and the
6
+ LiteRT conversion path. Keeping a single source of truth avoids the classic
7
+ "the converted graph does not match the trained weights" failure mode.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Optional
14
+
15
+
16
+ @dataclass
17
+ class HermesConfig:
18
+ """Hyper-parameters for a decoder-only, grouped-query-attention model.
19
+
20
+ Attributes:
21
+ vocab_size: SentencePiece vocabulary size (must match the tokenizer).
22
+ hidden_size: Model / embedding dimension.
23
+ intermediate_size: Feed-forward (MLP) inner dimension.
24
+ num_layers: Number of transformer decoder blocks.
25
+ num_heads: Number of query attention heads.
26
+ num_kv_heads: Number of key/value heads (GQA). Must divide num_heads.
27
+ head_dim: Dimension per attention head.
28
+ max_seq_len: Maximum context window (KV-cache length) in tokens.
29
+ rope_theta: RoPE base frequency.
30
+ rms_norm_eps: Epsilon for RMSNorm numerical stability.
31
+ tie_embeddings: Share input embedding and output projection weights.
32
+ pad_token_id / bos_token_id / eos_token_id: Special token ids.
33
+ """
34
+
35
+ vocab_size: int = 32000
36
+ hidden_size: int = 2048
37
+ intermediate_size: int = 5632
38
+ num_layers: int = 22
39
+ num_heads: int = 32
40
+ num_kv_heads: int = 4
41
+ head_dim: int = 64
42
+ max_seq_len: int = 4096
43
+ rope_theta: float = 10000.0
44
+ rms_norm_eps: float = 1e-6
45
+ tie_embeddings: bool = True
46
+ pad_token_id: int = 0
47
+ bos_token_id: int = 1
48
+ eos_token_id: int = 2
49
+ # Tool-call sentinel tokens reserved in the tokenizer for constrained
50
+ # decoding of function calls (see scripts/train.py chat template).
51
+ tool_call_start_id: Optional[int] = 3
52
+ tool_call_end_id: Optional[int] = 4
53
+
54
+ def __post_init__(self) -> None:
55
+ if self.num_heads % self.num_kv_heads != 0:
56
+ raise ValueError(
57
+ f"num_heads ({self.num_heads}) must be divisible by "
58
+ f"num_kv_heads ({self.num_kv_heads}) for grouped-query attention."
59
+ )
60
+ if self.hidden_size != self.num_heads * self.head_dim:
61
+ raise ValueError(
62
+ f"hidden_size ({self.hidden_size}) must equal "
63
+ f"num_heads * head_dim ({self.num_heads * self.head_dim})."
64
+ )
65
+
66
+ @property
67
+ def num_query_groups(self) -> int:
68
+ """Heads per KV group (the GQA sharing factor)."""
69
+ return self.num_heads // self.num_kv_heads
70
+
71
+ def estimated_parameters(self) -> int:
72
+ """Rough parameter count (weights only, ignoring norms/biases)."""
73
+ emb = self.vocab_size * self.hidden_size
74
+ q = self.hidden_size * self.num_heads * self.head_dim
75
+ kv = 2 * self.hidden_size * self.num_kv_heads * self.head_dim
76
+ o = self.num_heads * self.head_dim * self.hidden_size
77
+ attn = q + kv + o
78
+ mlp = 3 * self.hidden_size * self.intermediate_size # gate, up, down
79
+ per_layer = attn + mlp
80
+ total = emb + self.num_layers * per_layer
81
+ if not self.tie_embeddings:
82
+ total += emb # separate lm_head
83
+ return total
84
+
85
+
86
+ def hermes_1b_config() -> HermesConfig:
87
+ """~1B parameter variant — the default mobile target (~600 MB at INT4)."""
88
+ return HermesConfig(
89
+ vocab_size=32000,
90
+ hidden_size=2048,
91
+ intermediate_size=5632,
92
+ num_layers=22,
93
+ num_heads=32,
94
+ num_kv_heads=4,
95
+ head_dim=64,
96
+ max_seq_len=4096,
97
+ )
98
+
99
+
100
+ def hermes_500m_config() -> HermesConfig:
101
+ """~500M parameter variant — quality/speed sweet spot (~280 MB at INT4)."""
102
+ return HermesConfig(
103
+ vocab_size=32000,
104
+ hidden_size=1536,
105
+ intermediate_size=4096,
106
+ num_layers=24,
107
+ num_heads=24,
108
+ num_kv_heads=6,
109
+ head_dim=64,
110
+ max_seq_len=4096,
111
+ )
112
+
113
+
114
+ def hermes_270m_config() -> HermesConfig:
115
+ """~270M parameter variant — smallest, FunctionGemma-class footprint."""
116
+ return HermesConfig(
117
+ vocab_size=32000,
118
+ hidden_size=1024,
119
+ intermediate_size=2816,
120
+ num_layers=21,
121
+ num_heads=16,
122
+ num_kv_heads=4,
123
+ head_dim=64,
124
+ max_seq_len=4096,
125
+ )
126
+
127
+
128
+ # ── Gemma-inspired presets (optimized for iPhone 16 A18 Pro / ANE) ──────────
129
+
130
+ def gemma_3_1b_config() -> HermesConfig:
131
+ """Gemma 3 1B — Google's latest small model architecture.
132
+
133
+ Optimized for on-device inference with Apple Neural Engine:
134
+ - 32k vocab, 26 layers, 2048 hidden dim
135
+ - 16 heads, 8 KV heads (GQA ratio 2:1 — efficient for ANE)
136
+ - 8192 context window for longer conversations
137
+ - Ideal for iPhone 16 A18 Pro at INT4 (~250 MB on disk)
138
+ """
139
+ return HermesConfig(
140
+ vocab_size=32768,
141
+ hidden_size=2048,
142
+ intermediate_size=8192,
143
+ num_layers=26,
144
+ num_heads=16,
145
+ num_kv_heads=8,
146
+ head_dim=128,
147
+ max_seq_len=8192,
148
+ rope_theta=10000.0,
149
+ )
150
+
151
+
152
+ def gemma_2_2b_config() -> HermesConfig:
153
+ """Gemma 2 2B — higher quality with shared KV-heads.
154
+
155
+ Uses deeper GQA (2 KV heads shared across 16 query heads) for
156
+ memory-efficient inference on iPhone 16 Pro / Pro Max.
157
+ ~1.1 GB at INT4.
158
+ """
159
+ return HermesConfig(
160
+ vocab_size=32768,
161
+ hidden_size=2560,
162
+ intermediate_size=9216,
163
+ num_layers=26,
164
+ num_heads=16,
165
+ num_kv_heads=2,
166
+ head_dim=160,
167
+ max_seq_len=8192,
168
+ rope_theta=10000.0,
169
+ )
170
+
171
+
172
+ # ── DeepSeek-inspired distilled presets ─────────────────────────────────────
173
+
174
+ def hermes_distilled_1b_config() -> HermesConfig:
175
+ """Distilled 1B model using DeepSeek-R1 reasoning principles.
176
+
177
+ Knowledge distilled from Gemma 3 1B teacher. Maintains the same
178
+ architecture as hermes-1b but with extended context and tuned
179
+ for step-by-step reasoning before tool calls.
180
+ """
181
+ return HermesConfig(
182
+ vocab_size=32000,
183
+ hidden_size=2048,
184
+ intermediate_size=5632,
185
+ num_layers=22,
186
+ num_heads=32,
187
+ num_kv_heads=4,
188
+ head_dim=64,
189
+ max_seq_len=8192,
190
+ rope_theta=10000.0,
191
+ )
192
+
193
+
194
+ PRESETS = {
195
+ "hermes-1b": hermes_1b_config,
196
+ "hermes-500m": hermes_500m_config,
197
+ "hermes-270m": hermes_270m_config,
198
+ "gemma-3-1b": gemma_3_1b_config,
199
+ "gemma-2-2b": gemma_2_2b_config,
200
+ "hermes-distilled-1b": hermes_distilled_1b_config,
201
+ }
202
+
203
+
204
+ def get_config(name: str) -> HermesConfig:
205
+ """Look up a preset config by name."""
206
+ if name not in PRESETS:
207
+ raise KeyError(
208
+ f"Unknown preset '{name}'. Available: {sorted(PRESETS)}"
209
+ )
210
+ return PRESETS[name]()
inference.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Streaming inference engine for the Hermes mobile transformer.
2
+
3
+ :class:`HermesInference` wraps a :class:`~hermes.model.HermesForCausalLM` and a
4
+ SentencePiece tokenizer into a single object with three entry points:
5
+
6
+ * :meth:`generate` — low-level text completion with nucleus (top-p), top-k, and
7
+ repetition-penalty sampling, optionally streaming token strings as they decode.
8
+ * :meth:`chat` — renders a message list through the Hermes ChatML template, then
9
+ generates an assistant turn.
10
+ * :meth:`tool_call_loop` — the agentic loop: generate, parse any ``<tool_call>``,
11
+ dispatch it to a Python callable, feed the ``<tool_response>`` back, and repeat
12
+ until the model produces a plain answer (or ``max_rounds`` is hit).
13
+
14
+ Decoding reuses the existing :class:`~hermes.model.Attention` KV-cache: the
15
+ prompt is run once to prime per-layer caches, then each new token is decoded with
16
+ a single-position forward pass, so cost is linear in generated length rather than
17
+ quadratic.
18
+
19
+ The tokenizer is duck-typed: anything exposing ``encode(str) -> list[int]`` and
20
+ ``decode(list[int]) -> str`` works, which covers ``sentencepiece`` and the tiny
21
+ byte-level stub used in tests.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union
27
+
28
+ import torch
29
+ import torch.nn.functional as F
30
+
31
+ from hermes.chat_template import (
32
+ Message,
33
+ build_prompt,
34
+ parse_tool_call,
35
+ )
36
+ from hermes.config import HermesConfig
37
+ from hermes.model import HermesForCausalLM, build_model
38
+
39
+ KVList = List[Optional[Tuple[torch.Tensor, torch.Tensor]]]
40
+ ToolFn = Callable[..., Any]
41
+
42
+
43
+ class HermesInference:
44
+ """Load a Hermes checkpoint + tokenizer and run streaming generation."""
45
+
46
+ def __init__(
47
+ self,
48
+ model: HermesForCausalLM,
49
+ tokenizer: Any,
50
+ device: Union[str, torch.device] = "cpu",
51
+ preset_name: str = "custom",
52
+ ) -> None:
53
+ self.device = torch.device(device)
54
+ self.model = model.to(self.device).eval()
55
+ self.tokenizer = tokenizer
56
+ self.config: HermesConfig = model.config
57
+ self.preset_name = preset_name
58
+
59
+ # ------------------------------------------------------------------ #
60
+ # Construction helpers
61
+ # ------------------------------------------------------------------ #
62
+ @classmethod
63
+ def from_checkpoint(
64
+ cls,
65
+ config: HermesConfig,
66
+ checkpoint_path: Optional[str],
67
+ tokenizer: Any,
68
+ device: Union[str, torch.device] = "cpu",
69
+ preset_name: str = "custom",
70
+ ) -> "HermesInference":
71
+ """Build a model from ``config``, optionally load weights, and wrap it.
72
+
73
+ If ``checkpoint_path`` is None the model keeps its random init — handy for
74
+ CI and shape tests that don't need a trained checkpoint.
75
+ """
76
+ model = build_model(config)
77
+ if checkpoint_path is not None:
78
+ ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
79
+ state_dict = ckpt.get("model", ckpt) if isinstance(ckpt, dict) else ckpt
80
+ model.load_state_dict(state_dict, strict=False)
81
+ return cls(model, tokenizer, device=device, preset_name=preset_name)
82
+
83
+ def __repr__(self) -> str:
84
+ n_params = sum(p.numel() for p in self.model.parameters())
85
+ return (
86
+ f"HermesInference(preset={self.preset_name!r}, "
87
+ f"params={n_params / 1e6:.1f}M, "
88
+ f"layers={self.config.num_layers}, ctx={self.config.max_seq_len}, "
89
+ f"device={self.device.type})"
90
+ )
91
+
92
+ # ------------------------------------------------------------------ #
93
+ # Sampling
94
+ # ------------------------------------------------------------------ #
95
+ @staticmethod
96
+ def _apply_repetition_penalty(
97
+ logits: torch.Tensor, generated: List[int], penalty: float
98
+ ) -> torch.Tensor:
99
+ """Divide logits of already-seen tokens by ``penalty`` (CTRL-style)."""
100
+ if penalty == 1.0 or not generated:
101
+ return logits
102
+ idx = torch.tensor(sorted(set(generated)), device=logits.device)
103
+ selected = logits.index_select(-1, idx)
104
+ # Positive logits are divided, negative are multiplied (push both down).
105
+ selected = torch.where(selected > 0, selected / penalty, selected * penalty)
106
+ logits = logits.index_copy(-1, idx, selected)
107
+ return logits
108
+
109
+ @staticmethod
110
+ def _sample(
111
+ logits: torch.Tensor,
112
+ temperature: float,
113
+ top_p: float,
114
+ top_k: int,
115
+ ) -> int:
116
+ """Sample a single token id from ``logits`` with top-k + nucleus filtering."""
117
+ if temperature <= 0.0:
118
+ return int(logits.argmax(dim=-1))
119
+
120
+ logits = logits / temperature
121
+
122
+ if top_k and top_k > 0:
123
+ k = min(top_k, logits.size(-1))
124
+ kth = torch.topk(logits, k).values[..., -1, None]
125
+ logits = torch.where(
126
+ logits < kth, torch.full_like(logits, float("-inf")), logits
127
+ )
128
+
129
+ if top_p and 0.0 < top_p < 1.0:
130
+ sorted_logits, sorted_idx = torch.sort(logits, descending=True)
131
+ probs = F.softmax(sorted_logits, dim=-1)
132
+ cumulative = torch.cumsum(probs, dim=-1)
133
+ # Keep tokens up to and including the one that crosses top_p.
134
+ remove = cumulative - probs > top_p
135
+ sorted_logits = sorted_logits.masked_fill(remove, float("-inf"))
136
+ logits = torch.full_like(logits, float("-inf")).scatter(
137
+ -1, sorted_idx, sorted_logits
138
+ )
139
+
140
+ probs = F.softmax(logits, dim=-1)
141
+ return int(torch.multinomial(probs, num_samples=1))
142
+
143
+ # ------------------------------------------------------------------ #
144
+ # KV-cache primed decode
145
+ # ------------------------------------------------------------------ #
146
+ def _forward_with_cache(
147
+ self,
148
+ input_ids: torch.Tensor,
149
+ caches: KVList,
150
+ start_pos: int,
151
+ ) -> Tuple[torch.Tensor, KVList]:
152
+ """Run the model for ``input_ids`` reusing/extending per-layer KV caches.
153
+
154
+ Returns the last-position logits and the updated cache list. This bypasses
155
+ ``HermesForCausalLM.forward`` so it can thread the per-layer cache tuples
156
+ through the existing :class:`Attention` ``kv_cache`` argument.
157
+ """
158
+ model = self.model
159
+ b, t = input_ids.shape
160
+ x = model.embed_tokens(input_ids)
161
+ cos = model.rope_cos[start_pos : start_pos + t].to(x.device)
162
+ sin = model.rope_sin[start_pos : start_pos + t].to(x.device)
163
+
164
+ # Causal mask over the *full* attended length (past + current).
165
+ total = start_pos + t
166
+ full_mask = torch.full((t, total), float("-inf"), device=x.device)
167
+ full_mask = torch.triu(full_mask, diagonal=1 + start_pos)
168
+
169
+ new_caches: KVList = [None] * len(model.layers)
170
+ for i, layer in enumerate(model.layers):
171
+ h, new_cache = layer.self_attn(
172
+ layer.input_layernorm(x), cos, sin, full_mask, caches[i]
173
+ )
174
+ x = x + h
175
+ x = x + layer.mlp(layer.post_attention_layernorm(x))
176
+ new_caches[i] = new_cache
177
+
178
+ x = model.norm(x)
179
+ logits = model.lm_head(x[:, -1, :])
180
+ return logits, new_caches
181
+
182
+ @torch.no_grad()
183
+ def _generate_ids(
184
+ self,
185
+ prompt_ids: List[int],
186
+ max_new_tokens: int,
187
+ temperature: float,
188
+ top_p: float,
189
+ top_k: int,
190
+ repetition_penalty: float,
191
+ ) -> Iterator[int]:
192
+ """Yield newly generated token ids one at a time, using a KV cache."""
193
+ self.model.eval()
194
+ eos = self.config.eos_token_id
195
+ caches: KVList = [None] * len(self.model.layers)
196
+
197
+ # Keep room for at least one generated token: truncate the prompt to the
198
+ # most recent (max_seq_len - 1) tokens if it would otherwise fill context.
199
+ max_prompt = max(1, self.config.max_seq_len - 1)
200
+ if len(prompt_ids) > max_prompt:
201
+ prompt_ids = prompt_ids[-max_prompt:]
202
+
203
+ # Prime the cache on the full prompt in one prefill pass.
204
+ ids = torch.tensor([prompt_ids], dtype=torch.long, device=self.device)
205
+ logits, caches = self._forward_with_cache(ids, caches, start_pos=0)
206
+ pos = len(prompt_ids)
207
+
208
+ generated: List[int] = []
209
+ for _ in range(max_new_tokens):
210
+ step_logits = self._apply_repetition_penalty(
211
+ logits.clone(), prompt_ids + generated, repetition_penalty
212
+ )
213
+ next_id = self._sample(step_logits.squeeze(0), temperature, top_p, top_k)
214
+ if next_id == eos:
215
+ break
216
+ generated.append(next_id)
217
+ yield next_id
218
+
219
+ if pos >= self.config.max_seq_len:
220
+ break
221
+ step = torch.tensor([[next_id]], dtype=torch.long, device=self.device)
222
+ logits, caches = self._forward_with_cache(step, caches, start_pos=pos)
223
+ pos += 1
224
+
225
+ # ------------------------------------------------------------------ #
226
+ # Public generation API
227
+ # ------------------------------------------------------------------ #
228
+ def generate(
229
+ self,
230
+ prompt: str,
231
+ max_new_tokens: int = 128,
232
+ temperature: float = 0.8,
233
+ top_p: float = 0.95,
234
+ top_k: int = 50,
235
+ repetition_penalty: float = 1.1,
236
+ stream: bool = False,
237
+ ) -> Union[str, Iterator[str]]:
238
+ """Generate text from ``prompt``.
239
+
240
+ Returns the full completion string, or — if ``stream=True`` — a generator
241
+ that yields incremental token strings as they are produced.
242
+ """
243
+ prompt_ids = self.tokenizer.encode(prompt)
244
+
245
+ def _token_strings() -> Iterator[str]:
246
+ prev_text = ""
247
+ buffer: List[int] = []
248
+ for tok in self._generate_ids(
249
+ prompt_ids, max_new_tokens, temperature, top_p, top_k, repetition_penalty
250
+ ):
251
+ buffer.append(tok)
252
+ # Decode incrementally so multi-token characters render correctly.
253
+ text = self.tokenizer.decode(buffer)
254
+ delta = text[len(prev_text) :]
255
+ if delta:
256
+ prev_text = text
257
+ yield delta
258
+
259
+ if stream:
260
+ return _token_strings()
261
+ return "".join(_token_strings())
262
+
263
+ def chat(
264
+ self,
265
+ messages: List[Message],
266
+ tools: Optional[List[Dict[str, Any]]] = None,
267
+ **kwargs: Any,
268
+ ) -> str:
269
+ """Render ``messages`` (+ optional ``tools``) and generate a reply string."""
270
+ prompt = build_prompt(messages, tools=tools)
271
+ result = self.generate(prompt, stream=False, **kwargs)
272
+ assert isinstance(result, str)
273
+ return result
274
+
275
+ def tool_call_loop(
276
+ self,
277
+ messages: List[Message],
278
+ tools: List[Dict[str, Any]],
279
+ tool_functions: Dict[str, ToolFn],
280
+ max_rounds: int = 5,
281
+ **kwargs: Any,
282
+ ) -> List[Message]:
283
+ """Agentic loop: generate → parse tool call → dispatch → feed back.
284
+
285
+ Each round generates an assistant turn. If it contains a parseable
286
+ ``<tool_call>``, the named callable in ``tool_functions`` is invoked with
287
+ the parsed arguments and its result is appended as a ``tool`` message;
288
+ otherwise the loop ends. Returns the full conversation including all
289
+ assistant and tool turns appended.
290
+
291
+ Args:
292
+ messages: Seed conversation (mutated copy is returned).
293
+ tools: Tool schemas advertised to the model in the system prompt.
294
+ tool_functions: Maps tool ``name`` → Python callable.
295
+ max_rounds: Hard cap on generate/dispatch cycles.
296
+ """
297
+ convo = list(messages)
298
+ for _ in range(max_rounds):
299
+ reply = self.chat(convo, tools=tools, **kwargs)
300
+ convo.append(Message("assistant", reply))
301
+
302
+ call = parse_tool_call(reply)
303
+ if call is None:
304
+ break
305
+
306
+ fn = tool_functions.get(call["name"])
307
+ if fn is None:
308
+ convo.append(
309
+ Message("tool", f'{{"error": "unknown tool: {call["name"]}"}}')
310
+ )
311
+ continue
312
+ try:
313
+ result = fn(**call.get("arguments", {}))
314
+ except Exception as exc: # surface tool errors back to the model
315
+ result = {"error": str(exc)}
316
+ convo.append(Message("tool", str(result)))
317
+ return convo
318
+
319
+
320
+ if __name__ == "__main__": # pragma: no cover - manual smoke check
321
+ from hermes.config import hermes_270m_config
322
+
323
+ class _ByteTokenizer:
324
+ def encode(self, text: str) -> List[int]:
325
+ return [b % 32000 for b in text.encode("utf-8")] or [1]
326
+
327
+ def decode(self, ids: List[int]) -> str:
328
+ return bytes(i % 256 for i in ids).decode("utf-8", errors="replace")
329
+
330
+ engine = HermesInference.from_checkpoint(
331
+ hermes_270m_config(), None, _ByteTokenizer(), preset_name="hermes-270m"
332
+ )
333
+ print(engine)
334
+ print(engine.generate("Hello", max_new_tokens=8, temperature=0.0))
kv_cache.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Key/value cache managers for Hermes incremental decoding.
2
+
3
+ Three cache strategies are provided, trading off memory, context length, and
4
+ multi-session serving:
5
+
6
+ * :class:`StaticKVCache` — a single pre-allocated, fixed-size cache. This is the
7
+ shape LiteRT-LM exports on device: the converted TFLite ``decode`` signature
8
+ writes into a buffer sized to ``max_seq_len`` and never reallocates.
9
+ * :class:`SlidingWindowKVCache` — a :class:`StaticKVCache` subclass that keeps a
10
+ rolling window of the most recent ``window_size`` tokens, evicting the oldest
11
+ when full. Lets a 4096-ctx model hold an arbitrarily long conversation at a
12
+ bounded memory cost (at the price of forgetting distant context).
13
+ * :class:`PagedKVCache` — block-level paging à la vLLM. The cache is carved into
14
+ fixed-size blocks that are allocated on demand and freed per sequence, which
15
+ makes it suitable for serving many concurrent sessions from one pool.
16
+
17
+ All caches expose ``to(device)`` for device migration and
18
+ ``state_dict``/``load_state_dict`` for clean (de)serialization.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Dict, List, Tuple
24
+
25
+ import torch
26
+
27
+ KVTuple = Tuple[torch.Tensor, torch.Tensor]
28
+
29
+
30
+ class StaticKVCache:
31
+ """Pre-allocated fixed-size KV cache (the LiteRT-LM on-device shape).
32
+
33
+ Each layer owns a ``[1, num_kv_heads, max_seq_len, head_dim]`` key and value
34
+ buffer. :meth:`update` writes new entries at ``position`` and returns the
35
+ valid prefix; :meth:`get` returns the currently-filled slice.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ num_layers: int,
41
+ num_kv_heads: int,
42
+ max_seq_len: int,
43
+ head_dim: int,
44
+ dtype: torch.dtype = torch.float32,
45
+ device: torch.device | str = "cpu",
46
+ batch_size: int = 1,
47
+ ) -> None:
48
+ self.num_layers = num_layers
49
+ self.num_kv_heads = num_kv_heads
50
+ self.max_seq_len = max_seq_len
51
+ self.head_dim = head_dim
52
+ self.dtype = dtype
53
+ self.device = torch.device(device)
54
+ self.batch_size = batch_size
55
+ self._len = 0
56
+ self._alloc()
57
+
58
+ def _alloc(self) -> None:
59
+ shape = (self.batch_size, self.num_kv_heads, self.max_seq_len, self.head_dim)
60
+ self.keys: List[torch.Tensor] = [
61
+ torch.zeros(shape, dtype=self.dtype, device=self.device)
62
+ for _ in range(self.num_layers)
63
+ ]
64
+ self.values: List[torch.Tensor] = [
65
+ torch.zeros(shape, dtype=self.dtype, device=self.device)
66
+ for _ in range(self.num_layers)
67
+ ]
68
+
69
+ @property
70
+ def current_len(self) -> int:
71
+ """Number of valid (written) timesteps in the cache."""
72
+ return self._len
73
+
74
+ def reset(self) -> None:
75
+ """Zero the fill pointer (buffers are reused, not reallocated)."""
76
+ self._len = 0
77
+
78
+ def update(
79
+ self, layer_idx: int, k: torch.Tensor, v: torch.Tensor, position: int
80
+ ) -> KVTuple:
81
+ """Write ``k``/``v`` at ``position`` and return the valid prefix.
82
+
83
+ Args:
84
+ layer_idx: Which decoder layer this cache slot belongs to.
85
+ k: Keys ``[B, num_kv_heads, T_new, head_dim]``.
86
+ v: Values with the same shape.
87
+ position: Start timestep to write at (the current sequence length).
88
+
89
+ Returns:
90
+ ``(keys, values)`` covering timesteps ``[0, position + T_new)``.
91
+
92
+ Raises:
93
+ ValueError: If the write would exceed ``max_seq_len``.
94
+ """
95
+ t_new = k.shape[2]
96
+ end = position + t_new
97
+ if end > self.max_seq_len:
98
+ raise ValueError(
99
+ f"KV cache overflow: writing {t_new} tokens at position "
100
+ f"{position} exceeds max_seq_len={self.max_seq_len}."
101
+ )
102
+ self.keys[layer_idx][:, :, position:end, :] = k.to(self.dtype)
103
+ self.values[layer_idx][:, :, position:end, :] = v.to(self.dtype)
104
+ # The fill pointer tracks the furthest layer write of the current step.
105
+ self._len = max(self._len, end)
106
+ return self.get(layer_idx)
107
+
108
+ def get(self, layer_idx: int) -> KVTuple:
109
+ """Return the valid ``(keys, values)`` prefix for ``layer_idx``."""
110
+ return (
111
+ self.keys[layer_idx][:, :, : self._len, :],
112
+ self.values[layer_idx][:, :, : self._len, :],
113
+ )
114
+
115
+ def to(self, device: torch.device | str) -> "StaticKVCache":
116
+ """Move all buffers to ``device`` in place and return self."""
117
+ self.device = torch.device(device)
118
+ self.keys = [k.to(self.device) for k in self.keys]
119
+ self.values = [v.to(self.device) for v in self.values]
120
+ return self
121
+
122
+ def state_dict(self) -> Dict[str, object]:
123
+ """Serialize cache contents + metadata into a plain dict."""
124
+ return {
125
+ "class": type(self).__name__,
126
+ "num_layers": self.num_layers,
127
+ "num_kv_heads": self.num_kv_heads,
128
+ "max_seq_len": self.max_seq_len,
129
+ "head_dim": self.head_dim,
130
+ "batch_size": self.batch_size,
131
+ "len": self._len,
132
+ "keys": [k.cpu() for k in self.keys],
133
+ "values": [v.cpu() for v in self.values],
134
+ }
135
+
136
+ def load_state_dict(self, state: Dict[str, object]) -> None:
137
+ """Restore cache contents from :meth:`state_dict` output."""
138
+ self._len = int(state["len"]) # type: ignore[arg-type]
139
+ self.keys = [k.to(self.device) for k in state["keys"]] # type: ignore[union-attr]
140
+ self.values = [v.to(self.device) for v in state["values"]] # type: ignore[union-attr]
141
+
142
+
143
+ class SlidingWindowKVCache(StaticKVCache):
144
+ """Rolling-window KV cache that evicts the oldest tokens when full.
145
+
146
+ Keeps at most ``window_size`` timesteps. Once full, each new write shifts the
147
+ buffer left (dropping the oldest entries) so memory stays bounded — handy for
148
+ long-running chats inside the 4096-token model.
149
+ """
150
+
151
+ def __init__(
152
+ self,
153
+ num_layers: int,
154
+ num_kv_heads: int,
155
+ max_seq_len: int,
156
+ head_dim: int,
157
+ dtype: torch.dtype = torch.float32,
158
+ device: torch.device | str = "cpu",
159
+ batch_size: int = 1,
160
+ window_size: int = 1024,
161
+ ) -> None:
162
+ self.window_size = min(window_size, max_seq_len)
163
+ super().__init__(
164
+ num_layers, num_kv_heads, max_seq_len, head_dim, dtype, device, batch_size
165
+ )
166
+
167
+ def update(
168
+ self, layer_idx: int, k: torch.Tensor, v: torch.Tensor, position: int
169
+ ) -> KVTuple:
170
+ t_new = k.shape[2]
171
+ if t_new > self.window_size:
172
+ # Only the most recent window_size tokens can be retained.
173
+ k = k[:, :, -self.window_size :, :]
174
+ v = v[:, :, -self.window_size :, :]
175
+ t_new = self.window_size
176
+
177
+ if self._len + t_new > self.window_size:
178
+ evict = self._len + t_new - self.window_size
179
+ kept = self._len - evict
180
+ if kept > 0:
181
+ self.keys[layer_idx][:, :, :kept, :] = self.keys[layer_idx][
182
+ :, :, evict : self._len, :
183
+ ].clone()
184
+ self.values[layer_idx][:, :, :kept, :] = self.values[layer_idx][
185
+ :, :, evict : self._len, :
186
+ ].clone()
187
+ write_at = kept
188
+ else:
189
+ write_at = self._len
190
+
191
+ end = write_at + t_new
192
+ self.keys[layer_idx][:, :, write_at:end, :] = k.to(self.dtype)
193
+ self.values[layer_idx][:, :, write_at:end, :] = v.to(self.dtype)
194
+ # current_len is shared across layers; cap it at the window.
195
+ self._len = min(end, self.window_size)
196
+ return self.get(layer_idx)
197
+
198
+
199
+ class PagedKVCache:
200
+ """Block-paged KV cache for multi-session serving (vLLM-style).
201
+
202
+ The KV pool is divided into ``num_blocks`` blocks of ``block_size`` tokens.
203
+ Sequences request blocks via :meth:`allocate_block`; :meth:`free_sequence`
204
+ returns a sequence's blocks to the free list. :meth:`get_page_table` exposes
205
+ the per-sequence block mapping used to gather KV during attention.
206
+ """
207
+
208
+ def __init__(
209
+ self,
210
+ num_layers: int,
211
+ num_kv_heads: int,
212
+ head_dim: int,
213
+ num_blocks: int = 256,
214
+ block_size: int = 16,
215
+ dtype: torch.dtype = torch.float32,
216
+ device: torch.device | str = "cpu",
217
+ ) -> None:
218
+ self.num_layers = num_layers
219
+ self.num_kv_heads = num_kv_heads
220
+ self.head_dim = head_dim
221
+ self.num_blocks = num_blocks
222
+ self.block_size = block_size
223
+ self.dtype = dtype
224
+ self.device = torch.device(device)
225
+ self._page_table: Dict[int, List[int]] = {}
226
+ self._free: List[int] = list(range(num_blocks))
227
+ self._alloc()
228
+
229
+ def _alloc(self) -> None:
230
+ # Pool shape: [num_layers, num_blocks, num_kv_heads, block_size, head_dim].
231
+ shape = (
232
+ self.num_layers,
233
+ self.num_blocks,
234
+ self.num_kv_heads,
235
+ self.block_size,
236
+ self.head_dim,
237
+ )
238
+ self.key_pool = torch.zeros(shape, dtype=self.dtype, device=self.device)
239
+ self.value_pool = torch.zeros(shape, dtype=self.dtype, device=self.device)
240
+
241
+ @property
242
+ def num_free_blocks(self) -> int:
243
+ """Count of blocks currently available for allocation."""
244
+ return len(self._free)
245
+
246
+ @property
247
+ def num_used_blocks(self) -> int:
248
+ """Count of blocks currently assigned to some sequence."""
249
+ return self.num_blocks - len(self._free)
250
+
251
+ def allocate_block(self, seq_id: int = 0) -> int:
252
+ """Pop a free block, assign it to ``seq_id``, and return its index.
253
+
254
+ Raises:
255
+ RuntimeError: If the pool is exhausted.
256
+ """
257
+ if not self._free:
258
+ raise RuntimeError("PagedKVCache out of blocks; free a sequence first.")
259
+ block = self._free.pop(0)
260
+ self._page_table.setdefault(seq_id, []).append(block)
261
+ return block
262
+
263
+ def free_sequence(self, seq_id: int) -> List[int]:
264
+ """Return all of ``seq_id``'s blocks to the free list.
265
+
266
+ Returns the freed block indices (empty if the sequence was unknown).
267
+ """
268
+ blocks = self._page_table.pop(seq_id, [])
269
+ self._free.extend(blocks)
270
+ self._free.sort()
271
+ return blocks
272
+
273
+ def get_page_table(self) -> Dict[int, List[int]]:
274
+ """Return a copy of the per-sequence ``seq_id -> [block_idx]`` mapping."""
275
+ return {seq: list(blocks) for seq, blocks in self._page_table.items()}
276
+
277
+ def reset(self) -> None:
278
+ """Free every block and clear the page table."""
279
+ self._page_table.clear()
280
+ self._free = list(range(self.num_blocks))
281
+
282
+ def to(self, device: torch.device | str) -> "PagedKVCache":
283
+ """Move the KV pool to ``device`` in place and return self."""
284
+ self.device = torch.device(device)
285
+ self.key_pool = self.key_pool.to(self.device)
286
+ self.value_pool = self.value_pool.to(self.device)
287
+ return self
288
+
289
+ def state_dict(self) -> Dict[str, object]:
290
+ """Serialize pool contents + allocation state."""
291
+ return {
292
+ "class": type(self).__name__,
293
+ "num_layers": self.num_layers,
294
+ "num_kv_heads": self.num_kv_heads,
295
+ "head_dim": self.head_dim,
296
+ "num_blocks": self.num_blocks,
297
+ "block_size": self.block_size,
298
+ "page_table": self.get_page_table(),
299
+ "free": list(self._free),
300
+ "key_pool": self.key_pool.cpu(),
301
+ "value_pool": self.value_pool.cpu(),
302
+ }
303
+
304
+ def load_state_dict(self, state: Dict[str, object]) -> None:
305
+ """Restore pool contents + allocation state from :meth:`state_dict`."""
306
+ self._page_table = {
307
+ int(k): list(v) for k, v in state["page_table"].items() # type: ignore[union-attr]
308
+ }
309
+ self._free = list(state["free"]) # type: ignore[arg-type]
310
+ self.key_pool = state["key_pool"].to(self.device) # type: ignore[union-attr]
311
+ self.value_pool = state["value_pool"].to(self.device) # type: ignore[union-attr]
litert_model.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LiteRT-LM Model Wrapper — Python interface for .litertlm models
3
+
4
+ Wraps the LiteRT-LM C++ runtime via ctypes, providing a Pythonic
5
+ interface for inference, tokenization, and agent integration.
6
+
7
+ On actual devices, this is replaced by the Swift/Kotlin SDK.
8
+ This Python wrapper is used for:
9
+ - Desktop testing and debugging
10
+ - HF Space demos (via Python backend)
11
+ - CI validation of model bundles
12
+
13
+ Usage:
14
+ from hermes.litert_model import LiteRTModel
15
+
16
+ model = LiteRTModel("dist/hermes-mobile.litertlm")
17
+ model.load()
18
+ response = model.generate("Hello!", max_tokens=128)
19
+ print(response)
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import subprocess
26
+ import tempfile
27
+ from pathlib import Path
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+
32
+ class LiteRTModel:
33
+ """
34
+ Wrapper around a .litertlm model bundle.
35
+
36
+ Uses the `litert-lm` CLI tool for inference (since the Python C++
37
+ binding requires libvulkan which isn't available in all environments).
38
+
39
+ On iOS/Android, the native SDK replaces this class entirely.
40
+ """
41
+
42
+ def __init__(self, model_path: str, cli_path: str = "litert-lm"):
43
+ self.model_path = Path(model_path).resolve()
44
+ self.cli_path = cli_path
45
+ self.vocab_size = 32000
46
+ self.tokenizer = None
47
+ self._loaded = False
48
+ self._metadata: dict = {}
49
+
50
+ def load(self) -> bool:
51
+ """Validate the model file and extract metadata."""
52
+ if not self.model_path.exists():
53
+ log.error("Model not found: %s", self.model_path)
54
+ return False
55
+
56
+ with open(self.model_path, "rb") as f:
57
+ header = f.read(16)
58
+ if header[:8] != b"LITERTLM":
59
+ log.error("Invalid model file (bad magic): %s", self.model_path)
60
+ return False
61
+
62
+ self._loaded = True
63
+ mb = self.model_path.stat().st_size / 1024 / 1024
64
+ log.info("Model loaded: %s (%.1f MB)", self.model_path.name, mb)
65
+ return True
66
+
67
+ def generate(
68
+ self,
69
+ prompt: str,
70
+ max_tokens: int = 256,
71
+ temperature: float = 0.7,
72
+ top_k: int = 40,
73
+ ) -> str:
74
+ """Generate text using the litert-lm CLI."""
75
+ if not self._loaded:
76
+ return "Error: Model not loaded."
77
+
78
+ try:
79
+ result = subprocess.run(
80
+ [
81
+ self.cli_path,
82
+ "run",
83
+ str(self.model_path),
84
+ "--prompt",
85
+ prompt,
86
+ "--max_tokens",
87
+ str(max_tokens),
88
+ ],
89
+ capture_output=True,
90
+ text=True,
91
+ timeout=60,
92
+ )
93
+ if result.returncode == 0 and result.stdout.strip():
94
+ return result.stdout.strip()
95
+
96
+ if result.stderr:
97
+ log.warning("CLI stderr: %s", result.stderr[:200])
98
+
99
+ except FileNotFoundError:
100
+ log.warning("litert-lm CLI not available, using simulated response")
101
+ except subprocess.TimeoutExpired:
102
+ log.warning("Model inference timed out")
103
+ except Exception as exc:
104
+ log.warning("Model inference error: %s", exc)
105
+
106
+ return self._simulate_response(prompt)
107
+
108
+ def predict_next_token(self, context: list[int]) -> int:
109
+ """Predict the most likely next token (used by DSpark draft engine)."""
110
+ if not self._loaded:
111
+ return 0
112
+ try:
113
+ text = self._decode_tokens(context)
114
+ result = subprocess.run(
115
+ [
116
+ self.cli_path,
117
+ "run",
118
+ str(self.model_path),
119
+ "--prompt",
120
+ text[-200:],
121
+ "--max_tokens",
122
+ "1",
123
+ "--temperature",
124
+ "0.0",
125
+ ],
126
+ capture_output=True,
127
+ text=True,
128
+ timeout=30,
129
+ )
130
+ if result.returncode == 0 and result.stdout.strip():
131
+ return hash(result.stdout.strip()) % self.vocab_size
132
+ except Exception:
133
+ pass
134
+ return context[-1] if context else 0
135
+
136
+ @staticmethod
137
+ def _decode_tokens(token_ids: list[int]) -> str:
138
+ return "".join(chr(max(32, min(126, t % 128))) for t in token_ids[-50:])
139
+
140
+ def _simulate_response(self, prompt: str) -> str:
141
+ """Simulated response when CLI is unavailable (for demo/dev only)."""
142
+ prompt_lower = prompt.lower()
143
+ if "hello" in prompt_lower or "hi" in prompt_lower:
144
+ return "Hello! I'm Hermes Edge, running on-device. How can I help?"
145
+ if "tool" in prompt_lower or "function" in prompt_lower:
146
+ return (
147
+ "<think>The user is asking about tool calling. "
148
+ "I can use calculator, web search, memory, and timer tools.</think>\n\n"
149
+ "I support function calling. Available tools:\n"
150
+ "- calculator: evaluate math expressions\n"
151
+ "- web_search: search the web (requires network)\n"
152
+ "- memory: store and recall information\n"
153
+ "- timer: set timers"
154
+ )
155
+ if "reason" in prompt_lower or "deep" in prompt_lower:
156
+ return (
157
+ "<think>Applying DeepSeek-style reasoning. "
158
+ "Breaking down the question step by step. "
159
+ "Verifying each step.</think>\n\n"
160
+ "Based on my reasoning, here's my answer."
161
+ )
162
+ return (
163
+ f"<think>Processing query using {self.model_path.name} "
164
+ f"on LiteRT-LM runtime.</think>\n\n"
165
+ f"I received your message. I'm running fully offline as a {self.model_path.stem} model."
166
+ )
167
+
168
+ def get_metadata(self) -> dict:
169
+ """Get model metadata."""
170
+ return {
171
+ "path": str(self.model_path),
172
+ "size_mb": round(self.model_path.stat().st_size / 1024 / 1024, 1),
173
+ "loaded": self._loaded,
174
+ "format": "LITERTLM",
175
+ "vocab_size": self.vocab_size,
176
+ }
model.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reference PyTorch implementation of the Hermes mobile transformer.
2
+
3
+ This is the *training* model. It is intentionally written with plain,
4
+ conversion-friendly PyTorch ops (no custom CUDA kernels, no flash-attention
5
+ calls) so that the same ``state_dict`` can be loaded by the LiteRT builder in
6
+ ``scripts/convert_to_litertlm.py`` and traced by ``ai_edge_torch``.
7
+
8
+ Architecture: decoder-only, RMSNorm (pre-norm), rotary position embeddings,
9
+ grouped-query attention, and a SwiGLU feed-forward block — the same family as
10
+ Gemma / Llama, sized for on-device inference.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from typing import Optional, Tuple
17
+
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.nn.functional as F
21
+
22
+ from hermes.config import HermesConfig
23
+
24
+
25
+ class RMSNorm(nn.Module):
26
+ def __init__(self, dim: int, eps: float = 1e-6) -> None:
27
+ super().__init__()
28
+ self.eps = eps
29
+ self.weight = nn.Parameter(torch.ones(dim))
30
+
31
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
32
+ dtype = x.dtype
33
+ x = x.float()
34
+ x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
35
+ return (x.to(dtype)) * self.weight
36
+
37
+
38
+ def build_rope_cache(
39
+ seq_len: int, head_dim: int, theta: float, device: torch.device
40
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
41
+ """Precompute cos/sin tables for rotary position embeddings."""
42
+ inv_freq = 1.0 / (
43
+ theta ** (torch.arange(0, head_dim, 2, device=device).float() / head_dim)
44
+ )
45
+ t = torch.arange(seq_len, device=device).float()
46
+ freqs = torch.outer(t, inv_freq)
47
+ emb = torch.cat((freqs, freqs), dim=-1)
48
+ return emb.cos(), emb.sin()
49
+
50
+
51
+ def rotate_half(x: torch.Tensor) -> torch.Tensor:
52
+ x1, x2 = x.chunk(2, dim=-1)
53
+ return torch.cat((-x2, x1), dim=-1)
54
+
55
+
56
+ def apply_rope(
57
+ q: torch.Tensor, k: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor
58
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
59
+ # q, k: [B, H, T, D]; cos/sin: [T, D]
60
+ cos = cos.unsqueeze(0).unsqueeze(0)
61
+ sin = sin.unsqueeze(0).unsqueeze(0)
62
+ q_out = (q * cos) + (rotate_half(q) * sin)
63
+ k_out = (k * cos) + (rotate_half(k) * sin)
64
+ return q_out, k_out
65
+
66
+
67
+ class Attention(nn.Module):
68
+ """Grouped-query attention with an optional incremental KV-cache."""
69
+
70
+ def __init__(self, config: HermesConfig) -> None:
71
+ super().__init__()
72
+ self.num_heads = config.num_heads
73
+ self.num_kv_heads = config.num_kv_heads
74
+ self.head_dim = config.head_dim
75
+ self.num_query_groups = config.num_query_groups
76
+
77
+ self.q_proj = nn.Linear(
78
+ config.hidden_size, self.num_heads * self.head_dim, bias=False
79
+ )
80
+ self.k_proj = nn.Linear(
81
+ config.hidden_size, self.num_kv_heads * self.head_dim, bias=False
82
+ )
83
+ self.v_proj = nn.Linear(
84
+ config.hidden_size, self.num_kv_heads * self.head_dim, bias=False
85
+ )
86
+ self.o_proj = nn.Linear(
87
+ self.num_heads * self.head_dim, config.hidden_size, bias=False
88
+ )
89
+
90
+ def forward(
91
+ self,
92
+ x: torch.Tensor,
93
+ cos: torch.Tensor,
94
+ sin: torch.Tensor,
95
+ mask: Optional[torch.Tensor],
96
+ kv_cache: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
97
+ ) -> Tuple[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
98
+ b, t, _ = x.shape
99
+
100
+ q = self.q_proj(x).view(b, t, self.num_heads, self.head_dim).transpose(1, 2)
101
+ k = self.k_proj(x).view(b, t, self.num_kv_heads, self.head_dim).transpose(1, 2)
102
+ v = self.v_proj(x).view(b, t, self.num_kv_heads, self.head_dim).transpose(1, 2)
103
+
104
+ q, k = apply_rope(q, k, cos, sin)
105
+
106
+ if kv_cache is not None:
107
+ past_k, past_v = kv_cache
108
+ k = torch.cat([past_k, k], dim=2)
109
+ v = torch.cat([past_v, v], dim=2)
110
+ new_cache = (k, v)
111
+
112
+ # Expand KV heads to match query heads (GQA).
113
+ if self.num_query_groups > 1:
114
+ k = k.repeat_interleave(self.num_query_groups, dim=1)
115
+ v = v.repeat_interleave(self.num_query_groups, dim=1)
116
+
117
+ attn = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim)
118
+ if mask is not None:
119
+ attn = attn + mask
120
+ attn = F.softmax(attn, dim=-1, dtype=torch.float32).to(q.dtype)
121
+ out = torch.matmul(attn, v)
122
+
123
+ out = out.transpose(1, 2).contiguous().view(b, t, -1)
124
+ return self.o_proj(out), new_cache
125
+
126
+
127
+ class FeedForward(nn.Module):
128
+ """SwiGLU MLP: down(silu(gate(x)) * up(x))."""
129
+
130
+ def __init__(self, config: HermesConfig) -> None:
131
+ super().__init__()
132
+ self.gate_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
133
+ self.up_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
134
+ self.down_proj = nn.Linear(config.intermediate_size, config.hidden_size, bias=False)
135
+
136
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
137
+ return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
138
+
139
+
140
+ class DecoderBlock(nn.Module):
141
+ def __init__(self, config: HermesConfig) -> None:
142
+ super().__init__()
143
+ self.input_layernorm = RMSNorm(config.hidden_size, config.rms_norm_eps)
144
+ self.self_attn = Attention(config)
145
+ self.post_attention_layernorm = RMSNorm(config.hidden_size, config.rms_norm_eps)
146
+ self.mlp = FeedForward(config)
147
+
148
+ def forward(
149
+ self,
150
+ x: torch.Tensor,
151
+ cos: torch.Tensor,
152
+ sin: torch.Tensor,
153
+ mask: Optional[torch.Tensor],
154
+ kv_cache: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
155
+ ) -> Tuple[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
156
+ h, new_cache = self.self_attn(
157
+ self.input_layernorm(x), cos, sin, mask, kv_cache
158
+ )
159
+ x = x + h
160
+ x = x + self.mlp(self.post_attention_layernorm(x))
161
+ return x, new_cache
162
+
163
+
164
+ class HermesForCausalLM(nn.Module):
165
+ """Full Hermes decoder-only language model with a causal LM head."""
166
+
167
+ def __init__(self, config: HermesConfig) -> None:
168
+ super().__init__()
169
+ self.config = config
170
+ self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
171
+ self.layers = nn.ModuleList(
172
+ [DecoderBlock(config) for _ in range(config.num_layers)]
173
+ )
174
+ self.norm = RMSNorm(config.hidden_size, config.rms_norm_eps)
175
+ self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
176
+ if config.tie_embeddings:
177
+ self.lm_head.weight = self.embed_tokens.weight
178
+
179
+ cos, sin = build_rope_cache(
180
+ config.max_seq_len, config.head_dim, config.rope_theta, torch.device("cpu")
181
+ )
182
+ self.register_buffer("rope_cos", cos, persistent=False)
183
+ self.register_buffer("rope_sin", sin, persistent=False)
184
+
185
+ def forward(
186
+ self,
187
+ input_ids: torch.Tensor,
188
+ labels: Optional[torch.Tensor] = None,
189
+ start_pos: int = 0,
190
+ ) -> dict:
191
+ b, t = input_ids.shape
192
+ x = self.embed_tokens(input_ids)
193
+
194
+ cos = self.rope_cos[start_pos : start_pos + t].to(x.device)
195
+ sin = self.rope_sin[start_pos : start_pos + t].to(x.device)
196
+
197
+ mask = torch.full((t, t), float("-inf"), device=x.device)
198
+ mask = torch.triu(mask, diagonal=1)
199
+
200
+ for layer in self.layers:
201
+ x, _ = layer(x, cos, sin, mask)
202
+
203
+ x = self.norm(x)
204
+ logits = self.lm_head(x)
205
+
206
+ loss = None
207
+ if labels is not None:
208
+ shift_logits = logits[:, :-1, :].contiguous()
209
+ shift_labels = labels[:, 1:].contiguous()
210
+ loss = F.cross_entropy(
211
+ shift_logits.view(-1, shift_logits.size(-1)),
212
+ shift_labels.view(-1),
213
+ ignore_index=self.config.pad_token_id,
214
+ )
215
+ return {"logits": logits, "loss": loss}
216
+
217
+ @torch.no_grad()
218
+ def generate(
219
+ self,
220
+ input_ids: torch.Tensor,
221
+ max_new_tokens: int = 64,
222
+ temperature: float = 0.8,
223
+ top_k: int = 50,
224
+ eos_token_id: Optional[int] = None,
225
+ ) -> torch.Tensor:
226
+ """Minimal greedy/sampling loop — sanity check for trained weights."""
227
+ self.eval()
228
+ eos_token_id = eos_token_id if eos_token_id is not None else self.config.eos_token_id
229
+ for _ in range(max_new_tokens):
230
+ ids = input_ids[:, -self.config.max_seq_len :]
231
+ logits = self.forward(ids)["logits"][:, -1, :]
232
+ if temperature > 0:
233
+ logits = logits / temperature
234
+ if top_k:
235
+ v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
236
+ logits[logits < v[:, [-1]]] = float("-inf")
237
+ probs = F.softmax(logits, dim=-1)
238
+ next_id = torch.multinomial(probs, num_samples=1)
239
+ else:
240
+ next_id = logits.argmax(dim=-1, keepdim=True)
241
+ input_ids = torch.cat([input_ids, next_id], dim=1)
242
+ if (next_id == eos_token_id).all():
243
+ break
244
+ return input_ids
245
+
246
+
247
+ def build_model(config: HermesConfig) -> HermesForCausalLM:
248
+ return HermesForCausalLM(config)
quantization.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Post-training quantization (PTQ) analysis + fake-quant utilities.
2
+
3
+ These helpers are deliberately **standalone** — they have no ``ai_edge_torch``
4
+ dependency. They serve two purposes:
5
+
6
+ 1. **Pre-conversion analysis.** :func:`collect_calibration_stats` and
7
+ :func:`quantization_error_report` let you measure activation ranges and the
8
+ weight/perplexity error a given bit-width would introduce, *before* you spend
9
+ minutes lowering the model through the LiteRT stack. Use them to sanity-check
10
+ that INT4 is viable for a checkpoint, or to pick which layers are sensitive.
11
+
12
+ 2. **Training-time fake quantization.** :func:`apply_weight_only_int4` and
13
+ :func:`apply_weight_only_int8` replace each ``nn.Linear`` weight with its
14
+ quantized-then-dequantized value using a straight-through estimator (STE) so
15
+ gradients still flow. This is the quantization-aware-training (QAT) path: fine
16
+ tune with fake-quant on to recover accuracy the real INT4 graph would lose.
17
+
18
+ Relationship to ``scripts/convert_to_litertlm.py``
19
+ --------------------------------------------------
20
+ The *real* mobile INT4 graph is produced by ``convert_to_litertlm.py`` via
21
+ ``ai_edge_torch``'s ``full_int4_dynamic_recipe`` — that is what actually ships in
22
+ the ``.litertlm`` bundle. The functions here do **not** replace that conversion:
23
+ they approximate the same symmetric per-group INT4 scheme in pure PyTorch so you
24
+ can (a) estimate the error offline and (b) QAT-finetune to minimize it. Numbers
25
+ from here are guidance; the converter's output is ground truth.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from typing import Dict, Iterable, Optional
32
+
33
+ import torch
34
+ import torch.nn as nn
35
+
36
+
37
+ # --------------------------------------------------------------------------- #
38
+ # Symmetric per-group quantization core
39
+ # --------------------------------------------------------------------------- #
40
+ def _quant_levels(bits: int) -> tuple[int, int]:
41
+ """Return ``(qmin, qmax)`` for a signed ``bits``-bit integer."""
42
+ qmax = 2 ** (bits - 1) - 1
43
+ qmin = -(2 ** (bits - 1))
44
+ return qmin, qmax
45
+
46
+
47
+ def fake_quantize_per_group(
48
+ weight: torch.Tensor, bits: int, group_size: int
49
+ ) -> torch.Tensor:
50
+ """Symmetric per-group fake quantization of a 2-D weight matrix.
51
+
52
+ The weight ``[out_features, in_features]`` is split along ``in_features`` into
53
+ groups of ``group_size``; each group gets its own scale ``max(|w|) / qmax``.
54
+ The result is quantized to the integer grid and dequantized back to float, so
55
+ the returned tensor has the same dtype/shape but only takes representable
56
+ values. Used by both the analysis and STE paths.
57
+ """
58
+ qmin, qmax = _quant_levels(bits)
59
+ out_features, in_features = weight.shape
60
+ gs = group_size if group_size > 0 else in_features
61
+ pad = (gs - in_features % gs) % gs
62
+ w = weight
63
+ if pad:
64
+ w = torch.nn.functional.pad(w, (0, pad))
65
+ w = w.reshape(out_features, -1, gs)
66
+
67
+ max_abs = w.abs().amax(dim=-1, keepdim=True)
68
+ scale = (max_abs / qmax).clamp(min=1e-8)
69
+ q = torch.clamp(torch.round(w / scale), qmin, qmax)
70
+ deq = (q * scale).reshape(out_features, -1)
71
+ if pad:
72
+ deq = deq[:, :in_features]
73
+ return deq.to(weight.dtype)
74
+
75
+
76
+ class _STEFakeQuant(torch.autograd.Function):
77
+ """Straight-through estimator: quantize on forward, identity on backward."""
78
+
79
+ @staticmethod
80
+ def forward(ctx, weight: torch.Tensor, bits: int, group_size: int) -> torch.Tensor: # type: ignore[override]
81
+ return fake_quantize_per_group(weight, bits, group_size)
82
+
83
+ @staticmethod
84
+ def backward(ctx, grad_output: torch.Tensor): # type: ignore[override]
85
+ # Identity gradient w.r.t. the weight; None for the int hyper-params.
86
+ return grad_output, None, None
87
+
88
+
89
+ def _apply_weight_only(model: nn.Module, bits: int, group_size: int) -> nn.Module:
90
+ """In-place STE fake-quant of every ``nn.Linear`` weight in ``model``."""
91
+ for module in model.modules():
92
+ if isinstance(module, nn.Linear):
93
+ with torch.no_grad():
94
+ quantized = _STEFakeQuant.apply(module.weight, bits, group_size)
95
+ module.weight.copy_(quantized)
96
+ return model
97
+
98
+
99
+ def apply_weight_only_int4(model: nn.Module, group_size: int = 128) -> nn.Module:
100
+ """Fake-quantize all ``nn.Linear`` weights to symmetric per-group INT4.
101
+
102
+ Each weight is mapped onto the signed 4-bit grid ``[-8, 7]`` (per group of
103
+ ``group_size`` input channels) and dequantized in place. Uses a
104
+ straight-through estimator so the operation is differentiable for QAT.
105
+
106
+ This mirrors the per-group INT4 scheme that
107
+ ``ai_edge_torch``'s ``full_int4_dynamic_recipe`` applies during the real
108
+ conversion in ``scripts/convert_to_litertlm.py`` — call this to QAT-finetune
109
+ or to estimate INT4 error offline; the converter produces the shipped graph.
110
+
111
+ Returns the same model (mutated in place).
112
+ """
113
+ return _apply_weight_only(model, bits=4, group_size=group_size)
114
+
115
+
116
+ def apply_weight_only_int8(model: nn.Module, group_size: int = 0) -> nn.Module:
117
+ """Fake-quantize all ``nn.Linear`` weights to symmetric INT8 (``[-128, 127]``).
118
+
119
+ Per-channel by default (``group_size=0`` → one scale per output row). Same STE
120
+ semantics as :func:`apply_weight_only_int4`; useful as the higher-quality
121
+ fallback recipe when INT4 degrades a sensitive checkpoint too much.
122
+
123
+ Returns the same model (mutated in place).
124
+ """
125
+ return _apply_weight_only(model, bits=8, group_size=group_size)
126
+
127
+
128
+ # --------------------------------------------------------------------------- #
129
+ # Calibration + error analysis
130
+ # --------------------------------------------------------------------------- #
131
+ @torch.no_grad()
132
+ def collect_calibration_stats(
133
+ model: nn.Module,
134
+ dataloader: Iterable,
135
+ num_batches: int = 64,
136
+ ) -> Dict[str, Dict[str, float]]:
137
+ """Run forward passes and collect per-layer activation statistics.
138
+
139
+ Forward hooks on every ``nn.Linear`` record the running min/max and a coarse
140
+ 99th-percentile estimate of the *output* activations across up to
141
+ ``num_batches`` batches. These ranges are what an activation-quantization
142
+ scheme (or a converter calibration pass) would use to pick scales.
143
+
144
+ Args:
145
+ model: The model to profile (set to eval).
146
+ dataloader: Yields either tensors of ``input_ids`` or ``(inputs, _)``
147
+ tuples / dicts with an ``input_ids`` key.
148
+ num_batches: Max number of batches to run.
149
+
150
+ Returns:
151
+ ``{layer_name: {"min", "max", "abs_max", "p99", "mean", "num_samples"}}``.
152
+ """
153
+ model.eval()
154
+ stats: Dict[str, Dict[str, float]] = {}
155
+ handles = []
156
+
157
+ def make_hook(name: str):
158
+ def hook(_module, _inp, out):
159
+ t = out.detach()
160
+ if not torch.is_floating_point(t):
161
+ return
162
+ flat = t.float().reshape(-1)
163
+ entry = stats.setdefault(
164
+ name,
165
+ {
166
+ "min": math.inf,
167
+ "max": -math.inf,
168
+ "abs_max": 0.0,
169
+ "p99": 0.0,
170
+ "mean": 0.0,
171
+ "num_samples": 0.0,
172
+ },
173
+ )
174
+ entry["min"] = min(entry["min"], float(flat.min()))
175
+ entry["max"] = max(entry["max"], float(flat.max()))
176
+ entry["abs_max"] = max(entry["abs_max"], float(flat.abs().max()))
177
+ # Running mean + percentile (cheap quantile on a subsample).
178
+ n_prev = entry["num_samples"]
179
+ n_new = flat.numel()
180
+ entry["mean"] = (
181
+ entry["mean"] * n_prev + float(flat.sum())
182
+ ) / max(n_prev + n_new, 1)
183
+ sample = flat if flat.numel() <= 16384 else flat[torch.randint(
184
+ 0, flat.numel(), (16384,), device=flat.device)]
185
+ entry["p99"] = max(entry["p99"], float(torch.quantile(sample.abs(), 0.99)))
186
+ entry["num_samples"] = n_prev + n_new
187
+
188
+ return hook
189
+
190
+ for name, module in model.named_modules():
191
+ if isinstance(module, nn.Linear):
192
+ handles.append(module.register_forward_hook(make_hook(name)))
193
+
194
+ try:
195
+ for i, batch in enumerate(dataloader):
196
+ if i >= num_batches:
197
+ break
198
+ input_ids = _extract_input_ids(batch)
199
+ model(input_ids)
200
+ finally:
201
+ for h in handles:
202
+ h.remove()
203
+
204
+ return stats
205
+
206
+
207
+ def _extract_input_ids(batch) -> torch.Tensor:
208
+ """Pull an ``input_ids`` tensor out of common dataloader batch shapes."""
209
+ if isinstance(batch, torch.Tensor):
210
+ return batch
211
+ if isinstance(batch, dict):
212
+ return batch["input_ids"]
213
+ if isinstance(batch, (tuple, list)):
214
+ return batch[0]
215
+ raise TypeError(f"Cannot extract input_ids from batch of type {type(batch)}.")
216
+
217
+
218
+ @torch.no_grad()
219
+ def _perplexity(model: nn.Module, dataloader: Iterable, num_batches: int) -> float:
220
+ """Mean token-level perplexity over ``num_batches`` (labels == inputs)."""
221
+ model.eval()
222
+ total_loss = 0.0
223
+ count = 0
224
+ for i, batch in enumerate(dataloader):
225
+ if i >= num_batches:
226
+ break
227
+ input_ids = _extract_input_ids(batch)
228
+ out = model(input_ids, labels=input_ids)
229
+ loss = out["loss"] if isinstance(out, dict) else out
230
+ if loss is None:
231
+ continue
232
+ total_loss += float(loss)
233
+ count += 1
234
+ if count == 0:
235
+ return float("nan")
236
+ return math.exp(total_loss / count)
237
+
238
+
239
+ @torch.no_grad()
240
+ def quantization_error_report(
241
+ original_model: nn.Module,
242
+ quantized_model: nn.Module,
243
+ dataloader: Iterable,
244
+ num_batches: int = 8,
245
+ ) -> Dict[str, object]:
246
+ """Compare a model against its quantized copy.
247
+
248
+ Computes, per ``nn.Linear`` layer, the relative L2 error between the original
249
+ and quantized weights, and the model-level perplexity delta on ``dataloader``.
250
+
251
+ Returns:
252
+ ``{"per_layer_l2": {name: rel_l2}, "max_layer_l2": float,
253
+ "perplexity_original": float, "perplexity_quantized": float,
254
+ "perplexity_delta": float}``.
255
+ """
256
+ orig_linears = dict(_named_linears(original_model))
257
+ quant_linears = dict(_named_linears(quantized_model))
258
+
259
+ per_layer: Dict[str, float] = {}
260
+ for name, orig in orig_linears.items():
261
+ if name not in quant_linears:
262
+ continue
263
+ diff = (orig.weight - quant_linears[name].weight).float()
264
+ denom = orig.weight.float().norm().clamp(min=1e-8)
265
+ per_layer[name] = float(diff.norm() / denom)
266
+
267
+ ppl_orig = _perplexity(original_model, dataloader, num_batches)
268
+ ppl_quant = _perplexity(quantized_model, dataloader, num_batches)
269
+
270
+ return {
271
+ "per_layer_l2": per_layer,
272
+ "max_layer_l2": max(per_layer.values()) if per_layer else 0.0,
273
+ "perplexity_original": ppl_orig,
274
+ "perplexity_quantized": ppl_quant,
275
+ "perplexity_delta": ppl_quant - ppl_orig,
276
+ }
277
+
278
+
279
+ def _named_linears(model: nn.Module):
280
+ """Yield ``(name, module)`` for every ``nn.Linear`` in ``model``."""
281
+ for name, module in model.named_modules():
282
+ if isinstance(module, nn.Linear):
283
+ yield name, module
284
+
285
+
286
+ if __name__ == "__main__": # pragma: no cover - manual smoke check
287
+ import copy
288
+
289
+ from hermes.config import HermesConfig
290
+ from hermes.model import build_model
291
+
292
+ cfg = HermesConfig(
293
+ vocab_size=128, hidden_size=64, intermediate_size=128, num_layers=2,
294
+ num_heads=4, num_kv_heads=2, head_dim=16, max_seq_len=32,
295
+ )
296
+ fp_model = build_model(cfg)
297
+ q_model = apply_weight_only_int4(copy.deepcopy(fp_model))
298
+ data = [torch.randint(0, cfg.vocab_size, (1, 8)) for _ in range(4)]
299
+ report = quantization_error_report(fp_model, q_model, data, num_batches=4)
300
+ print("max layer L2 error:", round(report["max_layer_l2"], 4))
301
+ print("perplexity delta:", round(report["perplexity_delta"], 4))