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.
- eigencore/__init__.py +82 -0
- eigencore/agent/__init__.py +45 -0
- eigencore/agent/executor.py +297 -0
- eigencore/agent/grammar.py +121 -0
- eigencore/agent/policy.py +262 -0
- eigencore/agent/react.py +261 -0
- eigencore/agent/tool.py +149 -0
- eigencore/analysis/__init__.py +3 -0
- eigencore/analysis/sparsity.py +184 -0
- eigencore/benchmark/__init__.py +33 -0
- eigencore/benchmark/harness.py +342 -0
- eigencore/cli/__init__.py +0 -0
- eigencore/cli/main.py +856 -0
- eigencore/context/__init__.py +3 -0
- eigencore/context/manager.py +318 -0
- eigencore/engine/__init__.py +3 -0
- eigencore/engine/inference.py +378 -0
- eigencore/engine/layer_skip.py +268 -0
- eigencore/engine/prompt_lookup.py +39 -0
- eigencore/engine/pruning.py +116 -0
- eigencore/engine/sparse_inference.py +218 -0
- eigencore/engine/sparsity_experiment.py +109 -0
- eigencore/engine/speculative.py +238 -0
- eigencore/fit/__init__.py +34 -0
- eigencore/fit/estimator.py +310 -0
- eigencore/fit/gguf_meta.py +130 -0
- eigencore/forge.py +216 -0
- eigencore/hal/__init__.py +3 -0
- eigencore/hal/_cpuid_win.py +119 -0
- eigencore/hal/profiler.py +349 -0
- eigencore/kernels/__init__.py +28 -0
- eigencore/kernels/q4k.py +76 -0
- eigencore/kernels/reference.py +168 -0
- eigencore/models/__init__.py +3 -0
- eigencore/models/registry.py +193 -0
- eigencore/router/__init__.py +3 -0
- eigencore/router/classifier.py +176 -0
- eigencore/training/__init__.py +3 -0
- eigencore/training/convergence.py +112 -0
- eigencore/training/trainer.py +401 -0
- eigencore/tui/__init__.py +5 -0
- eigencore/tui/chat.py +265 -0
- eigencore/tui/onboarding.py +134 -0
- eigencore/tui/theme.py +61 -0
- eigencore-0.4.0.dist-info/METADATA +144 -0
- eigencore-0.4.0.dist-info/RECORD +49 -0
- eigencore-0.4.0.dist-info/WHEEL +4 -0
- eigencore-0.4.0.dist-info/entry_points.txt +2 -0
- eigencore-0.4.0.dist-info/licenses/LICENSE +21 -0
eigencore/cli/main.py
ADDED
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EigenCore CLI — the user-facing interface.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
eigencore profile # show hardware profile + recommendations
|
|
6
|
+
eigencore models # list available models
|
|
7
|
+
eigencore run --prompt "..." # run inference (auto-selects model if not specified)
|
|
8
|
+
eigencore chat # interactive chat session
|
|
9
|
+
eigencore download <model> # pre-download a model
|
|
10
|
+
eigencore analyze --prompt "." # measure activation sparsity on a prompt
|
|
11
|
+
eigencore benchmark # benchmark with/without Phase 2 optimizations
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
|
|
24
|
+
from eigencore.engine.inference import GenerationConfig, InferenceEngine
|
|
25
|
+
from eigencore.fit import (
|
|
26
|
+
FitVerdict,
|
|
27
|
+
estimate_fit,
|
|
28
|
+
gpu_layers_for_vram,
|
|
29
|
+
max_context_for_budget,
|
|
30
|
+
read_gguf_model_info,
|
|
31
|
+
)
|
|
32
|
+
from eigencore.hal.profiler import profile_hardware
|
|
33
|
+
from eigencore.models.registry import ModelRegistry
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fit_for_spec(spec, hw, registry, kv_quant: str = "fp16"):
|
|
39
|
+
"""Fit estimate for a registry spec.
|
|
40
|
+
|
|
41
|
+
Uses real GGUF metadata (geometry + file size) when the model file is
|
|
42
|
+
downloaded; otherwise params-only, which yields an UNKNOWN verdict
|
|
43
|
+
because the KV cache cannot be known without geometry.
|
|
44
|
+
"""
|
|
45
|
+
info = None
|
|
46
|
+
if registry.is_downloaded(spec):
|
|
47
|
+
info = read_gguf_model_info(spec.local_path(registry.cache_dir))
|
|
48
|
+
geometry = info.geometry if info else None
|
|
49
|
+
file_size = info.file_size_bytes if info else None
|
|
50
|
+
ctx_limit = info.context_length if info and info.context_length else spec.context_length
|
|
51
|
+
est = estimate_fit(
|
|
52
|
+
spec.params_b,
|
|
53
|
+
spec.quant,
|
|
54
|
+
hw.recommended_context_length,
|
|
55
|
+
hw.available_ram_gb,
|
|
56
|
+
geometry=geometry,
|
|
57
|
+
file_size_bytes=file_size,
|
|
58
|
+
bandwidth_gbps=hw.estimated_bandwidth_gbps,
|
|
59
|
+
kv_quant=kv_quant,
|
|
60
|
+
available_vram_gb=hw.available_vram_gb,
|
|
61
|
+
active_params_b=(
|
|
62
|
+
spec.params_b * spec.experts_active / spec.experts_total if spec.is_moe else None
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
gpu_layers = 0
|
|
66
|
+
if hw.available_vram_gb > 0 and geometry is not None and geometry.num_layers and file_size:
|
|
67
|
+
layer_bytes = int(file_size / geometry.num_layers)
|
|
68
|
+
gpu_layers = gpu_layers_for_vram(
|
|
69
|
+
geometry.num_layers, layer_bytes, hw.available_vram_gb, est.kv_cache_gb
|
|
70
|
+
)
|
|
71
|
+
max_ctx = max_context_for_budget(
|
|
72
|
+
spec.params_b,
|
|
73
|
+
spec.quant,
|
|
74
|
+
hw.available_ram_gb,
|
|
75
|
+
geometry=geometry,
|
|
76
|
+
file_size_bytes=file_size,
|
|
77
|
+
model_context_limit=ctx_limit,
|
|
78
|
+
)
|
|
79
|
+
return est, max_ctx, gpu_layers
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@click.group()
|
|
83
|
+
@click.version_option(version="0.4.0", prog_name="eigencore")
|
|
84
|
+
def cli():
|
|
85
|
+
"""EigenCore — CPU-first LLM intelligence runtime."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@cli.command()
|
|
89
|
+
def profile():
|
|
90
|
+
"""Detect hardware and show optimal model recommendations."""
|
|
91
|
+
with console.status("[bold cyan]Profiling hardware..."):
|
|
92
|
+
hw = profile_hardware()
|
|
93
|
+
|
|
94
|
+
console.print()
|
|
95
|
+
console.print(Panel(hw.summary(), title="[bold]Hardware Profile", border_style="cyan"))
|
|
96
|
+
|
|
97
|
+
registry = ModelRegistry()
|
|
98
|
+
rec = registry.recommend(hw)
|
|
99
|
+
|
|
100
|
+
console.print()
|
|
101
|
+
console.print(f"[bold green]Recommended model:[/] {rec.name} ({rec.description})")
|
|
102
|
+
console.print(
|
|
103
|
+
f" Size: {rec.size_gb:.1f} GB | Params: {rec.params_b:.1f}B | Quant: {rec.quant}"
|
|
104
|
+
)
|
|
105
|
+
est, max_ctx, gpu_layers = _fit_for_spec(rec, hw, registry)
|
|
106
|
+
verdict_style = {
|
|
107
|
+
FitVerdict.FITS_WITH_MARGIN: "green",
|
|
108
|
+
FitVerdict.TIGHT: "yellow",
|
|
109
|
+
FitVerdict.DOES_NOT_FIT: "red",
|
|
110
|
+
FitVerdict.UNKNOWN: "dim",
|
|
111
|
+
}[est.verdict]
|
|
112
|
+
console.print(
|
|
113
|
+
f" Fit estimate at ctx {est.context_length}: weights {est.weights_gb:.1f} GB + "
|
|
114
|
+
f"reserve {est.runtime_reserve_gb:.1f} GB + KV {est.kv_cache_gb:.2f} GB = "
|
|
115
|
+
f"{est.total_gb:.1f} GB of {est.available_ram_gb:.1f} GB available "
|
|
116
|
+
f"[{verdict_style}]{est.verdict.value}[/] (headroom {est.headroom_gb:.1f} GB)"
|
|
117
|
+
)
|
|
118
|
+
if est.active_weights_gb is not None:
|
|
119
|
+
console.print(
|
|
120
|
+
f" MoE: active weights/token ≈ {est.active_weights_gb:.1f} GiB "
|
|
121
|
+
f"(resident file {est.weights_gb:.1f} GiB, {'RAM' if est.resident else 'mmap/SSD paging'})"
|
|
122
|
+
)
|
|
123
|
+
if est.decode_ceiling_tok_s is not None:
|
|
124
|
+
console.print(
|
|
125
|
+
f" Decode ceiling estimate: ~{est.decode_ceiling_tok_s:.1f} tok/s "
|
|
126
|
+
f"(bandwidth / active bytes per token — ceiling, not a measurement)"
|
|
127
|
+
)
|
|
128
|
+
if gpu_layers > 0:
|
|
129
|
+
console.print(
|
|
130
|
+
f" [dim]GPU offload recommendation: {gpu_layers} layers to "
|
|
131
|
+
f"{hw.gpu_name} ({hw.available_vram_gb:.1f} GiB VRAM free)[/]"
|
|
132
|
+
)
|
|
133
|
+
for note in est.notes:
|
|
134
|
+
console.print(f" [dim]* {note}[/]")
|
|
135
|
+
if max_ctx > 0:
|
|
136
|
+
console.print(f" [dim]Max context that fits this budget: {max_ctx} tokens[/]")
|
|
137
|
+
else:
|
|
138
|
+
console.print(
|
|
139
|
+
" [red]Cannot compute fitting context (model file not downloaded or no geometry)[/]"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if not registry.is_downloaded(rec):
|
|
143
|
+
console.print(f"\n [dim]Download with:[/] eigencore download {rec.name}")
|
|
144
|
+
else:
|
|
145
|
+
console.print('\n [dim]Already downloaded. Run with:[/] eigencore run --prompt "hello"')
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@cli.command()
|
|
149
|
+
@click.option("--task", default=None, help="Filter by task type (general, code, math)")
|
|
150
|
+
def models(task: str | None):
|
|
151
|
+
"""List all available models."""
|
|
152
|
+
registry = ModelRegistry()
|
|
153
|
+
available = registry.list_models(task)
|
|
154
|
+
|
|
155
|
+
hw = profile_hardware()
|
|
156
|
+
|
|
157
|
+
table = Table(title="Available Models")
|
|
158
|
+
table.add_column("Name", style="cyan")
|
|
159
|
+
table.add_column("Params", justify="right")
|
|
160
|
+
table.add_column("Active", justify="right")
|
|
161
|
+
table.add_column("Quant")
|
|
162
|
+
table.add_column("Size", justify="right")
|
|
163
|
+
table.add_column("Task")
|
|
164
|
+
table.add_column("Fits?", justify="center")
|
|
165
|
+
table.add_column("Downloaded?", justify="center")
|
|
166
|
+
table.add_column("Description")
|
|
167
|
+
|
|
168
|
+
for m in sorted(available, key=lambda x: x.params_b):
|
|
169
|
+
est, _, _ = _fit_for_spec(m, hw, registry)
|
|
170
|
+
downloaded = registry.is_downloaded(m)
|
|
171
|
+
if est.verdict == FitVerdict.FITS_WITH_MARGIN:
|
|
172
|
+
fits_label, fits_style = "fits", "green"
|
|
173
|
+
elif est.verdict == FitVerdict.TIGHT:
|
|
174
|
+
fits_label, fits_style = "tight", "yellow"
|
|
175
|
+
elif est.verdict == FitVerdict.UNKNOWN:
|
|
176
|
+
fits_label, fits_style = "unknown", "dim"
|
|
177
|
+
else:
|
|
178
|
+
fits_label, fits_style = "no", "red"
|
|
179
|
+
table.add_row(
|
|
180
|
+
m.name,
|
|
181
|
+
f"{m.params_b:.1f}B",
|
|
182
|
+
f"{m.params_b * m.experts_active / m.experts_total:.1f}B" if m.is_moe else "—",
|
|
183
|
+
m.quant,
|
|
184
|
+
f"{m.size_gb:.1f}GB",
|
|
185
|
+
m.task,
|
|
186
|
+
f"[{fits_style}]{fits_label}[/]",
|
|
187
|
+
"[green]yes[/]" if downloaded else "[dim]no[/]",
|
|
188
|
+
m.description,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
console.print(table)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@cli.command()
|
|
195
|
+
@click.argument("name")
|
|
196
|
+
def download(name: str):
|
|
197
|
+
"""Download a model by name."""
|
|
198
|
+
registry = ModelRegistry()
|
|
199
|
+
hw = profile_hardware()
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
model = registry.resolve(name, hw)
|
|
203
|
+
except ValueError as e:
|
|
204
|
+
console.print(f"[red]Error:[/] {e}")
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
|
|
207
|
+
if registry.is_downloaded(model):
|
|
208
|
+
console.print(f"[green]Already downloaded:[/] {model.name}")
|
|
209
|
+
console.print(f" Path: {model.local_path(registry.cache_dir)}")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
console.print(f"Downloading [cyan]{model.name}[/] ({model.size_gb:.1f} GB)...")
|
|
213
|
+
console.print(f" From: {model.repo_id}")
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
path = registry.download(model)
|
|
217
|
+
console.print(f"\n[green]Downloaded:[/] {path}")
|
|
218
|
+
except Exception as e:
|
|
219
|
+
console.print(f"\n[red]Download failed:[/] {e}")
|
|
220
|
+
sys.exit(1)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@cli.command()
|
|
224
|
+
@click.option("--model", "-m", default=None, help="Model name (auto-selects if omitted)")
|
|
225
|
+
@click.option("--prompt", "-p", required=True, help="The prompt to send")
|
|
226
|
+
@click.option("--max-tokens", default=512, help="Maximum tokens to generate")
|
|
227
|
+
@click.option("--temperature", default=0.7, help="Sampling temperature")
|
|
228
|
+
@click.option("--task", default="general", help="Task type for auto model selection")
|
|
229
|
+
@click.option("--no-stream", is_flag=True, help="Disable streaming output")
|
|
230
|
+
@click.option(
|
|
231
|
+
"--kv",
|
|
232
|
+
"kv_type",
|
|
233
|
+
default="fp16",
|
|
234
|
+
type=click.Choice(["fp16", "q8_0"]),
|
|
235
|
+
help="KV cache type (q8_0 halves KV memory)",
|
|
236
|
+
)
|
|
237
|
+
@click.option("--no-flash", is_flag=True, help="Disable flash attention")
|
|
238
|
+
@click.option(
|
|
239
|
+
"--gpu-layers",
|
|
240
|
+
default=0,
|
|
241
|
+
help="Layers to offload to GPU (0 = CPU-only; auto-recommended when a GPU is detected and flag omitted)",
|
|
242
|
+
)
|
|
243
|
+
@click.option(
|
|
244
|
+
"--pld",
|
|
245
|
+
is_flag=True,
|
|
246
|
+
help="Enable prompt-lookup decoding (copies prompt-verbatim spans; default OFF until measured)",
|
|
247
|
+
)
|
|
248
|
+
def run(
|
|
249
|
+
model: str | None,
|
|
250
|
+
prompt: str,
|
|
251
|
+
max_tokens: int,
|
|
252
|
+
temperature: float,
|
|
253
|
+
task: str,
|
|
254
|
+
no_stream: bool,
|
|
255
|
+
kv_type: str,
|
|
256
|
+
no_flash: bool,
|
|
257
|
+
gpu_layers: int,
|
|
258
|
+
pld: bool,
|
|
259
|
+
):
|
|
260
|
+
"""Run inference with a prompt."""
|
|
261
|
+
hw = profile_hardware()
|
|
262
|
+
registry = ModelRegistry()
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
spec = registry.resolve(model, hw, task)
|
|
266
|
+
except ValueError as e:
|
|
267
|
+
console.print(f"[red]Error:[/] {e}")
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
if not registry.is_downloaded(spec):
|
|
271
|
+
console.print(
|
|
272
|
+
f"Model [cyan]{spec.name}[/] not downloaded. Downloading ({spec.size_gb:.1f} GB)..."
|
|
273
|
+
)
|
|
274
|
+
try:
|
|
275
|
+
registry.download(spec)
|
|
276
|
+
except Exception as e:
|
|
277
|
+
console.print(f"[red]Download failed:[/] {e}")
|
|
278
|
+
sys.exit(1)
|
|
279
|
+
|
|
280
|
+
model_path = spec.local_path(registry.cache_dir)
|
|
281
|
+
|
|
282
|
+
console.print(
|
|
283
|
+
f"[dim]Model: {spec.name} | Threads: {hw.recommended_threads} | Ctx: {hw.recommended_context_length}[/]"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if gpu_layers == 0 and hw.available_vram_gb > 0:
|
|
287
|
+
_, _, gpu_layers = _fit_for_spec(spec, hw, registry, kv_quant=kv_type)
|
|
288
|
+
if gpu_layers > 0:
|
|
289
|
+
console.print(f"[dim]Offloading {gpu_layers} layers to {hw.gpu_name}[/]")
|
|
290
|
+
engine = InferenceEngine(
|
|
291
|
+
model_path, hw, spec, kv_type=kv_type, flash_attn=not no_flash, n_gpu_layers=gpu_layers
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
with console.status("[bold cyan]Loading model..."):
|
|
295
|
+
engine.load()
|
|
296
|
+
|
|
297
|
+
config = GenerationConfig(
|
|
298
|
+
prompt_lookup=pld,
|
|
299
|
+
max_tokens=max_tokens,
|
|
300
|
+
temperature=temperature,
|
|
301
|
+
stream=not no_stream,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if no_stream:
|
|
305
|
+
result = engine.generate(prompt, config)
|
|
306
|
+
console.print()
|
|
307
|
+
console.print(result.text)
|
|
308
|
+
console.print(
|
|
309
|
+
f"\n[dim]{result.tokens_generated} tokens in {result.time_seconds:.1f}s ({result.tokens_per_second:.1f} tok/s)[/]"
|
|
310
|
+
)
|
|
311
|
+
else:
|
|
312
|
+
console.print()
|
|
313
|
+
token_count = 0
|
|
314
|
+
start_time = __import__("time").perf_counter()
|
|
315
|
+
for token in engine.stream(prompt, config):
|
|
316
|
+
console.print(token, end="", highlight=False)
|
|
317
|
+
token_count += 1
|
|
318
|
+
elapsed = __import__("time").perf_counter() - start_time
|
|
319
|
+
tps = token_count / elapsed if elapsed > 0 else 0
|
|
320
|
+
console.print(f"\n\n[dim]{token_count} tokens in {elapsed:.1f}s ({tps:.1f} tok/s)[/]")
|
|
321
|
+
|
|
322
|
+
engine.unload()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@cli.command()
|
|
326
|
+
@click.option("--model", "-m", default=None, help="Model name (auto-selects if omitted)")
|
|
327
|
+
@click.option("--max-tokens", default=512, help="Maximum tokens per response")
|
|
328
|
+
@click.option("--temperature", default=0.7, help="Sampling temperature")
|
|
329
|
+
@click.option("--task", default="general", help="Task type for auto model selection")
|
|
330
|
+
@click.option(
|
|
331
|
+
"--plain", is_flag=True, help="Plain-text mode: no panels/colors (pipes, SSH, screen readers)"
|
|
332
|
+
)
|
|
333
|
+
@click.option(
|
|
334
|
+
"--kv",
|
|
335
|
+
"kv_type",
|
|
336
|
+
default="fp16",
|
|
337
|
+
type=click.Choice(["fp16", "q8_0"]),
|
|
338
|
+
help="KV cache type (q8_0 halves KV memory)",
|
|
339
|
+
)
|
|
340
|
+
@click.option("--no-flash", is_flag=True, help="Disable flash attention")
|
|
341
|
+
@click.option(
|
|
342
|
+
"--gpu-layers",
|
|
343
|
+
default=0,
|
|
344
|
+
help="Layers to offload to GPU (0 = CPU-only; auto-recommended when a GPU is detected and flag omitted)",
|
|
345
|
+
)
|
|
346
|
+
@click.option(
|
|
347
|
+
"--pld",
|
|
348
|
+
is_flag=True,
|
|
349
|
+
help="Enable prompt-lookup decoding (copies prompt-verbatim spans; default OFF until measured)",
|
|
350
|
+
)
|
|
351
|
+
def chat(
|
|
352
|
+
model: str | None,
|
|
353
|
+
max_tokens: int,
|
|
354
|
+
temperature: float,
|
|
355
|
+
task: str,
|
|
356
|
+
plain: bool,
|
|
357
|
+
kv_type: str,
|
|
358
|
+
no_flash: bool,
|
|
359
|
+
gpu_layers: int,
|
|
360
|
+
pld: bool,
|
|
361
|
+
):
|
|
362
|
+
"""Start an interactive chat session (streaming, slash commands, plain mode available)."""
|
|
363
|
+
hw = profile_hardware()
|
|
364
|
+
registry = ModelRegistry()
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
spec = registry.resolve(model, hw, task)
|
|
368
|
+
except ValueError as e:
|
|
369
|
+
console.print(f"[red]Error:[/] {e}")
|
|
370
|
+
sys.exit(1)
|
|
371
|
+
|
|
372
|
+
if not registry.is_downloaded(spec):
|
|
373
|
+
console.print(
|
|
374
|
+
f"Model [cyan]{spec.name}[/] not downloaded. Downloading ({spec.size_gb:.1f} GB)..."
|
|
375
|
+
)
|
|
376
|
+
try:
|
|
377
|
+
registry.download(spec)
|
|
378
|
+
except Exception as e:
|
|
379
|
+
console.print(f"[red]Download failed:[/] {e}")
|
|
380
|
+
sys.exit(1)
|
|
381
|
+
|
|
382
|
+
model_path = spec.local_path(registry.cache_dir)
|
|
383
|
+
if gpu_layers == 0 and hw.available_vram_gb > 0:
|
|
384
|
+
_, _, gpu_layers = _fit_for_spec(spec, hw, registry, kv_quant=kv_type)
|
|
385
|
+
|
|
386
|
+
# SSD-paging warning before load when the file exceeds free RAM (TUI.md 3.4)
|
|
387
|
+
est0, _, _ = _fit_for_spec(spec, hw, registry, kv_quant=kv_type)
|
|
388
|
+
if not est0.resident:
|
|
389
|
+
console.print(
|
|
390
|
+
f"[yellow]WARNING:[/] {spec.name} file ~{est0.weights_gb:.1f} GiB > free RAM "
|
|
391
|
+
f"{est0.available_ram_gb:.1f} GiB — loading will page from SSD (severe slowdown).[/]"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# First-run onboarding: only when interactive and not yet onboarded
|
|
395
|
+
from eigencore.tui.onboarding import load_config, run_onboarding, save_config
|
|
396
|
+
|
|
397
|
+
cfg_store = load_config()
|
|
398
|
+
interactive = sys.stdin.isatty() and not plain
|
|
399
|
+
if interactive and not cfg_store.get("onboarded") and model is None:
|
|
400
|
+
estimates = {
|
|
401
|
+
m.name: _fit_for_spec(m, hw, registry, kv_quant=kv_type)[0]
|
|
402
|
+
for m in registry.list_models()
|
|
403
|
+
}
|
|
404
|
+
chosen = run_onboarding(
|
|
405
|
+
hw,
|
|
406
|
+
registry,
|
|
407
|
+
estimates,
|
|
408
|
+
ask=lambda prompt="": console.input(prompt),
|
|
409
|
+
out=lambda line="": console.print(line),
|
|
410
|
+
)
|
|
411
|
+
if chosen is not None and chosen.name != spec.name:
|
|
412
|
+
spec = chosen
|
|
413
|
+
if not registry.is_downloaded(spec):
|
|
414
|
+
console.print(f"Downloading [cyan]{spec.name}[/] ({spec.size_gb:.1f} GB)...")
|
|
415
|
+
registry.download(spec)
|
|
416
|
+
model_path = spec.local_path(registry.cache_dir)
|
|
417
|
+
cfg_store["onboarded"] = True
|
|
418
|
+
cfg_store["model"] = spec.name
|
|
419
|
+
save_config(cfg_store)
|
|
420
|
+
|
|
421
|
+
engine = InferenceEngine(
|
|
422
|
+
model_path, hw, spec, kv_type=kv_type, flash_attn=not no_flash, n_gpu_layers=gpu_layers
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
with console.status("[bold cyan]Loading model..."):
|
|
426
|
+
engine.load()
|
|
427
|
+
|
|
428
|
+
from eigencore.tui.chat import ChatUI, ChatUiConfig
|
|
429
|
+
from eigencore.tui.theme import probe_terminal
|
|
430
|
+
|
|
431
|
+
info = read_gguf_model_info(model_path)
|
|
432
|
+
caps = probe_terminal(force_plain=plain)
|
|
433
|
+
ui = ChatUI(
|
|
434
|
+
engine,
|
|
435
|
+
spec,
|
|
436
|
+
hw,
|
|
437
|
+
geometry=info.geometry if info else None,
|
|
438
|
+
policy_label="read-only (policy engine attached in P4)",
|
|
439
|
+
caps=caps,
|
|
440
|
+
cfg=ChatUiConfig(
|
|
441
|
+
plain=caps.plain,
|
|
442
|
+
max_tokens=max_tokens,
|
|
443
|
+
temperature=temperature,
|
|
444
|
+
prompt_lookup=pld,
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
ui.approval_card = ui.render_approval_card
|
|
448
|
+
ui.run()
|
|
449
|
+
|
|
450
|
+
engine.unload()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@cli.command()
|
|
454
|
+
@click.option("--model", "-m", default=None, help="Model name (auto-selects if omitted)")
|
|
455
|
+
@click.option("--prompt", "-p", required=True, help="Prompt to analyze sparsity on")
|
|
456
|
+
@click.option("--threshold", default=0.01, help="Near-zero threshold")
|
|
457
|
+
@click.option("--output", "-o", default=None, help="Save report to JSON file")
|
|
458
|
+
def analyze(
|
|
459
|
+
model: str | None,
|
|
460
|
+
prompt: str,
|
|
461
|
+
threshold: float,
|
|
462
|
+
output: str | None,
|
|
463
|
+
):
|
|
464
|
+
"""Measure OUTPUT-LOGIT sparsity for a prompt (not neuron activation sparsity)."""
|
|
465
|
+
from eigencore.analysis.sparsity import SparsityAnalyzer
|
|
466
|
+
|
|
467
|
+
hw = profile_hardware()
|
|
468
|
+
registry = ModelRegistry()
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
spec = registry.resolve(model, hw)
|
|
472
|
+
except ValueError as e:
|
|
473
|
+
console.print(f"[red]Error:[/] {e}")
|
|
474
|
+
sys.exit(1)
|
|
475
|
+
|
|
476
|
+
if not registry.is_downloaded(spec):
|
|
477
|
+
console.print(
|
|
478
|
+
f"Model [cyan]{spec.name}[/] not downloaded. Downloading ({spec.size_gb:.1f} GB)..."
|
|
479
|
+
)
|
|
480
|
+
try:
|
|
481
|
+
registry.download(spec)
|
|
482
|
+
except Exception as e:
|
|
483
|
+
console.print(f"[red]Download failed:[/] {e}")
|
|
484
|
+
sys.exit(1)
|
|
485
|
+
|
|
486
|
+
model_path = spec.local_path(registry.cache_dir)
|
|
487
|
+
engine = InferenceEngine(model_path, hw, spec)
|
|
488
|
+
|
|
489
|
+
with console.status("[bold cyan]Loading model..."):
|
|
490
|
+
engine.load()
|
|
491
|
+
|
|
492
|
+
console.print(f"[dim]Analyzing sparsity for: {prompt[:80]}[/]")
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
llm = engine._llm
|
|
496
|
+
|
|
497
|
+
llm.reset()
|
|
498
|
+
tokens = llm.tokenize(prompt.encode("utf-8"))
|
|
499
|
+
llm.eval(tokens)
|
|
500
|
+
|
|
501
|
+
n_vocab = llm.n_vocab()
|
|
502
|
+
logits_ptr = llm._ctx.get_logits()
|
|
503
|
+
logits = [logits_ptr[i] for i in range(n_vocab)]
|
|
504
|
+
|
|
505
|
+
analyzer = SparsityAnalyzer(threshold=threshold)
|
|
506
|
+
|
|
507
|
+
report_logits = analyzer.analyze_logits(logits, spec.name, prompt)
|
|
508
|
+
report_probs = analyzer.analyze_token_probabilities(logits, spec.name, prompt)
|
|
509
|
+
|
|
510
|
+
console.print()
|
|
511
|
+
console.print(
|
|
512
|
+
Panel(
|
|
513
|
+
report_logits.summary(),
|
|
514
|
+
title="[bold]Logit Sparsity Analysis",
|
|
515
|
+
border_style="yellow",
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
console.print()
|
|
519
|
+
console.print(
|
|
520
|
+
Panel(
|
|
521
|
+
report_probs.summary(),
|
|
522
|
+
title="[bold]Probability Concentration Analysis",
|
|
523
|
+
border_style="green",
|
|
524
|
+
)
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
console.print("\n[bold]Key findings:[/]")
|
|
528
|
+
console.print(
|
|
529
|
+
f" Logit sparsity: [cyan]{report_logits.overall_sparsity:.1%}[/] of logits near zero"
|
|
530
|
+
)
|
|
531
|
+
console.print(
|
|
532
|
+
f" Probability concentration: [cyan]{report_probs.overall_sparsity:.1%}[/] of mass in top tokens"
|
|
533
|
+
)
|
|
534
|
+
console.print(" (No speedup claimed: logit sparsity is not a measured inference speedup.)")
|
|
535
|
+
|
|
536
|
+
if output:
|
|
537
|
+
combined = {
|
|
538
|
+
"logit_analysis": report_logits.to_dict(),
|
|
539
|
+
"probability_analysis": report_probs.to_dict(),
|
|
540
|
+
}
|
|
541
|
+
Path(output).write_text(__import__("json").dumps(combined, indent=2))
|
|
542
|
+
console.print(f"\n[dim]Report saved to {output}[/]")
|
|
543
|
+
|
|
544
|
+
except Exception as e:
|
|
545
|
+
console.print(f"[red]Analysis error:[/] {e}")
|
|
546
|
+
import traceback
|
|
547
|
+
|
|
548
|
+
traceback.print_exc()
|
|
549
|
+
finally:
|
|
550
|
+
engine.unload()
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@cli.command()
|
|
554
|
+
@click.option("--model", "-m", default=None, help="Model name (auto-selects if omitted)")
|
|
555
|
+
@click.option("--prompt", "-p", default=None, help="Custom prompt to benchmark with")
|
|
556
|
+
@click.option("--max-tokens", default=128, help="Tokens to generate per run")
|
|
557
|
+
@click.option("--runs", default=3, help="Number of runs per configuration")
|
|
558
|
+
@click.option("--output", "-o", default=None, help="Save results to JSON file")
|
|
559
|
+
@click.option(
|
|
560
|
+
"--report",
|
|
561
|
+
"report_path",
|
|
562
|
+
default=None,
|
|
563
|
+
help="Save a full Phase 1 evidence report (hardware, model sha256, timing, quality) to this JSON path",
|
|
564
|
+
)
|
|
565
|
+
def benchmark(
|
|
566
|
+
model: str | None,
|
|
567
|
+
prompt: str | None,
|
|
568
|
+
max_tokens: int,
|
|
569
|
+
runs: int,
|
|
570
|
+
output: str | None,
|
|
571
|
+
report_path: str | None,
|
|
572
|
+
):
|
|
573
|
+
"""Benchmark inference across configurations.
|
|
574
|
+
|
|
575
|
+
NOTE (P3 audit): the layer_skip / sparse / all_optimizations configs only
|
|
576
|
+
change reported stats today — no optimization is wired into the backend
|
|
577
|
+
call, so speedup vs baseline is expected to be ~1.0x (noise) until P3
|
|
578
|
+
wires them in and the quality gate passes.
|
|
579
|
+
"""
|
|
580
|
+
import json
|
|
581
|
+
import time
|
|
582
|
+
|
|
583
|
+
import numpy as np
|
|
584
|
+
|
|
585
|
+
hw = profile_hardware()
|
|
586
|
+
registry = ModelRegistry()
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
spec = registry.resolve(model, hw)
|
|
590
|
+
except ValueError as e:
|
|
591
|
+
console.print(f"[red]Error:[/] {e}")
|
|
592
|
+
sys.exit(1)
|
|
593
|
+
|
|
594
|
+
if not registry.is_downloaded(spec):
|
|
595
|
+
console.print(f"[red]Model not downloaded.[/] Run: eigencore download {spec.name}")
|
|
596
|
+
sys.exit(1)
|
|
597
|
+
|
|
598
|
+
model_path = spec.local_path(registry.cache_dir)
|
|
599
|
+
|
|
600
|
+
test_prompts = [
|
|
601
|
+
prompt or "Explain how a CPU executes instructions step by step.",
|
|
602
|
+
"Write a Python function that implements quicksort with comments.",
|
|
603
|
+
"Summarize the key differences between TCP and UDP protocols.",
|
|
604
|
+
]
|
|
605
|
+
|
|
606
|
+
if prompt:
|
|
607
|
+
test_prompts = [prompt]
|
|
608
|
+
|
|
609
|
+
console.print(
|
|
610
|
+
Panel(
|
|
611
|
+
f"Model: [cyan]{spec.name}[/] ({spec.params_b:.1f}B {spec.quant})\n"
|
|
612
|
+
f"Tokens per run: {max_tokens}\n"
|
|
613
|
+
f"Runs per config: {runs}\n"
|
|
614
|
+
f"Test prompts: {len(test_prompts)}",
|
|
615
|
+
title="[bold]EigenCore Benchmark",
|
|
616
|
+
border_style="cyan",
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
engine = InferenceEngine(model_path, hw, spec)
|
|
621
|
+
with console.status("[bold cyan]Loading model..."):
|
|
622
|
+
engine.load()
|
|
623
|
+
|
|
624
|
+
if report_path:
|
|
625
|
+
from eigencore.benchmark import DEFAULT_CASES, build_report, run_cases
|
|
626
|
+
|
|
627
|
+
baseline_config = GenerationConfig(
|
|
628
|
+
max_tokens=max_tokens,
|
|
629
|
+
temperature=0.0,
|
|
630
|
+
stream=False,
|
|
631
|
+
enable_layer_skip=False,
|
|
632
|
+
enable_sparse_inference=False,
|
|
633
|
+
)
|
|
634
|
+
case_results = run_cases(engine, DEFAULT_CASES, baseline_config, runs=1)
|
|
635
|
+
report = build_report(engine, spec, model_path, baseline_config, case_results)
|
|
636
|
+
saved = report.save(report_path)
|
|
637
|
+
console.print(
|
|
638
|
+
f"[dim]Phase 1 evidence report saved to {saved} "
|
|
639
|
+
f"(quality pass rate {report.summary['quality_pass_rate']:.0%}, "
|
|
640
|
+
f"median decode {report.summary['decode_tok_s_median']:.1f} tok/s)[/]"
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
configs = [
|
|
644
|
+
(
|
|
645
|
+
"baseline",
|
|
646
|
+
GenerationConfig(
|
|
647
|
+
max_tokens=max_tokens,
|
|
648
|
+
temperature=0.0,
|
|
649
|
+
stream=False,
|
|
650
|
+
enable_layer_skip=False,
|
|
651
|
+
enable_sparse_inference=False,
|
|
652
|
+
complexity=0.5,
|
|
653
|
+
),
|
|
654
|
+
),
|
|
655
|
+
(
|
|
656
|
+
"layer_skip",
|
|
657
|
+
GenerationConfig(
|
|
658
|
+
max_tokens=max_tokens,
|
|
659
|
+
temperature=0.0,
|
|
660
|
+
stream=False,
|
|
661
|
+
enable_layer_skip=True,
|
|
662
|
+
enable_sparse_inference=False,
|
|
663
|
+
complexity=0.5,
|
|
664
|
+
),
|
|
665
|
+
),
|
|
666
|
+
(
|
|
667
|
+
"sparse",
|
|
668
|
+
GenerationConfig(
|
|
669
|
+
max_tokens=max_tokens,
|
|
670
|
+
temperature=0.0,
|
|
671
|
+
stream=False,
|
|
672
|
+
enable_layer_skip=False,
|
|
673
|
+
enable_sparse_inference=True,
|
|
674
|
+
complexity=0.5,
|
|
675
|
+
),
|
|
676
|
+
),
|
|
677
|
+
(
|
|
678
|
+
"all_optimizations",
|
|
679
|
+
GenerationConfig(
|
|
680
|
+
max_tokens=max_tokens,
|
|
681
|
+
temperature=0.0,
|
|
682
|
+
stream=False,
|
|
683
|
+
enable_layer_skip=True,
|
|
684
|
+
enable_sparse_inference=True,
|
|
685
|
+
complexity=0.3,
|
|
686
|
+
),
|
|
687
|
+
),
|
|
688
|
+
]
|
|
689
|
+
|
|
690
|
+
results_data: dict = {"model": spec.name, "max_tokens": max_tokens, "runs": runs, "configs": {}}
|
|
691
|
+
|
|
692
|
+
table = Table(title="Benchmark Results")
|
|
693
|
+
table.add_column("Config", style="cyan")
|
|
694
|
+
table.add_column("Avg tok/s", justify="right")
|
|
695
|
+
table.add_column("Min tok/s", justify="right")
|
|
696
|
+
table.add_column("Max tok/s", justify="right")
|
|
697
|
+
table.add_column("Avg latency", justify="right")
|
|
698
|
+
table.add_column("Speedup", justify="right", style="green")
|
|
699
|
+
|
|
700
|
+
baseline_tps = 0.0
|
|
701
|
+
|
|
702
|
+
for config_name, config in configs:
|
|
703
|
+
all_tps: list[float] = []
|
|
704
|
+
all_latency: list[float] = []
|
|
705
|
+
|
|
706
|
+
console.print(f"\n Running [cyan]{config_name}[/]...")
|
|
707
|
+
|
|
708
|
+
for prompt_text in test_prompts:
|
|
709
|
+
for run_idx in range(runs):
|
|
710
|
+
start = time.perf_counter()
|
|
711
|
+
result = engine.generate(prompt_text, config)
|
|
712
|
+
elapsed = time.perf_counter() - start
|
|
713
|
+
|
|
714
|
+
tps = result.tokens_generated / elapsed if elapsed > 0 else 0.0
|
|
715
|
+
all_tps.append(tps)
|
|
716
|
+
all_latency.append(elapsed)
|
|
717
|
+
|
|
718
|
+
console.print(
|
|
719
|
+
f" Run {run_idx + 1}/{runs}: "
|
|
720
|
+
f"{result.tokens_generated} tok in {elapsed:.1f}s "
|
|
721
|
+
f"({tps:.1f} tok/s)"
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
avg_tps = float(np.mean(all_tps))
|
|
725
|
+
min_tps = float(np.min(all_tps))
|
|
726
|
+
max_tps = float(np.max(all_tps))
|
|
727
|
+
avg_latency = float(np.mean(all_latency))
|
|
728
|
+
|
|
729
|
+
if config_name == "baseline":
|
|
730
|
+
baseline_tps = avg_tps
|
|
731
|
+
|
|
732
|
+
speedup = avg_tps / baseline_tps if baseline_tps > 0 else 1.0
|
|
733
|
+
|
|
734
|
+
table.add_row(
|
|
735
|
+
config_name,
|
|
736
|
+
f"{avg_tps:.1f}",
|
|
737
|
+
f"{min_tps:.1f}",
|
|
738
|
+
f"{max_tps:.1f}",
|
|
739
|
+
f"{avg_latency:.1f}s",
|
|
740
|
+
f"{speedup:.2f}x",
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
results_data["configs"][config_name] = {
|
|
744
|
+
"avg_tps": avg_tps,
|
|
745
|
+
"min_tps": min_tps,
|
|
746
|
+
"max_tps": max_tps,
|
|
747
|
+
"avg_latency_s": avg_latency,
|
|
748
|
+
"speedup": speedup,
|
|
749
|
+
"samples": len(all_tps),
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
console.print()
|
|
753
|
+
console.print(table)
|
|
754
|
+
console.print(
|
|
755
|
+
"[yellow]P3 audit:[/] optimization configs change reported stats only; "
|
|
756
|
+
"no optimization is applied to the backend call yet, so 'Speedup' is "
|
|
757
|
+
"noise until they are wired in.[/]"
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
opt_stats = engine.optimization_stats
|
|
761
|
+
if opt_stats:
|
|
762
|
+
console.print("\n[bold]Optimization Module Status:[/]")
|
|
763
|
+
if "layer_skip" in opt_stats:
|
|
764
|
+
ls = opt_stats["layer_skip"]
|
|
765
|
+
console.print(
|
|
766
|
+
f" Layer skip: {ls['layers_skipped']}/{ls['total_layers']} layers "
|
|
767
|
+
f"({ls['skip_rate']:.0%}) | ~{ls['expected_speedup']:.1f}x potential"
|
|
768
|
+
)
|
|
769
|
+
if "sparse_inference" in opt_stats:
|
|
770
|
+
si = opt_stats["sparse_inference"]
|
|
771
|
+
console.print(
|
|
772
|
+
f" Sparse inference: {si['overall_sparsity']:.0%} sparsity | "
|
|
773
|
+
f"~{si['estimated_speedup']:.1f}x potential"
|
|
774
|
+
)
|
|
775
|
+
console.print(f" Sparsity cache: {opt_stats['sparsity_cache']['hit_rate']:.0%} hit rate")
|
|
776
|
+
console.print(
|
|
777
|
+
f" Speculative decoder: draft_length={opt_stats['speculative']['draft_length']}"
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
if output:
|
|
781
|
+
results_data["optimization_stats"] = opt_stats
|
|
782
|
+
Path(output).write_text(json.dumps(results_data, indent=2))
|
|
783
|
+
console.print(f"\n[dim]Results saved to {output}[/]")
|
|
784
|
+
|
|
785
|
+
engine.unload()
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
@cli.command()
|
|
789
|
+
@click.option(
|
|
790
|
+
"--base-model", "-b", default="TinyLlama/TinyLlama-1.1B-Chat-v1.0", help="HuggingFace model ID"
|
|
791
|
+
)
|
|
792
|
+
@click.option("--data", "-d", required=True, help="Path to training data (jsonl/json/txt/csv)")
|
|
793
|
+
@click.option("--output", "-o", default="./eigencore-adapters", help="Output directory for adapter")
|
|
794
|
+
@click.option("--lora-rank", default=8, help="LoRA rank (4-64)")
|
|
795
|
+
@click.option("--epochs", default=100, help="Initial epochs per phase")
|
|
796
|
+
@click.option("--lr", default=2e-4, help="Learning rate")
|
|
797
|
+
def train(
|
|
798
|
+
base_model: str,
|
|
799
|
+
data: str,
|
|
800
|
+
output: str,
|
|
801
|
+
lora_rank: int,
|
|
802
|
+
epochs: int,
|
|
803
|
+
lr: float,
|
|
804
|
+
):
|
|
805
|
+
"""Fine-tune a model with QLoRA on CPU using adaptive epoch scaling."""
|
|
806
|
+
from eigencore.training.trainer import CPUTrainer, TrainingConfig
|
|
807
|
+
|
|
808
|
+
hw = profile_hardware()
|
|
809
|
+
|
|
810
|
+
config = TrainingConfig(
|
|
811
|
+
base_model=base_model,
|
|
812
|
+
dataset_path=data,
|
|
813
|
+
output_dir=output,
|
|
814
|
+
lora_rank=lora_rank,
|
|
815
|
+
initial_epochs=epochs,
|
|
816
|
+
learning_rate=lr,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
def on_epoch(metrics):
|
|
820
|
+
console.print(
|
|
821
|
+
f" Phase {metrics.phase} | Epoch {metrics.epoch} | "
|
|
822
|
+
f"Loss: {metrics.train_loss:.4f} | "
|
|
823
|
+
f"RAM: {metrics.memory_used_gb:.1f}GB | "
|
|
824
|
+
f"Time: {metrics.elapsed_seconds:.0f}s"
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
console.print(
|
|
828
|
+
Panel(
|
|
829
|
+
f"Base model: [cyan]{base_model}[/]\n"
|
|
830
|
+
f"Data: {data}\n"
|
|
831
|
+
f"LoRA rank: {lora_rank}\n"
|
|
832
|
+
f"Initial epochs: {epochs}\n"
|
|
833
|
+
f"Adaptive scaling: 1/4 consistency rule",
|
|
834
|
+
title="[bold]EigenCore Training",
|
|
835
|
+
border_style="cyan",
|
|
836
|
+
)
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
try:
|
|
840
|
+
trainer = CPUTrainer(config, hw, on_epoch_end=on_epoch)
|
|
841
|
+
result = trainer.train()
|
|
842
|
+
console.print()
|
|
843
|
+
console.print(
|
|
844
|
+
Panel(result.summary(), title="[bold green]Training Complete", border_style="green")
|
|
845
|
+
)
|
|
846
|
+
except ImportError as e:
|
|
847
|
+
console.print(f"\n[red]Missing dependencies:[/] {e}")
|
|
848
|
+
console.print("[dim]Install with: pip install eigencore[train][/]")
|
|
849
|
+
sys.exit(1)
|
|
850
|
+
except MemoryError as e:
|
|
851
|
+
console.print(f"\n[red]Memory error:[/] {e}")
|
|
852
|
+
sys.exit(1)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
if __name__ == "__main__":
|
|
856
|
+
cli()
|