eigencore 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. eigencore/__init__.py +82 -0
  2. eigencore/agent/__init__.py +45 -0
  3. eigencore/agent/executor.py +297 -0
  4. eigencore/agent/grammar.py +121 -0
  5. eigencore/agent/policy.py +262 -0
  6. eigencore/agent/react.py +261 -0
  7. eigencore/agent/tool.py +149 -0
  8. eigencore/analysis/__init__.py +3 -0
  9. eigencore/analysis/sparsity.py +184 -0
  10. eigencore/benchmark/__init__.py +33 -0
  11. eigencore/benchmark/harness.py +342 -0
  12. eigencore/cli/__init__.py +0 -0
  13. eigencore/cli/main.py +856 -0
  14. eigencore/context/__init__.py +3 -0
  15. eigencore/context/manager.py +318 -0
  16. eigencore/engine/__init__.py +3 -0
  17. eigencore/engine/inference.py +378 -0
  18. eigencore/engine/layer_skip.py +268 -0
  19. eigencore/engine/prompt_lookup.py +39 -0
  20. eigencore/engine/pruning.py +116 -0
  21. eigencore/engine/sparse_inference.py +218 -0
  22. eigencore/engine/sparsity_experiment.py +109 -0
  23. eigencore/engine/speculative.py +238 -0
  24. eigencore/fit/__init__.py +34 -0
  25. eigencore/fit/estimator.py +310 -0
  26. eigencore/fit/gguf_meta.py +130 -0
  27. eigencore/forge.py +216 -0
  28. eigencore/hal/__init__.py +3 -0
  29. eigencore/hal/_cpuid_win.py +119 -0
  30. eigencore/hal/profiler.py +349 -0
  31. eigencore/kernels/__init__.py +28 -0
  32. eigencore/kernels/q4k.py +76 -0
  33. eigencore/kernels/reference.py +168 -0
  34. eigencore/models/__init__.py +3 -0
  35. eigencore/models/registry.py +193 -0
  36. eigencore/router/__init__.py +3 -0
  37. eigencore/router/classifier.py +176 -0
  38. eigencore/training/__init__.py +3 -0
  39. eigencore/training/convergence.py +112 -0
  40. eigencore/training/trainer.py +401 -0
  41. eigencore/tui/__init__.py +5 -0
  42. eigencore/tui/chat.py +265 -0
  43. eigencore/tui/onboarding.py +134 -0
  44. eigencore/tui/theme.py +61 -0
  45. eigencore-0.4.0.dist-info/METADATA +144 -0
  46. eigencore-0.4.0.dist-info/RECORD +49 -0
  47. eigencore-0.4.0.dist-info/WHEEL +4 -0
  48. eigencore-0.4.0.dist-info/entry_points.txt +2 -0
  49. eigencore-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ from eigencore.context.manager import ContextManager, ContextWindow
2
+
3
+ __all__ = ["ContextManager", "ContextWindow"]
@@ -0,0 +1,318 @@
1
+ """
2
+ Attention-aware context manager — compresses conversation context while
3
+ preserving the tokens that matter most for generation quality.
4
+
5
+ Key insight (Gap 2): The first 3-4 tokens in any sequence act as "attention
6
+ sinks" — they absorb disproportionate attention weight regardless of content.
7
+ Deleting them destabilizes generation. Every other tool just truncates from
8
+ the beginning. This manager preserves sink tokens, compresses middle context,
9
+ and keeps recent context intact.
10
+
11
+ Result: stable generation quality at ~50% the context length, letting a 16GB
12
+ RAM machine maintain conversations that would normally need 32GB of context.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import sqlite3
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+
25
+ @dataclass
26
+ class Message:
27
+ role: str # "system", "user", "assistant"
28
+ content: str
29
+ timestamp: float = field(default_factory=time.time)
30
+ token_estimate: int = 0
31
+ compressed: bool = False
32
+ message_id: str = ""
33
+
34
+ def __post_init__(self):
35
+ if not self.token_estimate:
36
+ self.token_estimate = self._estimate_tokens(self.content)
37
+ if not self.message_id:
38
+ h = hashlib.md5(f"{self.timestamp}{self.content[:50]}".encode()).hexdigest()[:12]
39
+ self.message_id = h
40
+
41
+ @staticmethod
42
+ def _estimate_tokens(text: str) -> int:
43
+ """Rough token estimate: ~4 chars per token for English."""
44
+ return max(len(text) // 4, 1)
45
+
46
+
47
+ @dataclass
48
+ class ContextWindow:
49
+ """Represents the current context state with compression metadata."""
50
+
51
+ messages: list[Message]
52
+ total_tokens: int
53
+ max_tokens: int
54
+ compression_ratio: float
55
+ sink_tokens_preserved: int
56
+ messages_compressed: int
57
+
58
+
59
+ class ContextManager:
60
+ """
61
+ Manages conversation context with attention-aware compression.
62
+
63
+ Compression strategy (from most to least aggressive):
64
+ 1. PRESERVE: Always keep the first system message + first user message
65
+ (these occupy the "attention sink" positions 0-4 in the token stream)
66
+ 2. PRESERVE: Always keep the most recent N messages (active working memory)
67
+ 3. COMPRESS: Middle messages get summarized into shorter versions
68
+ 4. DROP: If still over budget after compression, drop oldest middle messages
69
+
70
+ Also provides persistent session storage via SQLite.
71
+ """
72
+
73
+ SINK_MESSAGES = 2 # first system + first user message to always preserve
74
+ RECENT_MESSAGES = 6 # recent messages to always keep uncompressed
75
+ COMPRESSION_TARGET = 0.4 # compress middle messages to 40% of original size
76
+
77
+ def __init__(
78
+ self,
79
+ max_context_tokens: int = 2048,
80
+ db_path: Optional[Path] = None,
81
+ ):
82
+ self.max_context_tokens = max_context_tokens
83
+ self.messages: list[Message] = []
84
+ self._db: Optional[sqlite3.Connection] = None
85
+ if db_path:
86
+ self._init_db(db_path)
87
+
88
+ def _init_db(self, db_path: Path) -> None:
89
+ db_path.parent.mkdir(parents=True, exist_ok=True)
90
+ self._db = sqlite3.connect(str(db_path))
91
+ self._db.execute("""
92
+ CREATE TABLE IF NOT EXISTS messages (
93
+ id TEXT PRIMARY KEY,
94
+ session_id TEXT NOT NULL,
95
+ role TEXT NOT NULL,
96
+ content TEXT NOT NULL,
97
+ timestamp REAL NOT NULL,
98
+ token_estimate INTEGER NOT NULL,
99
+ compressed INTEGER DEFAULT 0
100
+ )
101
+ """)
102
+ self._db.execute("""
103
+ CREATE INDEX IF NOT EXISTS idx_session
104
+ ON messages(session_id, timestamp)
105
+ """)
106
+ self._db.commit()
107
+
108
+ def add(self, role: str, content: str) -> Message:
109
+ msg = Message(role=role, content=content)
110
+ self.messages.append(msg)
111
+ return msg
112
+
113
+ def get_context(self) -> ContextWindow:
114
+ """
115
+ Build the context window with attention-aware compression.
116
+ Returns messages ready to send to the model, respecting token budget.
117
+ """
118
+ if not self.messages:
119
+ return ContextWindow(
120
+ messages=[],
121
+ total_tokens=0,
122
+ max_tokens=self.max_context_tokens,
123
+ compression_ratio=1.0,
124
+ sink_tokens_preserved=0,
125
+ messages_compressed=0,
126
+ )
127
+
128
+ total = sum(m.token_estimate for m in self.messages)
129
+
130
+ # if everything fits, no compression needed
131
+ if total <= self.max_context_tokens:
132
+ return ContextWindow(
133
+ messages=list(self.messages),
134
+ total_tokens=total,
135
+ max_tokens=self.max_context_tokens,
136
+ compression_ratio=1.0,
137
+ sink_tokens_preserved=0,
138
+ messages_compressed=0,
139
+ )
140
+
141
+ # split into three zones
142
+ sink = self.messages[: self.SINK_MESSAGES]
143
+ n_recent = min(self.RECENT_MESSAGES, len(self.messages) - self.SINK_MESSAGES)
144
+ recent = self.messages[-n_recent:] if n_recent > 0 else []
145
+ middle_end = len(self.messages) - n_recent if n_recent > 0 else len(self.messages)
146
+ middle = self.messages[self.SINK_MESSAGES : middle_end]
147
+
148
+ sink_tokens = sum(m.token_estimate for m in sink)
149
+ recent_tokens = sum(m.token_estimate for m in recent)
150
+
151
+ # if sink + recent alone exceed budget, trim recent count
152
+ while sink_tokens + recent_tokens > self.max_context_tokens and len(recent) > 1:
153
+ dropped = recent.pop(0)
154
+ recent_tokens -= dropped.token_estimate
155
+
156
+ budget_for_middle = max(self.max_context_tokens - sink_tokens - recent_tokens, 0)
157
+
158
+ compressed_middle, middle_compressed_count = self._compress_middle(
159
+ middle, budget_for_middle
160
+ )
161
+
162
+ result = sink + compressed_middle + recent
163
+ result_tokens = sum(m.token_estimate for m in result)
164
+
165
+ # final safety: if still over, drop middle entirely
166
+ if result_tokens > self.max_context_tokens and compressed_middle:
167
+ result = sink + recent
168
+ result_tokens = sum(m.token_estimate for m in result)
169
+ middle_compressed_count = len(middle)
170
+
171
+ return ContextWindow(
172
+ messages=result,
173
+ total_tokens=result_tokens,
174
+ max_tokens=self.max_context_tokens,
175
+ compression_ratio=result_tokens / total if total > 0 else 1.0,
176
+ sink_tokens_preserved=sink_tokens,
177
+ messages_compressed=middle_compressed_count,
178
+ )
179
+
180
+ def _compress_middle(
181
+ self, messages: list[Message], token_budget: int
182
+ ) -> tuple[list[Message], int]:
183
+ """
184
+ Compress middle messages to fit within token budget.
185
+ Strategy: truncate each message proportionally, keeping the first
186
+ and last sentences (they tend to carry the most information).
187
+ """
188
+ if not messages:
189
+ return [], 0
190
+
191
+ if token_budget <= 0:
192
+ # no room for middle context — create a single summary placeholder
193
+ combined = " | ".join(f"[{m.role}]: {m.content[:30]}..." for m in messages[:5])
194
+ summary = Message(
195
+ role="system",
196
+ content=f"[Earlier context compressed: {len(messages)} messages] {combined}",
197
+ compressed=True,
198
+ )
199
+ return [summary], len(messages)
200
+
201
+ total_middle_tokens = sum(m.token_estimate for m in messages)
202
+
203
+ if total_middle_tokens <= token_budget:
204
+ return list(messages), 0
205
+
206
+ # compress each message proportionally
207
+ ratio = token_budget / total_middle_tokens
208
+ compressed = []
209
+ count = 0
210
+
211
+ for msg in messages:
212
+ target_chars = int(len(msg.content) * ratio)
213
+ if target_chars < 50:
214
+ # too small to be useful — skip but note it
215
+ continue
216
+
217
+ truncated = self._smart_truncate(msg.content, target_chars)
218
+ compressed.append(
219
+ Message(
220
+ role=msg.role,
221
+ content=truncated,
222
+ timestamp=msg.timestamp,
223
+ compressed=True,
224
+ )
225
+ )
226
+ count += 1
227
+
228
+ # if we still exceed budget after proportional compression, drop oldest
229
+ while sum(m.token_estimate for m in compressed) > token_budget and len(compressed) > 1:
230
+ compressed.pop(0)
231
+ count += 1
232
+
233
+ return compressed, count
234
+
235
+ @staticmethod
236
+ def _smart_truncate(text: str, target_chars: int) -> str:
237
+ """
238
+ Truncate text keeping the first and last sentences.
239
+ These carry the most semantic weight (topic introduction + conclusion).
240
+ """
241
+ if len(text) <= target_chars:
242
+ return text
243
+
244
+ sentences = text.replace("! ", ".\n").replace("? ", ".\n").split(". ")
245
+ if len(sentences) <= 2:
246
+ return text[:target_chars] + "..."
247
+
248
+ half = target_chars // 2
249
+ first_part = text[:half]
250
+ last_part = text[-half:]
251
+
252
+ # find clean sentence boundary in first part
253
+ last_period = first_part.rfind(". ")
254
+ if last_period > half // 2:
255
+ first_part = first_part[: last_period + 1]
256
+
257
+ # find clean sentence boundary in last part
258
+ first_period = last_part.find(". ")
259
+ if first_period > 0 and first_period < len(last_part) // 2:
260
+ last_part = last_part[first_period + 2 :]
261
+
262
+ return f"{first_part} [...] {last_part}"
263
+
264
+ def save_session(self, session_id: str) -> None:
265
+ """Persist current messages to SQLite."""
266
+ if not self._db:
267
+ return
268
+
269
+ for msg in self.messages:
270
+ self._db.execute(
271
+ "INSERT OR REPLACE INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)",
272
+ (
273
+ msg.message_id,
274
+ session_id,
275
+ msg.role,
276
+ msg.content,
277
+ msg.timestamp,
278
+ msg.token_estimate,
279
+ int(msg.compressed),
280
+ ),
281
+ )
282
+ self._db.commit()
283
+
284
+ def load_session(self, session_id: str) -> int:
285
+ """Load messages from a previous session. Returns count loaded."""
286
+ if not self._db:
287
+ return 0
288
+
289
+ cursor = self._db.execute(
290
+ "SELECT role, content, timestamp, token_estimate, compressed, id "
291
+ "FROM messages WHERE session_id = ? ORDER BY timestamp",
292
+ (session_id,),
293
+ )
294
+ self.messages.clear()
295
+ count = 0
296
+ for row in cursor:
297
+ msg = Message(
298
+ role=row[0],
299
+ content=row[1],
300
+ timestamp=row[2],
301
+ token_estimate=row[3],
302
+ compressed=bool(row[4]),
303
+ message_id=row[5],
304
+ )
305
+ self.messages.append(msg)
306
+ count += 1
307
+ return count
308
+
309
+ def clear(self) -> None:
310
+ self.messages.clear()
311
+
312
+ @property
313
+ def token_count(self) -> int:
314
+ return sum(m.token_estimate for m in self.messages)
315
+
316
+ @property
317
+ def message_count(self) -> int:
318
+ return len(self.messages)
@@ -0,0 +1,3 @@
1
+ from eigencore.engine.inference import InferenceEngine
2
+
3
+ __all__ = ["InferenceEngine"]
@@ -0,0 +1,378 @@
1
+ """
2
+ Inference engine — wraps llama.cpp with hardware-aware configuration.
3
+ Handles model loading, generation, and resource management.
4
+
5
+ Phase 2 integration: layer skipping, sparsity analysis, and speculative
6
+ decoding hooks are initialized on model load and report stats via
7
+ the `optimization_stats` property.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from collections.abc import Iterator
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from eigencore.engine.layer_skip import LayerSkipScheduler, SkipStrategy
18
+ from eigencore.engine.prompt_lookup import longest_prompt_continuation
19
+ from eigencore.engine.sparse_inference import SparsityCache, SparsityPredictor
20
+ from eigencore.engine.speculative import SpeculativeDecoder
21
+ from eigencore.hal.profiler import HardwareProfile, InstructionSet
22
+ from eigencore.models.registry import ModelSpec
23
+
24
+ # llama-cpp-python expects an int KV type; map our string names.
25
+ _KV_TYPE_MAP = {"fp16": 0, "q8_0": 1}
26
+
27
+
28
+ @dataclass
29
+ class GenerationConfig:
30
+ max_tokens: int = 512
31
+ temperature: float = 0.7
32
+ top_p: float = 0.9
33
+ top_k: int = 40
34
+ repeat_penalty: float = 1.1
35
+ stop: list[str] = field(default_factory=list)
36
+ stream: bool = True
37
+ enable_layer_skip: bool = True
38
+ enable_sparse_inference: bool = True
39
+ complexity: float = 0.5
40
+ # Prompt-lookup decoding: copy prompt-verbatim spans instead of decoding
41
+ # them. Default OFF; enable only after owner benchmarks (FINAL_REPORT §9 item 9).
42
+ prompt_lookup: bool = False
43
+
44
+
45
+ @dataclass
46
+ class GenerationResult:
47
+ text: str
48
+ tokens_generated: int
49
+ time_seconds: float
50
+ tokens_per_second: float
51
+ prompt_tokens: int
52
+ optimization_stats: dict | None = None
53
+ # Optimizations actually applied to the backend call. Empty today:
54
+ # layer-skip/sparsity plans and the speculative decoder are computed and
55
+ # reported but NOT wired into create_chat_completion yet.
56
+ applied_optimizations: list[str] = field(default_factory=list)
57
+
58
+
59
+ class InferenceEngine:
60
+ def __init__(
61
+ self,
62
+ model_path: Path,
63
+ profile: HardwareProfile,
64
+ model_spec: ModelSpec | None = None,
65
+ kv_type: str = "fp16",
66
+ flash_attn: bool = True,
67
+ n_gpu_layers: int = 0,
68
+ ):
69
+ self.model_path = model_path
70
+ self.profile = profile
71
+ self.model_spec = model_spec
72
+ # Load-time backend options: KV cache type ("fp16" default; "q8_0"
73
+ # halves KV memory at a small quality cost) and flash attention.
74
+ self.kv_type = kv_type
75
+ self.flash_attn = flash_attn
76
+ # 0 = CPU-only (default, safe). Set from the fit estimator's
77
+ # gpu_layers_for_vram when a VRAM budget exists.
78
+ self.n_gpu_layers = n_gpu_layers
79
+ self._llm = None
80
+
81
+ self._layer_skipper: LayerSkipScheduler | None = None
82
+ self._sparsity_predictor: SparsityPredictor | None = None
83
+ self._sparsity_cache = SparsityCache()
84
+ self._speculative_decoder = SpeculativeDecoder(adaptive=True)
85
+ self.last_pld_copied_chars = 0
86
+
87
+ def load(self) -> None:
88
+ """Load the model with hardware-optimized settings."""
89
+ try:
90
+ from llama_cpp import Llama
91
+ except ImportError:
92
+ raise ImportError(
93
+ "llama-cpp-python is required for inference. "
94
+ "Install with: pip install llama-cpp-python"
95
+ )
96
+
97
+ ctx_length = self.profile.recommended_context_length
98
+ if self.model_spec and self.model_spec.context_length < ctx_length:
99
+ ctx_length = self.model_spec.context_length
100
+
101
+ n_threads = self.profile.recommended_threads
102
+ n_batch = self._compute_batch_size()
103
+
104
+ self._llm = Llama(
105
+ model_path=str(self.model_path),
106
+ n_ctx=ctx_length,
107
+ n_threads=n_threads,
108
+ n_threads_batch=n_threads,
109
+ n_batch=n_batch,
110
+ n_gpu_layers=self.n_gpu_layers,
111
+ type_kv=_KV_TYPE_MAP[self.kv_type],
112
+ flash_attn=self.flash_attn,
113
+ verbose=False,
114
+ )
115
+
116
+ self._init_optimizers()
117
+
118
+ def _init_optimizers(self) -> None:
119
+ """Initialize Phase 2 optimization modules based on loaded model.
120
+
121
+ Metadata keys are architecture-prefixed (`{arch}.block_count`, ...),
122
+ not always `llama.*` — resolve via the shared GGUF helper, falling
123
+ back to raw metadata with an architecture prefix.
124
+ """
125
+ from eigencore.fit.gguf_meta import read_gguf_model_info
126
+
127
+ info = read_gguf_model_info(self.model_path)
128
+ if info is not None:
129
+ num_layers = info.geometry.num_layers or 0
130
+ hidden_size = info.geometry.hidden_size or 0
131
+ else:
132
+ metadata = dict(self._llm.metadata) if self._llm else {}
133
+ arch = metadata.get("general.architecture", "llama")
134
+ num_layers = int(
135
+ metadata.get(f"{arch}.block_count", metadata.get("llama.block_count", 0))
136
+ )
137
+ hidden_size = int(
138
+ metadata.get(f"{arch}.embedding_length", metadata.get("llama.embedding_length", 0))
139
+ )
140
+
141
+ if num_layers > 0:
142
+ self._layer_skipper = LayerSkipScheduler(
143
+ num_layers=num_layers,
144
+ strategy=SkipStrategy.STATIC,
145
+ max_skip_rate=0.3,
146
+ )
147
+
148
+ if hidden_size > 0 and num_layers > 0:
149
+ self._sparsity_predictor = SparsityPredictor(
150
+ num_layers=num_layers,
151
+ neurons_per_layer=hidden_size,
152
+ warmup_samples=16,
153
+ )
154
+
155
+ def _compute_batch_size(self) -> int:
156
+ """Determine optimal batch size based on available RAM and ISA."""
157
+ if InstructionSet.AVX512F in self.profile.instruction_sets:
158
+ return 1024
159
+ elif InstructionSet.AVX2 in self.profile.instruction_sets:
160
+ return 512
161
+ else:
162
+ return 256
163
+
164
+ def generate(
165
+ self,
166
+ prompt: str,
167
+ config: GenerationConfig | None = None,
168
+ ) -> GenerationResult:
169
+ """Generate a complete response (non-streaming)."""
170
+ if self._llm is None:
171
+ raise RuntimeError("Model not loaded. Call .load() first.")
172
+
173
+ if config is None:
174
+ config = GenerationConfig(stream=False)
175
+
176
+ messages = [{"role": "user", "content": prompt}]
177
+ return self.chat(messages, config)
178
+
179
+ def stream(
180
+ self,
181
+ prompt: str,
182
+ config: GenerationConfig | None = None,
183
+ ) -> Iterator[str]:
184
+ """Stream tokens as they're generated."""
185
+ if self._llm is None:
186
+ raise RuntimeError("Model not loaded. Call .load() first.")
187
+
188
+ if config is None:
189
+ config = GenerationConfig(stream=True)
190
+
191
+ messages = [{"role": "user", "content": prompt}]
192
+
193
+ for chunk in self._llm.create_chat_completion(
194
+ messages=messages,
195
+ max_tokens=config.max_tokens,
196
+ temperature=config.temperature,
197
+ top_p=config.top_p,
198
+ top_k=config.top_k,
199
+ repeat_penalty=config.repeat_penalty,
200
+ stop=config.stop or None,
201
+ stream=True,
202
+ ):
203
+ delta = chunk["choices"][0].get("delta", {})
204
+ token = delta.get("content", "")
205
+ if token:
206
+ yield token
207
+
208
+ def stream_messages(
209
+ self,
210
+ messages: list[dict[str, str]],
211
+ config: GenerationConfig | None = None,
212
+ ) -> Iterator[str]:
213
+ """Stream a chat completion from a full message list (multi-turn).
214
+
215
+ When config.prompt_lookup is set, prompt-verbatim continuations are
216
+ emitted directly (byte-identical copy, see engine.prompt_lookup);
217
+ copied characters are reported via `last_pld_copied_chars`.
218
+ """
219
+ if self._llm is None:
220
+ raise RuntimeError("Model not loaded. Call .load() first.")
221
+ if config is None:
222
+ config = GenerationConfig(stream=True)
223
+ prompt_text = " ".join(m.get("content", "") for m in messages)
224
+ generated = ""
225
+ self.last_pld_copied_chars = 0
226
+ pending_skip = 0 # backend chars to swallow (already copied from prompt)
227
+ for chunk in self._llm.create_chat_completion(
228
+ messages=messages,
229
+ max_tokens=config.max_tokens,
230
+ temperature=config.temperature,
231
+ top_p=config.top_p,
232
+ top_k=config.top_k,
233
+ repeat_penalty=config.repeat_penalty,
234
+ stop=config.stop or None,
235
+ stream=True,
236
+ ):
237
+ delta = chunk["choices"][0].get("delta", {})
238
+ token = delta.get("content", "")
239
+ if token:
240
+ if pending_skip > 0:
241
+ # this backend token is part of a span we already copied
242
+ pending_skip -= len(token)
243
+ continue
244
+ if config.prompt_lookup:
245
+ continuation = longest_prompt_continuation(prompt_text, generated + token)
246
+ if continuation:
247
+ # The prompt continues the generation verbatim: emit the
248
+ # copied span after this token and swallow its backend copy.
249
+ pending_skip = len(continuation)
250
+ self.last_pld_copied_chars += len(continuation)
251
+ generated += token + continuation
252
+ yield token
253
+ yield continuation
254
+ continue
255
+ generated += token
256
+ yield token
257
+
258
+ def chat(
259
+ self,
260
+ messages: list[dict[str, str]],
261
+ config: GenerationConfig | None = None,
262
+ ) -> GenerationResult:
263
+ """Chat-style completion with message list."""
264
+ if self._llm is None:
265
+ raise RuntimeError("Model not loaded. Call .load() first.")
266
+
267
+ if config is None:
268
+ config = GenerationConfig(stream=False)
269
+
270
+ start = time.perf_counter()
271
+
272
+ response = self._llm.create_chat_completion(
273
+ messages=messages,
274
+ max_tokens=config.max_tokens,
275
+ temperature=config.temperature,
276
+ top_p=config.top_p,
277
+ top_k=config.top_k,
278
+ repeat_penalty=config.repeat_penalty,
279
+ stop=config.stop or None,
280
+ )
281
+
282
+ elapsed = time.perf_counter() - start
283
+ text = response["choices"][0]["message"]["content"]
284
+ usage = response.get("usage", {})
285
+ prompt_tokens = usage.get("prompt_tokens", 0)
286
+ completion_tokens = usage.get("completion_tokens", 0)
287
+ tps = completion_tokens / elapsed if elapsed > 0 else 0.0
288
+
289
+ return GenerationResult(
290
+ text=text,
291
+ tokens_generated=completion_tokens,
292
+ time_seconds=elapsed,
293
+ tokens_per_second=tps,
294
+ prompt_tokens=prompt_tokens,
295
+ optimization_stats=self._collect_stats(config),
296
+ applied_optimizations=self._applied_optimizations(),
297
+ )
298
+
299
+ def _applied_optimizations(self) -> list[str]:
300
+ """Optimizations actually wired into the backend call.
301
+
302
+ Currently none: layer-skip/sparsity only produce reported plans and
303
+ the speculative decoder is never invoked during generation. Kept
304
+ explicit so callers can distinguish measured from planned work.
305
+ """
306
+ return []
307
+
308
+ def _collect_stats(self, config: GenerationConfig) -> dict:
309
+ """Collect optimization statistics from Phase 2 modules."""
310
+ stats: dict = {}
311
+ stats["backend_wired"] = self._applied_optimizations()
312
+ stats["note"] = (
313
+ "stats are planned/reported only; no optimization is applied to the backend call yet"
314
+ )
315
+
316
+ if self._layer_skipper and config.enable_layer_skip:
317
+ plan = self._layer_skipper.plan(complexity=config.complexity)
318
+ stats["layer_skip"] = {
319
+ "applied": False,
320
+ "strategy": plan.strategy.name,
321
+ "layers_skipped": len(plan.layers_to_skip),
322
+ "total_layers": plan.total_layers,
323
+ "skip_rate": plan.skip_rate,
324
+ "expected_speedup": plan.expected_speedup,
325
+ }
326
+
327
+ if self._sparsity_predictor and config.enable_sparse_inference:
328
+ if self._sparsity_predictor.is_warmed_up:
329
+ exec_plan = self._sparsity_predictor.create_execution_plan()
330
+ stats["sparse_inference"] = {
331
+ "applied": False,
332
+ "overall_sparsity": exec_plan.overall_sparsity,
333
+ "estimated_speedup": exec_plan.estimated_speedup,
334
+ "flop_savings": exec_plan.estimated_flop_savings,
335
+ }
336
+
337
+ stats["sparsity_cache"] = {
338
+ "hit_rate": self._sparsity_cache.hit_rate,
339
+ "hits": self._sparsity_cache.hits,
340
+ "misses": self._sparsity_cache.misses,
341
+ }
342
+
343
+ stats["speculative"] = {
344
+ "applied": False,
345
+ "draft_length": self._speculative_decoder.draft_length,
346
+ "overall_accept_rate": self._speculative_decoder.stats.overall_accept_rate,
347
+ "total_rounds": self._speculative_decoder.stats.total_rounds,
348
+ }
349
+
350
+ return stats
351
+
352
+ def unload(self) -> None:
353
+ """Release model from memory."""
354
+ if self._llm is not None:
355
+ del self._llm
356
+ self._llm = None
357
+
358
+ @property
359
+ def is_loaded(self) -> bool:
360
+ return self._llm is not None
361
+
362
+ @property
363
+ def optimization_stats(self) -> dict:
364
+ """Summary of all Phase 2 optimization module states."""
365
+ config = GenerationConfig()
366
+ return self._collect_stats(config) if self._llm else {}
367
+
368
+ @property
369
+ def layer_skipper(self) -> LayerSkipScheduler | None:
370
+ return self._layer_skipper
371
+
372
+ @property
373
+ def sparsity_predictor(self) -> SparsityPredictor | None:
374
+ return self._sparsity_predictor
375
+
376
+ @property
377
+ def speculative_decoder(self) -> SpeculativeDecoder:
378
+ return self._speculative_decoder