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,109 @@
1
+ """
2
+ Activation-sparsity experiment — Stage E slice 3 (PowerInfer/TurboSparse/TEAL-
3
+ style), REPORTED-ONLY.
4
+
5
+ Idea: if a fraction of FFN neurons are near-zero for a given token, skip their
6
+ contribution (sparse GEMM) to save compute. On bandwidth-bound CPU decode the
7
+ win is small and quant/dequant dominates — hence the kill criterion.
8
+
9
+ This module measures per-layer activation sparsity on real activations,
10
+ builds a sparse mask plan, and enforces the kill criterion: if the measured
11
+ quality delta exceeds the allowed threshold, the experiment is aborted and no
12
+ sparsity is applied. Nothing here is wired into inference; the backend always
13
+ runs dense today. No quality/performance claim without the owner's runs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ import numpy as np
21
+
22
+
23
+ def activation_sparsity(activations: np.ndarray, threshold: float = 0.01) -> float:
24
+ """Fraction of |x| <= threshold. `activations`: (tokens, hidden)."""
25
+ a = np.abs(np.asarray(activations, dtype=np.float64))
26
+ if a.size == 0:
27
+ return 0.0
28
+ return float((a <= threshold).mean())
29
+
30
+
31
+ def layer_sparsity_profile(
32
+ hidden_states: np.ndarray, # (layers, tokens, hidden) FFN inputs per layer
33
+ threshold: float = 0.01,
34
+ ) -> np.ndarray:
35
+ """Per-layer sparsity, (layers,)."""
36
+ hs = np.asarray(hidden_states, dtype=np.float64)
37
+ if hs.ndim != 3:
38
+ raise ValueError("expected (layers, tokens, hidden)")
39
+ return np.array([activation_sparsity(hs[i], threshold) for i in range(hs.shape[0])])
40
+
41
+
42
+ @dataclass
43
+ class SparseMask:
44
+ layer: int
45
+ keep: np.ndarray # bool mask over hidden dim; True = compute
46
+
47
+ @property
48
+ def sparsity(self) -> float:
49
+ return 1.0 - float(self.keep.mean())
50
+
51
+
52
+ def build_sparse_mask(
53
+ activations: np.ndarray,
54
+ threshold: float = 0.01,
55
+ ) -> SparseMask:
56
+ """Keep neurons whose |activation| exceeds threshold, averaged over tokens."""
57
+ a = np.abs(np.asarray(activations, dtype=np.float64))
58
+ mean_abs = a.mean(axis=0) if a.ndim == 2 else np.abs(a)
59
+ return SparseMask(layer=-1, keep=mean_abs > threshold)
60
+
61
+
62
+ @dataclass
63
+ class SparsityPlan:
64
+ masks: list[SparseMask]
65
+ overall_sparsity: float
66
+ max_quality_delta_allowed: float
67
+ killed: bool = False
68
+ kill_reason: str = ""
69
+ notes: list[str] = field(default_factory=list)
70
+
71
+
72
+ def build_sparsity_plan(
73
+ hidden_states: np.ndarray, # (layers, tokens, hidden)
74
+ threshold: float = 0.01,
75
+ min_sparsity_to_apply: float = 0.5,
76
+ max_quality_delta_allowed: float = 0.02,
77
+ measured_quality_delta: float | None = None,
78
+ ) -> SparsityPlan:
79
+ """Build per-layer sparse masks; kill if quality delta exceeds allowed.
80
+
81
+ Layers whose measured sparsity is below `min_sparsity_to_apply` get a full
82
+ keep mask (applying sparsity there saves nothing meaningful).
83
+ """
84
+ profile = layer_sparsity_profile(hidden_states, threshold)
85
+ if measured_quality_delta is not None and measured_quality_delta > max_quality_delta_allowed:
86
+ return SparsityPlan(
87
+ masks=[],
88
+ overall_sparsity=float(profile.mean()),
89
+ max_quality_delta_allowed=max_quality_delta_allowed,
90
+ killed=True,
91
+ kill_reason=(
92
+ f"quality delta {measured_quality_delta:.4f} > allowed "
93
+ f"{max_quality_delta_allowed:.4f} — kill criterion hit, no sparsity applied"
94
+ ),
95
+ )
96
+ masks: list[SparseMask] = []
97
+ for i in range(hidden_states.shape[0]):
98
+ if profile[i] >= min_sparsity_to_apply:
99
+ masks.append(build_sparse_mask(hidden_states[i], threshold))
100
+ masks[-1].layer = i
101
+ else:
102
+ masks.append(SparseMask(layer=i, keep=np.ones(hidden_states.shape[2], dtype=bool)))
103
+ overall = float(sum(m.sparsity for m in masks) / len(masks)) if masks else 0.0
104
+ return SparsityPlan(
105
+ masks=masks,
106
+ overall_sparsity=overall,
107
+ max_quality_delta_allowed=max_quality_delta_allowed,
108
+ notes=[f"mean measured sparsity {profile.mean():.1%} at threshold {threshold}"],
109
+ )
@@ -0,0 +1,238 @@
1
+ """
2
+ Speculative decoding rethought for CPU.
3
+
4
+ Standard speculative decoding uses a small "draft" model to predict N tokens,
5
+ then the large "verifier" model checks them in a single batch. On GPU this is
6
+ fast because the batch verify step parallelizes well.
7
+
8
+ On CPU the advantage is different: we exploit the fact that CPU branch prediction
9
+ and sequential processing makes it cheaper to run a tiny draft model for N tokens
10
+ than to run the large model N times. The verify step is still sequential on CPU,
11
+ but we only pay the large-model cost once per accepted draft sequence.
12
+
13
+ Key CPU-specific adaptations:
14
+ 1. Draft model shares the same memory-mapped GGUF file as verifier (different quant)
15
+ 2. Acceptance threshold adapts based on observed accept rate
16
+ 3. Draft length scales with CPU cache size (longer drafts if L3 cache can hold draft model)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from dataclasses import dataclass, field
23
+
24
+ import numpy as np
25
+
26
+
27
+ @dataclass
28
+ class DraftToken:
29
+ token_id: int
30
+ logprob: float
31
+ position: int
32
+
33
+
34
+ @dataclass
35
+ class SpeculativeResult:
36
+ accepted_tokens: list[int]
37
+ drafted_tokens: int
38
+ accepted_count: int
39
+ rejection_position: int | None
40
+ correction_token: int | None
41
+ draft_time_ms: float
42
+ verify_time_ms: float
43
+ total_time_ms: float
44
+
45
+ @property
46
+ def accept_rate(self) -> float:
47
+ return self.accepted_count / self.drafted_tokens if self.drafted_tokens > 0 else 0.0
48
+
49
+ @property
50
+ def speedup(self) -> float:
51
+ if self.total_time_ms <= 0:
52
+ return 1.0
53
+ naive_time = self.total_time_ms * (self.drafted_tokens + 1) / max(self.drafted_tokens, 1)
54
+ return naive_time / self.total_time_ms if self.total_time_ms > 0 else 1.0
55
+
56
+
57
+ @dataclass
58
+ class SpeculativeStats:
59
+ total_drafted: int = 0
60
+ total_accepted: int = 0
61
+ total_rounds: int = 0
62
+ draft_times_ms: list[float] = field(default_factory=list)
63
+ verify_times_ms: list[float] = field(default_factory=list)
64
+
65
+ @property
66
+ def overall_accept_rate(self) -> float:
67
+ return self.total_accepted / self.total_drafted if self.total_drafted > 0 else 0.0
68
+
69
+ @property
70
+ def avg_draft_time_ms(self) -> float:
71
+ return np.mean(self.draft_times_ms) if self.draft_times_ms else 0.0
72
+
73
+ @property
74
+ def avg_verify_time_ms(self) -> float:
75
+ return np.mean(self.verify_times_ms) if self.verify_times_ms else 0.0
76
+
77
+ def summary(self) -> str:
78
+ return (
79
+ f"Speculative decoding: {self.total_rounds} rounds | "
80
+ f"Accept rate: {self.overall_accept_rate:.0%} | "
81
+ f"Avg draft: {self.avg_draft_time_ms:.1f}ms | "
82
+ f"Avg verify: {self.avg_verify_time_ms:.1f}ms"
83
+ )
84
+
85
+
86
+ class SpeculativeDecoder:
87
+ """
88
+ CPU-optimized speculative decoding engine.
89
+
90
+ Takes a draft function (fast, small model) and a verify function (large model)
91
+ as callables, so it can wrap any underlying inference backend.
92
+ """
93
+
94
+ DEFAULT_DRAFT_LENGTH = 5
95
+ MIN_DRAFT_LENGTH = 2
96
+ MAX_DRAFT_LENGTH = 12
97
+
98
+ def __init__(
99
+ self,
100
+ draft_fn=None,
101
+ verify_fn=None,
102
+ initial_draft_length: int = 5,
103
+ acceptance_threshold: float = 0.3,
104
+ adaptive: bool = True,
105
+ ):
106
+ self.draft_fn = draft_fn
107
+ self.verify_fn = verify_fn
108
+ self.draft_length = initial_draft_length
109
+ self.acceptance_threshold = acceptance_threshold
110
+ self.adaptive = adaptive
111
+ self.stats = SpeculativeStats()
112
+
113
+ def optimal_draft_length(self, l3_cache_mb: float, draft_model_mb: float) -> int:
114
+ """
115
+ Scale draft length based on whether the draft model fits in L3 cache.
116
+ If it fits, longer drafts are nearly free (no main memory round-trips).
117
+ """
118
+ if draft_model_mb <= 0:
119
+ return self.DEFAULT_DRAFT_LENGTH
120
+
121
+ cache_ratio = l3_cache_mb / draft_model_mb
122
+
123
+ if cache_ratio >= 1.0:
124
+ return min(self.MAX_DRAFT_LENGTH, int(self.DEFAULT_DRAFT_LENGTH * 1.5))
125
+ elif cache_ratio >= 0.5:
126
+ return self.DEFAULT_DRAFT_LENGTH
127
+ else:
128
+ return max(self.MIN_DRAFT_LENGTH, int(self.DEFAULT_DRAFT_LENGTH * 0.6))
129
+
130
+ def verify_draft(
131
+ self,
132
+ draft_logprobs: np.ndarray,
133
+ verifier_logprobs: np.ndarray,
134
+ draft_token_ids: list[int],
135
+ rng: np.random.Generator | None = None,
136
+ ) -> SpeculativeResult:
137
+ """
138
+ Leviathan/Chen-style speculative rejection sampling.
139
+
140
+ `verifier_logprobs` is (n_positions, vocab) log-probs from the target
141
+ model; `draft_logprobs[i]` is the draft model's log-prob of the drafted
142
+ token at position i. Token t is accepted with probability
143
+ min(1, q(t)/p(t)); on rejection the correction token is sampled from
144
+ the residual distribution norm(max(q - p, 0)), which preserves the
145
+ target distribution exactly. (The draft distribution is approximated
146
+ as a point mass at the drafted token, since only its log-prob is
147
+ passed in — documented limitation.)
148
+ """
149
+ start = time.perf_counter()
150
+ n_draft = len(draft_token_ids)
151
+ rng = rng or np.random.default_rng()
152
+
153
+ if n_draft == 0 or len(draft_logprobs) == 0 or len(verifier_logprobs) == 0:
154
+ return SpeculativeResult(
155
+ accepted_tokens=[],
156
+ drafted_tokens=0,
157
+ accepted_count=0,
158
+ rejection_position=None,
159
+ correction_token=None,
160
+ draft_time_ms=0,
161
+ verify_time_ms=0,
162
+ total_time_ms=0,
163
+ )
164
+
165
+ accepted = []
166
+ rejection_pos = None
167
+ correction = None
168
+
169
+ for i in range(min(n_draft, len(verifier_logprobs))):
170
+ token_id = draft_token_ids[i]
171
+ if i < len(draft_logprobs):
172
+ draft_lp = draft_logprobs[i]
173
+ else:
174
+ break
175
+ q = np.exp(verifier_logprobs[i] - verifier_logprobs[i].max())
176
+ q /= q.sum()
177
+ p_t = float(np.exp(draft_lp))
178
+ q_t = float(q[token_id]) if token_id < len(q) else 0.0
179
+ if rng.random() <= min(1.0, q_t / p_t) if p_t > 0 else True:
180
+ accepted.append(token_id)
181
+ else:
182
+ rejection_pos = i
183
+ # residual distribution: norm(max(q - p, 0)), p = point mass at token
184
+ residual = np.maximum(q - p_t * np.eye(len(q))[token_id], 0.0)
185
+ total = residual.sum()
186
+ if total > 0:
187
+ correction = int(rng.choice(len(q), p=residual / total))
188
+ else:
189
+ correction = int(np.argmax(q))
190
+ break
191
+
192
+ elapsed = (time.perf_counter() - start) * 1000
193
+
194
+ result = SpeculativeResult(
195
+ accepted_tokens=accepted,
196
+ drafted_tokens=n_draft,
197
+ accepted_count=len(accepted),
198
+ rejection_position=rejection_pos,
199
+ correction_token=correction,
200
+ draft_time_ms=0,
201
+ verify_time_ms=elapsed,
202
+ total_time_ms=elapsed,
203
+ )
204
+
205
+ self._update_stats(result)
206
+
207
+ if self.adaptive:
208
+ self._adapt_draft_length(result.accept_rate)
209
+
210
+ return result
211
+
212
+ def _adapt_draft_length(self, recent_accept_rate: float) -> None:
213
+ """
214
+ Adjust draft length based on acceptance rate.
215
+ High accept rate → try longer drafts (more tokens per round).
216
+ Low accept rate → shorter drafts (less wasted compute).
217
+ """
218
+ if recent_accept_rate > 0.8:
219
+ self.draft_length = min(self.draft_length + 1, self.MAX_DRAFT_LENGTH)
220
+ elif recent_accept_rate < 0.3:
221
+ self.draft_length = max(self.draft_length - 1, self.MIN_DRAFT_LENGTH)
222
+
223
+ def _update_stats(self, result: SpeculativeResult) -> None:
224
+ self.stats.total_drafted += result.drafted_tokens
225
+ self.stats.total_accepted += result.accepted_count
226
+ self.stats.total_rounds += 1
227
+ self.stats.draft_times_ms.append(result.draft_time_ms)
228
+ self.stats.verify_times_ms.append(result.verify_time_ms)
229
+
230
+ def measured_speedup(self, draft_time_ms: float, verify_time_ms: float) -> float:
231
+ """Speedup computed only from measured times; no theoretical claims."""
232
+ if verify_time_ms <= 0:
233
+ return 1.0
234
+ return (
235
+ (draft_time_ms + verify_time_ms) / max(verify_time_ms, 1e-9)
236
+ if draft_time_ms > 0
237
+ else 1.0
238
+ )
@@ -0,0 +1,34 @@
1
+ """Phase 2 model-fit estimation."""
2
+
3
+ from eigencore.fit.estimator import (
4
+ BYTES_PER_PARAM,
5
+ DEFAULT_RUNTIME_RESERVE_GB,
6
+ DEFAULT_SAFETY_MARGIN_GB,
7
+ FitEstimate,
8
+ FitVerdict,
9
+ ModelGeometry,
10
+ decode_ceiling_tok_s,
11
+ estimate_fit,
12
+ estimate_kv_cache_gib,
13
+ estimate_weights_gib,
14
+ gpu_layers_for_vram,
15
+ max_context_for_budget,
16
+ )
17
+ from eigencore.fit.gguf_meta import GGUFModelInfo, read_gguf_model_info
18
+
19
+ __all__ = [
20
+ "BYTES_PER_PARAM",
21
+ "DEFAULT_RUNTIME_RESERVE_GB",
22
+ "DEFAULT_SAFETY_MARGIN_GB",
23
+ "GGUFModelInfo",
24
+ "FitEstimate",
25
+ "FitVerdict",
26
+ "ModelGeometry",
27
+ "estimate_fit",
28
+ "estimate_kv_cache_gib",
29
+ "estimate_weights_gib",
30
+ "gpu_layers_for_vram",
31
+ "decode_ceiling_tok_s",
32
+ "max_context_for_budget",
33
+ "read_gguf_model_info",
34
+ ]
@@ -0,0 +1,310 @@
1
+ """
2
+ Model-fit estimation — Phase 2 (honest hardware fit).
3
+
4
+ Estimates the memory needed to *load and run* a model, separating:
5
+ - weights (actual GGUF file size when known, else params × bytes-per-param)
6
+ - runtime reserve (allocator/backend/OS overhead, configurable)
7
+ - KV cache (context-length dependent; needs per-model geometry)
8
+
9
+ All sizes are **GiB** (1 GiB = 1024**3 bytes) end to end. Without model
10
+ geometry the KV cache is unknowable, so the verdict is `UNKNOWN` rather than
11
+ a made-up heuristic — a Core i7 label alone cannot predict whether a 35B Q4
12
+ model is usable.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from enum import Enum
19
+
20
+ # Approximate bytes per parameter for common GGUF quant types.
21
+ # These are planning estimates, not measurements.
22
+ BYTES_PER_PARAM: dict[str, float] = {
23
+ "Q4_K_M": 0.55,
24
+ "Q4_0": 0.50,
25
+ "Q5_K_M": 0.68,
26
+ "Q6_K": 0.84,
27
+ "Q8_0": 1.05,
28
+ "F16": 2.0,
29
+ "F32": 4.0,
30
+ }
31
+
32
+ # KV cache per token, per layer, per KV head, in bytes at fp16.
33
+ # kv_bytes_per_token = layers × kv_heads × head_dim × 2 (K+V) × 2 bytes
34
+ # head_dim = hidden_size / n_heads; for grouped-query attention kv_heads < n_heads.
35
+ FP16_BYTES_PER_ELEMENT = 2
36
+ KV_BYTES_PER_ELEMENT_Q8 = 1 # q8_0 KV cache
37
+
38
+ DEFAULT_RUNTIME_RESERVE_GB = 2.0
39
+ DEFAULT_SAFETY_MARGIN_GB = 1.0
40
+
41
+
42
+ class FitVerdict(str, Enum):
43
+ FITS_WITH_MARGIN = "fits_with_margin"
44
+ TIGHT = "tight"
45
+ DOES_NOT_FIT = "does_not_fit"
46
+ UNKNOWN = "unknown"
47
+
48
+
49
+ @dataclass
50
+ class ModelGeometry:
51
+ """Architecture metadata needed for a KV-cache estimate.
52
+
53
+ All fields optional: when absent the estimator falls back to a
54
+ conservative generic estimate and marks the result as UNKNOWN-precision.
55
+ """
56
+
57
+ num_layers: int | None = None
58
+ hidden_size: int | None = None
59
+ num_attention_heads: int | None = None
60
+ num_kv_heads: int | None = None # GQA/MQA: defaults to num_attention_heads
61
+ expert_count: int | None = None # MoE: total experts per MoE layer
62
+ expert_used_count: int | None = None # MoE: experts activated per token
63
+
64
+ @property
65
+ def kv_heads(self) -> int | None:
66
+ return self.num_kv_heads or self.num_attention_heads
67
+
68
+ @property
69
+ def head_dim(self) -> int | None:
70
+ if self.hidden_size and self.num_attention_heads:
71
+ return self.hidden_size // self.num_attention_heads
72
+ return None
73
+
74
+ @property
75
+ def is_complete(self) -> bool:
76
+ return all(
77
+ v is not None for v in (self.num_layers, self.hidden_size, self.num_attention_heads)
78
+ )
79
+
80
+ @property
81
+ def is_moe(self) -> bool:
82
+ return bool(self.expert_count and self.expert_used_count)
83
+
84
+
85
+ @dataclass
86
+ class FitEstimate:
87
+ weights_gb: float
88
+ active_weights_gb: float | None # MoE only: weights actually read per decode token
89
+ runtime_reserve_gb: float
90
+ kv_cache_gb: float
91
+ total_gb: float
92
+ available_ram_gb: float
93
+ headroom_gb: float
94
+ verdict: FitVerdict
95
+ context_length: int
96
+ quant: str
97
+ resident: bool # file fully in RAM (False → mmap/SSD paging)
98
+ decode_ceiling_tok_s: float | None # bandwidth / active bytes-per-token ceiling
99
+ gpu_layers: int | None = None # layers offloaded to GPU, if a VRAM budget was given
100
+ gpu_weights_gb: float = 0.0 # share of weights resident in VRAM
101
+ notes: list[str] = field(default_factory=list)
102
+
103
+ @property
104
+ def fits(self) -> bool:
105
+ return self.verdict in (FitVerdict.FITS_WITH_MARGIN, FitVerdict.TIGHT)
106
+
107
+
108
+ def gpu_layers_for_vram(
109
+ num_layers: int,
110
+ layer_bytes: int,
111
+ available_vram_gb: float,
112
+ kv_cache_gb: float = 0.0,
113
+ vram_reserve_gb: float = 1.0,
114
+ ) -> int:
115
+ """How many layers fit in VRAM: (vram - reserve - kv) / layer_bytes, capped at num_layers.
116
+
117
+ layer_bytes is the per-layer weight size (uniform approximation; real
118
+ per-layer sizes vary slightly). Returns 0 when nothing fits.
119
+ """
120
+ if num_layers <= 0 or layer_bytes <= 0 or available_vram_gb <= 0:
121
+ return 0
122
+ budget_bytes = max((available_vram_gb - vram_reserve_gb - kv_cache_gb) * 1024**3, 0.0)
123
+ return min(num_layers, int(budget_bytes // layer_bytes))
124
+
125
+
126
+ def estimate_weights_gib(
127
+ params_b: float,
128
+ quant: str,
129
+ file_size_bytes: int | None = None,
130
+ ) -> tuple[float, str | None]:
131
+ """Weight size in GiB.
132
+
133
+ Uses the actual GGUF file size when available (ground truth, includes
134
+ tokenizer/mmap overhead); otherwise params × bytes-per-param.
135
+ """
136
+ if file_size_bytes is not None:
137
+ return file_size_bytes / 1024**3, None
138
+ bpp = BYTES_PER_PARAM.get(quant.upper())
139
+ if bpp is None:
140
+ return params_b * 0.61, f"unknown quant {quant!r}; assumed ~0.61 bytes/param (Q4_K_M class)"
141
+ return params_b * bpp, None
142
+
143
+
144
+ def estimate_kv_cache_gib(
145
+ context_length: int,
146
+ geometry: ModelGeometry | None,
147
+ params_b: float,
148
+ kv_quant: str = "fp16",
149
+ ) -> tuple[float | None, str | None]:
150
+ """KV cache size in GiB for a context length.
151
+
152
+ layers × kv_heads × head_dim × 2 (K+V) × bytes-per-element × ctx.
153
+ Returns (None, note) when geometry is incomplete — no heuristic.
154
+ """
155
+ if geometry is None or not geometry.is_complete:
156
+ return None, "no GGUF geometry available; KV cache unknown"
157
+ bytes_per_elem = (
158
+ FP16_BYTES_PER_ELEMENT if kv_quant.lower() in ("fp16", "f16") else KV_BYTES_PER_ELEMENT_Q8
159
+ )
160
+ per_token = geometry.num_layers * geometry.kv_heads * geometry.head_dim * 2 * bytes_per_elem
161
+ return per_token * context_length / 1024**3, None
162
+
163
+
164
+ def decode_ceiling_tok_s(bandwidth_gbps: float, active_weights_gib: float) -> float:
165
+ """Optimistic decode ceiling: bandwidth / bytes streamed per token.
166
+
167
+ For dense models active == resident weights. For MoE, only the activated
168
+ experts' weights are read per token (plus shared/non-expert weights, which
169
+ we approximate as included in the active figure). This is a CEILING —
170
+ real decode is lower due to cache misses, dequant, sampling overhead.
171
+ """
172
+ if active_weights_gib <= 0:
173
+ return 0.0
174
+ return bandwidth_gbps / active_weights_gib
175
+
176
+
177
+ def estimate_fit(
178
+ params_b: float,
179
+ quant: str,
180
+ context_length: int,
181
+ available_ram_gb: float,
182
+ geometry: ModelGeometry | None = None,
183
+ runtime_reserve_gb: float = DEFAULT_RUNTIME_RESERVE_GB,
184
+ safety_margin_gb: float = DEFAULT_SAFETY_MARGIN_GB,
185
+ kv_quant: str = "fp16",
186
+ file_size_bytes: int | None = None,
187
+ bandwidth_gbps: float | None = None,
188
+ active_params_b: float | None = None,
189
+ available_vram_gb: float = 0.0,
190
+ ) -> FitEstimate:
191
+ """Full memory fit estimate (GiB) for loading + running a model at a context length.
192
+
193
+ MoE: pass `active_params_b` (params activated per token) — resident memory
194
+ still uses the full file size, but the decode ceiling uses active weights.
195
+ """
196
+ notes: list[str] = []
197
+ weights_gb, note = estimate_weights_gib(params_b, quant, file_size_bytes)
198
+ if note:
199
+ notes.append(note)
200
+ if geometry is not None and geometry.is_moe and active_params_b is None:
201
+ # derive active params from expert ratio when geometry is present
202
+ assert geometry.expert_count and geometry.expert_used_count
203
+ active_params_b = params_b * geometry.expert_used_count / geometry.expert_count
204
+ notes.append(
205
+ f"MoE: {geometry.expert_count} experts, {geometry.expert_used_count} active/token "
206
+ f"→ active ≈ {active_params_b:.1f}B params"
207
+ )
208
+ active_weights_gb: float | None = None
209
+ if active_params_b is not None and active_params_b < params_b:
210
+ active_weights_gb, _ = estimate_weights_gib(active_params_b, quant)
211
+ kv_gb_raw, note = estimate_kv_cache_gib(context_length, geometry, params_b, kv_quant)
212
+ if note:
213
+ notes.append(note)
214
+ kv_gb = kv_gb_raw or 0.0
215
+
216
+ total = weights_gb + runtime_reserve_gb + kv_gb
217
+ headroom = available_ram_gb - total
218
+ resident = weights_gb <= available_ram_gb
219
+
220
+ if kv_gb_raw is None:
221
+ verdict = FitVerdict.UNKNOWN
222
+ notes.append("verdict UNKNOWN: KV cache excluded (no geometry); real usage is higher")
223
+ elif headroom >= safety_margin_gb:
224
+ verdict = FitVerdict.FITS_WITH_MARGIN
225
+ elif headroom >= 0:
226
+ verdict = FitVerdict.TIGHT
227
+ notes.append(f"only {headroom:.1f} GB headroom; expect swapping/slowdown under load")
228
+ else:
229
+ verdict = FitVerdict.DOES_NOT_FIT
230
+ notes.append(f"short by {-headroom:.1f} GB at context {context_length}")
231
+
232
+ if not resident:
233
+ notes.append(
234
+ f"model file ({weights_gb:.1f} GiB) exceeds available RAM ({available_ram_gb:.1f} GiB): "
235
+ "would run via mmap/SSD paging — expect severe decode slowdown (SSD read rate, not RAM bandwidth)"
236
+ )
237
+
238
+ ceiling = None
239
+ if bandwidth_gbps is not None:
240
+ active_gib = active_weights_gb if active_weights_gb is not None else weights_gb
241
+ ceiling = decode_ceiling_tok_s(bandwidth_gbps, active_gib)
242
+
243
+ gpu_layers = None
244
+ gpu_weights_gb = 0.0
245
+ if available_vram_gb > 0 and geometry is not None and geometry.num_layers:
246
+ layer_bytes = int(weights_gb * 1024**3 / geometry.num_layers) if weights_gb > 0 else 0
247
+ gpu_layers = gpu_layers_for_vram(geometry.num_layers, layer_bytes, available_vram_gb, kv_gb)
248
+ gpu_weights_gb = gpu_layers * layer_bytes / 1024**3
249
+ if gpu_layers > 0:
250
+ notes.append(
251
+ f"GPU offload: ~{gpu_layers}/{geometry.num_layers} layers "
252
+ f"(≈{gpu_weights_gb:.1f} GiB) fit in {available_vram_gb:.1f} GiB VRAM; "
253
+ "rest stays in RAM"
254
+ )
255
+ else:
256
+ notes.append("VRAM present but too small for even one layer after reserve/KV")
257
+
258
+ return FitEstimate(
259
+ weights_gb=weights_gb,
260
+ active_weights_gb=active_weights_gb,
261
+ runtime_reserve_gb=runtime_reserve_gb,
262
+ kv_cache_gb=kv_gb,
263
+ total_gb=total,
264
+ available_ram_gb=available_ram_gb,
265
+ headroom_gb=headroom,
266
+ verdict=verdict,
267
+ context_length=context_length,
268
+ quant=quant,
269
+ resident=resident,
270
+ decode_ceiling_tok_s=ceiling,
271
+ gpu_layers=gpu_layers,
272
+ gpu_weights_gb=gpu_weights_gb,
273
+ notes=notes,
274
+ )
275
+
276
+
277
+ def max_context_for_budget(
278
+ params_b: float,
279
+ quant: str,
280
+ available_ram_gb: float,
281
+ geometry: ModelGeometry | None = None,
282
+ runtime_reserve_gb: float = DEFAULT_RUNTIME_RESERVE_GB,
283
+ safety_margin_gb: float = DEFAULT_SAFETY_MARGIN_GB,
284
+ model_context_limit: int = 128 * 1024,
285
+ kv_quant: str = "fp16",
286
+ file_size_bytes: int | None = None,
287
+ ) -> int:
288
+ """Largest context length that fits the RAM budget (power-of-two capped).
289
+
290
+ Returns 0 when geometry is unknown — without per-token KV bytes a context
291
+ recommendation would be a guess.
292
+ """
293
+ if geometry is None or not geometry.is_complete:
294
+ return 0
295
+ weights_gb, _ = estimate_weights_gib(params_b, quant, file_size_bytes)
296
+ budget_gb = available_ram_gb - runtime_reserve_gb - safety_margin_gb - weights_gb
297
+ if budget_gb <= 0:
298
+ return 0
299
+ bytes_per_elem = (
300
+ FP16_BYTES_PER_ELEMENT if kv_quant.lower() in ("fp16", "f16") else KV_BYTES_PER_ELEMENT_Q8
301
+ )
302
+ per_token = geometry.num_layers * geometry.kv_heads * geometry.head_dim * 2 * bytes_per_elem
303
+ if per_token <= 0:
304
+ return 0
305
+ max_ctx = int(budget_gb * 1024**3 / per_token)
306
+ # snap down to a power of two, clamp to model limit and a sane floor
307
+ ctx = 1
308
+ while ctx * 2 <= max_ctx and ctx * 2 <= model_context_limit:
309
+ ctx *= 2
310
+ return max(ctx, 0)