zeroquantz 0.1.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.
- zeroquantz/__init__.py +14 -0
- zeroquantz/__main__.py +8 -0
- zeroquantz/agent/__init__.py +16 -0
- zeroquantz/agent/dispatcher.py +520 -0
- zeroquantz/agent/intents.py +46 -0
- zeroquantz/agent/parser.py +255 -0
- zeroquantz/benchmark/__init__.py +7 -0
- zeroquantz/benchmark/latency.py +66 -0
- zeroquantz/benchmark/memory.py +41 -0
- zeroquantz/benchmark/quality.py +38 -0
- zeroquantz/benchmark/runner.py +151 -0
- zeroquantz/cli/__init__.py +7 -0
- zeroquantz/cli/app.py +98 -0
- zeroquantz/cli/commands.py +459 -0
- zeroquantz/cli/interactive.py +56 -0
- zeroquantz/core/__init__.py +7 -0
- zeroquantz/core/artifacts.py +179 -0
- zeroquantz/core/context.py +127 -0
- zeroquantz/core/events.py +30 -0
- zeroquantz/core/exceptions.py +105 -0
- zeroquantz/core/session.py +202 -0
- zeroquantz/core/subenv.py +202 -0
- zeroquantz/deploy/__init__.py +25 -0
- zeroquantz/deploy/assets.py +161 -0
- zeroquantz/deploy/launcher.py +80 -0
- zeroquantz/deploy/runtime_env.py +66 -0
- zeroquantz/deploy/targets.py +154 -0
- zeroquantz/export/__init__.py +8 -0
- zeroquantz/export/exporter.py +68 -0
- zeroquantz/export/report.py +203 -0
- zeroquantz/hardware/__init__.py +15 -0
- zeroquantz/hardware/capabilities.py +152 -0
- zeroquantz/hardware/detector.py +200 -0
- zeroquantz/hardware/gpu.py +31 -0
- zeroquantz/models/__init__.py +8 -0
- zeroquantz/models/architecture.py +168 -0
- zeroquantz/models/downloader.py +161 -0
- zeroquantz/models/hf_auth.py +105 -0
- zeroquantz/models/inspector.py +249 -0
- zeroquantz/models/metadata.py +108 -0
- zeroquantz/models/search.py +71 -0
- zeroquantz/optimization/__init__.py +22 -0
- zeroquantz/optimization/candidate.py +272 -0
- zeroquantz/optimization/constraints.py +70 -0
- zeroquantz/optimization/fit.py +203 -0
- zeroquantz/optimization/pareto.py +66 -0
- zeroquantz/optimization/planner.py +297 -0
- zeroquantz/optimization/recommender.py +149 -0
- zeroquantz/profiling/__init__.py +18 -0
- zeroquantz/profiling/calibration.py +74 -0
- zeroquantz/profiling/sensitivity.py +234 -0
- zeroquantz/quantization/__init__.py +17 -0
- zeroquantz/quantization/backends/__init__.py +8 -0
- zeroquantz/quantization/backends/bitsandbytes.py +210 -0
- zeroquantz/quantization/backends/torchao.py +198 -0
- zeroquantz/quantization/base.py +136 -0
- zeroquantz/quantization/catalog.py +321 -0
- zeroquantz/quantization/config.py +106 -0
- zeroquantz/quantization/gguf_pipeline.py +210 -0
- zeroquantz/quantization/isolated.py +248 -0
- zeroquantz/quantization/memory.py +133 -0
- zeroquantz/quantization/native.py +91 -0
- zeroquantz/quantization/registry.py +101 -0
- zeroquantz/render.py +341 -0
- zeroquantz/runtimes/__init__.py +18 -0
- zeroquantz/runtimes/base.py +64 -0
- zeroquantz/runtimes/compatibility.py +91 -0
- zeroquantz/runtimes/registry.py +70 -0
- zeroquantz/runtimes/transformers.py +53 -0
- zeroquantz/runtimes/vllm.py +83 -0
- zeroquantz/tui/__init__.py +13 -0
- zeroquantz/tui/app.py +77 -0
- zeroquantz/tui/banner.py +47 -0
- zeroquantz/tui/screens/__init__.py +25 -0
- zeroquantz/tui/screens/confirm.py +41 -0
- zeroquantz/tui/screens/execute.py +194 -0
- zeroquantz/tui/screens/model_select.py +206 -0
- zeroquantz/tui/screens/plan.py +177 -0
- zeroquantz/tui/screens/quantize_select.py +272 -0
- zeroquantz/tui/screens/settings.py +219 -0
- zeroquantz/tui/screens/token.py +94 -0
- zeroquantz/tui/screens/welcome.py +128 -0
- zeroquantz/tui/screens/workspace.py +175 -0
- zeroquantz/tui/styles/app.tcss +424 -0
- zeroquantz/tui/widgets/__init__.py +9 -0
- zeroquantz/tui/widgets/chip.py +36 -0
- zeroquantz/tui/widgets/sidebar.py +107 -0
- zeroquantz/tui/widgets/status_bar.py +43 -0
- zeroquantz/utils/__init__.py +8 -0
- zeroquantz/utils/config.py +46 -0
- zeroquantz/utils/env.py +78 -0
- zeroquantz/utils/logging.py +73 -0
- zeroquantz/utils/metrics.py +98 -0
- zeroquantz/utils/paths.py +57 -0
- zeroquantz/utils/units.py +134 -0
- zeroquantz/verification/__init__.py +17 -0
- zeroquantz/verification/logits.py +55 -0
- zeroquantz/verification/report.py +186 -0
- zeroquantz/verification/weights.py +44 -0
- zeroquantz/version.py +8 -0
- zeroquantz-0.1.0.dist-info/METADATA +72 -0
- zeroquantz-0.1.0.dist-info/RECORD +105 -0
- zeroquantz-0.1.0.dist-info/WHEEL +4 -0
- zeroquantz-0.1.0.dist-info/entry_points.txt +2 -0
- zeroquantz-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Manage on-disk artifacts: downloaded models (the HF cache) and the quantized
|
|
2
|
+
models ZeroQuantz produces.
|
|
3
|
+
|
|
4
|
+
Downloaded models are read from / deleted via ``huggingface_hub``'s cache API.
|
|
5
|
+
Quantized outputs are tracked in a small registry (``~/.zeroquantz/quantized.json``)
|
|
6
|
+
that ZeroQuantz appends to whenever it exports/quantizes, so they can be listed,
|
|
7
|
+
opened, and removed later.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
from zeroquantz.utils.env import utc_timestamp
|
|
20
|
+
from zeroquantz.utils.logging import get_logger
|
|
21
|
+
from zeroquantz.utils.paths import paths
|
|
22
|
+
|
|
23
|
+
log = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---- downloaded models (Hugging Face cache) --------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DownloadedModel:
|
|
31
|
+
repo_id: str
|
|
32
|
+
size_bytes: int
|
|
33
|
+
nb_files: int
|
|
34
|
+
revisions: list[str]
|
|
35
|
+
path: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def list_downloaded_models() -> list[DownloadedModel]:
|
|
39
|
+
"""List model repos in the HF cache, largest first. Safe if the cache is empty."""
|
|
40
|
+
try:
|
|
41
|
+
from huggingface_hub import scan_cache_dir
|
|
42
|
+
except ImportError:
|
|
43
|
+
return []
|
|
44
|
+
try:
|
|
45
|
+
info = scan_cache_dir()
|
|
46
|
+
except Exception as exc: # pragma: no cover - cache-dependent
|
|
47
|
+
log.debug("scan_cache_dir failed: %s", exc)
|
|
48
|
+
return []
|
|
49
|
+
models: list[DownloadedModel] = []
|
|
50
|
+
for repo in info.repos:
|
|
51
|
+
if getattr(repo, "repo_type", "model") != "model":
|
|
52
|
+
continue
|
|
53
|
+
models.append(
|
|
54
|
+
DownloadedModel(
|
|
55
|
+
repo_id=repo.repo_id,
|
|
56
|
+
size_bytes=int(repo.size_on_disk),
|
|
57
|
+
nb_files=int(repo.nb_files),
|
|
58
|
+
revisions=[r.commit_hash for r in repo.revisions],
|
|
59
|
+
path=str(repo.repo_path),
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
models.sort(key=lambda m: m.size_bytes, reverse=True)
|
|
63
|
+
return models
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def delete_downloaded_model(repo_id: str) -> int:
|
|
67
|
+
"""Delete every cached revision of ``repo_id``. Returns bytes freed."""
|
|
68
|
+
from huggingface_hub import scan_cache_dir
|
|
69
|
+
|
|
70
|
+
info = scan_cache_dir()
|
|
71
|
+
hashes: list[str] = []
|
|
72
|
+
for repo in info.repos:
|
|
73
|
+
if repo.repo_id == repo_id and getattr(repo, "repo_type", "model") == "model":
|
|
74
|
+
hashes.extend(r.commit_hash for r in repo.revisions)
|
|
75
|
+
if not hashes:
|
|
76
|
+
return 0
|
|
77
|
+
strategy = info.delete_revisions(*hashes)
|
|
78
|
+
freed = int(strategy.expected_freed_size)
|
|
79
|
+
strategy.execute()
|
|
80
|
+
return freed
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---- quantized outputs (ZeroQuantz registry) --------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class QuantizedArtifact(BaseModel):
|
|
87
|
+
path: str
|
|
88
|
+
base_model: str | None = None
|
|
89
|
+
format_id: str | None = None
|
|
90
|
+
method: str | None = None
|
|
91
|
+
size_bytes: int = 0
|
|
92
|
+
created_at: str = Field(default_factory=utc_timestamp)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def exists(self) -> bool:
|
|
96
|
+
return Path(self.path).exists()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def dir_size(path: str | Path) -> int:
|
|
100
|
+
total = 0
|
|
101
|
+
root = Path(path)
|
|
102
|
+
if not root.exists():
|
|
103
|
+
return 0
|
|
104
|
+
for p in root.rglob("*"):
|
|
105
|
+
if p.is_file():
|
|
106
|
+
try:
|
|
107
|
+
total += p.stat().st_size
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
return total
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class QuantizedRegistry:
|
|
114
|
+
"""A JSON-backed registry of quantized model directories ZeroQuantz produced."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, file: Path | None = None) -> None:
|
|
117
|
+
self.file = file or (paths().ensure().home / "quantized.json")
|
|
118
|
+
|
|
119
|
+
def _load(self) -> list[QuantizedArtifact]:
|
|
120
|
+
if not self.file.exists():
|
|
121
|
+
return []
|
|
122
|
+
try:
|
|
123
|
+
data = json.loads(self.file.read_text(encoding="utf-8"))
|
|
124
|
+
return [QuantizedArtifact.model_validate(d) for d in data]
|
|
125
|
+
except (OSError, ValueError):
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
def _save(self, items: list[QuantizedArtifact]) -> None:
|
|
129
|
+
try:
|
|
130
|
+
self.file.write_text(
|
|
131
|
+
json.dumps([i.model_dump(mode="json") for i in items], indent=2),
|
|
132
|
+
encoding="utf-8",
|
|
133
|
+
)
|
|
134
|
+
except OSError as exc: # pragma: no cover
|
|
135
|
+
log.warning("could not save quantized registry: %s", exc)
|
|
136
|
+
|
|
137
|
+
def add(
|
|
138
|
+
self,
|
|
139
|
+
path: str,
|
|
140
|
+
*,
|
|
141
|
+
base_model: str | None = None,
|
|
142
|
+
format_id: str | None = None,
|
|
143
|
+
method: str | None = None,
|
|
144
|
+
) -> QuantizedArtifact:
|
|
145
|
+
items = [i for i in self._load() if Path(i.path) != Path(path)]
|
|
146
|
+
artifact = QuantizedArtifact(
|
|
147
|
+
path=str(Path(path).resolve()),
|
|
148
|
+
base_model=base_model,
|
|
149
|
+
format_id=format_id,
|
|
150
|
+
method=method,
|
|
151
|
+
size_bytes=dir_size(path),
|
|
152
|
+
)
|
|
153
|
+
items.append(artifact)
|
|
154
|
+
self._save(items)
|
|
155
|
+
return artifact
|
|
156
|
+
|
|
157
|
+
def list(self) -> list[QuantizedArtifact]:
|
|
158
|
+
"""List tracked artifacts, pruning any whose directory is gone."""
|
|
159
|
+
items = self._load()
|
|
160
|
+
live = [i for i in items if i.exists]
|
|
161
|
+
if len(live) != len(items):
|
|
162
|
+
self._save(live)
|
|
163
|
+
# refresh sizes cheaply from disk
|
|
164
|
+
for i in live:
|
|
165
|
+
i.size_bytes = dir_size(i.path)
|
|
166
|
+
live.sort(key=lambda i: i.created_at, reverse=True)
|
|
167
|
+
return live
|
|
168
|
+
|
|
169
|
+
def delete(self, path: str, *, remove_files: bool = True) -> int:
|
|
170
|
+
target = Path(path)
|
|
171
|
+
freed = dir_size(target)
|
|
172
|
+
if remove_files and target.exists():
|
|
173
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
174
|
+
self._save([i for i in self._load() if Path(i.path) != target])
|
|
175
|
+
return freed
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def default_quantized_registry() -> QuantizedRegistry:
|
|
179
|
+
return QuantizedRegistry()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""The application context — shared state for one interactive/CLI invocation.
|
|
2
|
+
|
|
3
|
+
Holds the detected hardware, the backend/runtime registries, the app config, and
|
|
4
|
+
the active :class:`Session`. Command handlers read and mutate this; the CLI and
|
|
5
|
+
TUI both build one and hand it to the dispatcher.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from zeroquantz.core.session import (
|
|
14
|
+
Session,
|
|
15
|
+
SessionRepository,
|
|
16
|
+
default_session_repository,
|
|
17
|
+
)
|
|
18
|
+
from zeroquantz.hardware.detector import HardwareDetector
|
|
19
|
+
from zeroquantz.quantization.registry import BackendRegistry, default_registry
|
|
20
|
+
from zeroquantz.runtimes.registry import RuntimeRegistry, default_runtime_registry
|
|
21
|
+
from zeroquantz.utils.config import AppConfig, load_config
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from zeroquantz.hardware.capabilities import HardwareProfile
|
|
25
|
+
from zeroquantz.optimization.constraints import OptimizationGoal
|
|
26
|
+
from zeroquantz.models.metadata import ModelProfile
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AppContext:
|
|
31
|
+
"""Everything a command needs to run."""
|
|
32
|
+
|
|
33
|
+
hardware: HardwareProfile
|
|
34
|
+
session: Session
|
|
35
|
+
repo: SessionRepository
|
|
36
|
+
backends: BackendRegistry
|
|
37
|
+
runtimes: RuntimeRegistry
|
|
38
|
+
config: AppConfig = field(default_factory=AppConfig)
|
|
39
|
+
|
|
40
|
+
# transient, per-invocation artifacts (never persisted)
|
|
41
|
+
candidates: list = field(default_factory=list)
|
|
42
|
+
sensitivity: object | None = None
|
|
43
|
+
last_quant_result: object | None = None
|
|
44
|
+
|
|
45
|
+
# ---- convenience accessors ---------------------------------------------
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def goal(self) -> OptimizationGoal:
|
|
49
|
+
return self.session.goal
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def model_profile(self) -> ModelProfile | None:
|
|
53
|
+
return self.session.model_profile
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def model_id(self) -> str | None:
|
|
57
|
+
return self.session.model_id
|
|
58
|
+
|
|
59
|
+
def save(self) -> None:
|
|
60
|
+
"""Persist the session if it is a named (non-scratch) one."""
|
|
61
|
+
if not self.session.name.startswith("scratch-"):
|
|
62
|
+
self.repo.save(self.session)
|
|
63
|
+
|
|
64
|
+
# ---- construction -------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def create(
|
|
68
|
+
cls,
|
|
69
|
+
*,
|
|
70
|
+
session_name: str | None = None,
|
|
71
|
+
continue_last: bool = False,
|
|
72
|
+
repo: SessionRepository | None = None,
|
|
73
|
+
config: AppConfig | None = None,
|
|
74
|
+
detect_hardware: bool = True,
|
|
75
|
+
) -> AppContext:
|
|
76
|
+
repo = repo or default_session_repository()
|
|
77
|
+
config = config or load_config()
|
|
78
|
+
|
|
79
|
+
session = cls._resolve_session(repo, session_name, continue_last)
|
|
80
|
+
|
|
81
|
+
# Apply config-level default runtime/objective to a fresh goal.
|
|
82
|
+
if session.goal.is_empty() and (config.default_runtime or config.objective):
|
|
83
|
+
from zeroquantz.optimization.constraints import Objective, OptimizationGoal
|
|
84
|
+
|
|
85
|
+
session.goal = OptimizationGoal(
|
|
86
|
+
runtime=config.default_runtime,
|
|
87
|
+
objective=Objective(config.objective),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
hardware = (
|
|
91
|
+
HardwareDetector.detect() if detect_hardware else _empty_hardware()
|
|
92
|
+
)
|
|
93
|
+
# Keep a hardware snapshot on the session for display + reports.
|
|
94
|
+
session.hardware = hardware
|
|
95
|
+
|
|
96
|
+
return cls(
|
|
97
|
+
hardware=hardware,
|
|
98
|
+
session=session,
|
|
99
|
+
repo=repo,
|
|
100
|
+
backends=default_registry(),
|
|
101
|
+
runtimes=default_runtime_registry(),
|
|
102
|
+
config=config,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _resolve_session(
|
|
107
|
+
repo: SessionRepository, session_name: str | None, continue_last: bool
|
|
108
|
+
) -> Session:
|
|
109
|
+
if continue_last:
|
|
110
|
+
latest = repo.latest()
|
|
111
|
+
return latest or Session.new("default")
|
|
112
|
+
if session_name:
|
|
113
|
+
if repo.exists(session_name):
|
|
114
|
+
return repo.load(session_name)
|
|
115
|
+
return Session.new(session_name)
|
|
116
|
+
# Ephemeral scratch session (not auto-saved).
|
|
117
|
+
import os
|
|
118
|
+
|
|
119
|
+
return Session.new(f"scratch-{os.getpid()}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _empty_hardware() -> HardwareProfile:
|
|
123
|
+
from zeroquantz.hardware.capabilities import HardwareProfile, derive_precision_support
|
|
124
|
+
|
|
125
|
+
return HardwareProfile(
|
|
126
|
+
precision_support=derive_precision_support(None, cuda_available=False)
|
|
127
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Lightweight progress reporting shared by long-running operations.
|
|
2
|
+
|
|
3
|
+
Operations accept an optional ``progress(stage: str, fraction: float)`` callback.
|
|
4
|
+
The CLI wires it to a Rich progress bar; the TUI to a widget; tests pass a
|
|
5
|
+
collector or nothing at all.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
ProgressCallback = Callable[[str, float], None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def null_progress(stage: str, fraction: float) -> None: # noqa: ARG001
|
|
16
|
+
"""A no-op progress sink."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProgressCollector:
|
|
20
|
+
"""Records progress updates — handy in tests and for replaying to a UI."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self.updates: list[tuple[str, float]] = []
|
|
24
|
+
|
|
25
|
+
def __call__(self, stage: str, fraction: float) -> None:
|
|
26
|
+
self.updates.append((stage, fraction))
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def last(self) -> tuple[str, float] | None:
|
|
30
|
+
return self.updates[-1] if self.updates else None
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""ZeroQuantz's exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Errors are meant to be *actionable*. Every :class:`ZeroQuantzError` carries an
|
|
4
|
+
optional ``detail`` paragraph and a list of concrete ``suggestions`` (commands to
|
|
5
|
+
run, extras to install). The CLI and TUI render these instead of a bare
|
|
6
|
+
traceback, turning "RuntimeError" into "here is exactly what went wrong and what
|
|
7
|
+
to try next".
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ZeroQuantzError(Exception):
|
|
16
|
+
"""Base class for all expected ZeroQuantz failures.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
message: One-line summary of what failed.
|
|
20
|
+
detail: Optional longer explanation (may span several lines).
|
|
21
|
+
suggestions: Concrete next steps, e.g. ``["/recommend", "pip install ..."]``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
message: str,
|
|
27
|
+
*,
|
|
28
|
+
detail: str | None = None,
|
|
29
|
+
suggestions: Sequence[str] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.message = message
|
|
33
|
+
self.detail = detail
|
|
34
|
+
self.suggestions: list[str] = list(suggestions or [])
|
|
35
|
+
|
|
36
|
+
def format(self) -> str:
|
|
37
|
+
"""Render a multi-line, human-readable representation."""
|
|
38
|
+
lines = [self.message]
|
|
39
|
+
if self.detail:
|
|
40
|
+
lines.append("")
|
|
41
|
+
lines.append(self.detail)
|
|
42
|
+
if self.suggestions:
|
|
43
|
+
lines.append("")
|
|
44
|
+
lines.append("Try:")
|
|
45
|
+
lines.extend(f" {s}" for s in self.suggestions)
|
|
46
|
+
return "\n".join(lines)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DependencyError(ZeroQuantzError):
|
|
50
|
+
"""A required optional dependency is not installed."""
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def for_extra(cls, package: str, extra: str, *, purpose: str) -> DependencyError:
|
|
54
|
+
return cls(
|
|
55
|
+
f"'{package}' is required to {purpose}, but it is not installed.",
|
|
56
|
+
detail=(
|
|
57
|
+
f"This capability lives behind the '{extra}' extra so that the base "
|
|
58
|
+
"install stays lightweight."
|
|
59
|
+
),
|
|
60
|
+
suggestions=[f"pip install 'zeroquantz[{extra}]'", f"pip install {package}"],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HardwareError(ZeroQuantzError):
|
|
65
|
+
"""Something about the detected hardware blocks the requested operation."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class UnsupportedConfigurationError(ZeroQuantzError):
|
|
69
|
+
"""A quantization/runtime configuration is not supported on this setup."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ModelInspectionError(ZeroQuantzError):
|
|
73
|
+
"""Model metadata could not be read."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ModelLoadError(ZeroQuantzError):
|
|
77
|
+
"""A model's weights could not be loaded."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class BackendUnavailableError(ZeroQuantzError):
|
|
81
|
+
"""A quantization backend was requested but is not available."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class QuantizationError(ZeroQuantzError):
|
|
85
|
+
"""Quantization failed to run."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class RuntimeCompatibilityError(ZeroQuantzError):
|
|
89
|
+
"""A quantized model is incompatible with the requested runtime."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class VerificationError(ZeroQuantzError):
|
|
93
|
+
"""Verification could not be completed."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SessionError(ZeroQuantzError):
|
|
97
|
+
"""A session could not be loaded, saved, or mutated."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ConfigError(ZeroQuantzError):
|
|
101
|
+
"""The on-disk configuration file is invalid."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CommandError(ZeroQuantzError):
|
|
105
|
+
"""An interactive command was malformed or could not be dispatched."""
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Interactive session state, persistence, and undo/history.
|
|
2
|
+
|
|
3
|
+
A :class:`Session` bundles the model, hardware snapshot, optimization goal, the
|
|
4
|
+
selected strategy/plan, and measured results, plus an immutable history of
|
|
5
|
+
configuration snapshots. Undo/checkout operate on that history (config/session
|
|
6
|
+
state only — never on multi-hour quantization artifacts). Persistence goes
|
|
7
|
+
through a :class:`SessionRepository` abstraction so the JSON store can be swapped
|
|
8
|
+
later.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
from zeroquantz.benchmark.runner import BenchmarkResult
|
|
20
|
+
from zeroquantz.core.exceptions import SessionError
|
|
21
|
+
from zeroquantz.hardware.capabilities import HardwareProfile
|
|
22
|
+
from zeroquantz.models.metadata import ModelProfile
|
|
23
|
+
from zeroquantz.optimization.constraints import OptimizationGoal
|
|
24
|
+
from zeroquantz.optimization.planner import MixedPrecisionPlan
|
|
25
|
+
from zeroquantz.utils.env import utc_timestamp
|
|
26
|
+
from zeroquantz.utils.paths import paths
|
|
27
|
+
from zeroquantz.verification.report import VerificationReport
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SessionSnapshot(BaseModel):
|
|
31
|
+
"""Immutable record of the mutable config state at a point in time."""
|
|
32
|
+
|
|
33
|
+
label: str
|
|
34
|
+
timestamp: str = Field(default_factory=utc_timestamp)
|
|
35
|
+
model_id: str | None = None
|
|
36
|
+
goal: OptimizationGoal = Field(default_factory=OptimizationGoal)
|
|
37
|
+
selected_method: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Session(BaseModel):
|
|
41
|
+
"""A named, persistable interactive session."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
created_at: str = Field(default_factory=utc_timestamp)
|
|
45
|
+
updated_at: str = Field(default_factory=utc_timestamp)
|
|
46
|
+
|
|
47
|
+
model_id: str | None = None
|
|
48
|
+
goal: OptimizationGoal = Field(default_factory=OptimizationGoal)
|
|
49
|
+
selected_method: str | None = None
|
|
50
|
+
selected_format_id: str | None = None
|
|
51
|
+
model_local_path: str | None = None
|
|
52
|
+
|
|
53
|
+
# cached artifacts (recomputed lazily where possible)
|
|
54
|
+
model_profile: ModelProfile | None = None
|
|
55
|
+
hardware: HardwareProfile | None = None
|
|
56
|
+
plan: MixedPrecisionPlan | None = None
|
|
57
|
+
last_benchmark: BenchmarkResult | None = None
|
|
58
|
+
last_verification: VerificationReport | None = None
|
|
59
|
+
|
|
60
|
+
operation_log: list[str] = Field(default_factory=list)
|
|
61
|
+
history: list[SessionSnapshot] = Field(default_factory=list)
|
|
62
|
+
head: int = 0
|
|
63
|
+
|
|
64
|
+
# ---- construction -------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def new(cls, name: str) -> Session:
|
|
68
|
+
session = cls(name=name)
|
|
69
|
+
session.history = [SessionSnapshot(label="baseline")]
|
|
70
|
+
session.head = 0
|
|
71
|
+
return session
|
|
72
|
+
|
|
73
|
+
# ---- history / undo -----------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def record(self, label: str) -> None:
|
|
76
|
+
"""Push a new snapshot of the current config state and log the operation."""
|
|
77
|
+
# Truncate any redo tail before appending (new branch of history).
|
|
78
|
+
self.history = self.history[: self.head + 1]
|
|
79
|
+
self.history.append(
|
|
80
|
+
SessionSnapshot(
|
|
81
|
+
label=label,
|
|
82
|
+
model_id=self.model_id,
|
|
83
|
+
goal=self.goal,
|
|
84
|
+
selected_method=self.selected_method,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
self.head = len(self.history) - 1
|
|
88
|
+
self.operation_log.append(label)
|
|
89
|
+
self.updated_at = utc_timestamp()
|
|
90
|
+
|
|
91
|
+
def undo(self) -> str | None:
|
|
92
|
+
"""Revert to the previous snapshot. Returns the label reverted *from*."""
|
|
93
|
+
if self.head <= 0:
|
|
94
|
+
return None
|
|
95
|
+
reverted_from = self.history[self.head].label
|
|
96
|
+
self.head -= 1
|
|
97
|
+
self._apply(self.history[self.head])
|
|
98
|
+
self.updated_at = utc_timestamp()
|
|
99
|
+
return reverted_from
|
|
100
|
+
|
|
101
|
+
def checkout(self, index: int) -> SessionSnapshot:
|
|
102
|
+
if not 0 <= index < len(self.history):
|
|
103
|
+
raise SessionError(
|
|
104
|
+
f"No history entry {index}.",
|
|
105
|
+
detail=f"Valid range is 0..{len(self.history) - 1}.",
|
|
106
|
+
suggestions=["/history"],
|
|
107
|
+
)
|
|
108
|
+
self.head = index
|
|
109
|
+
self._apply(self.history[index])
|
|
110
|
+
self.updated_at = utc_timestamp()
|
|
111
|
+
return self.history[index]
|
|
112
|
+
|
|
113
|
+
def _apply(self, snapshot: SessionSnapshot) -> None:
|
|
114
|
+
self.model_id = snapshot.model_id
|
|
115
|
+
self.goal = snapshot.goal
|
|
116
|
+
self.selected_method = snapshot.selected_method
|
|
117
|
+
|
|
118
|
+
def history_view(self) -> list[tuple[int, str, bool]]:
|
|
119
|
+
return [(i, s.label, i == self.head) for i, s in enumerate(self.history)]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---- persistence -----------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SessionRepository(ABC):
|
|
126
|
+
"""Storage abstraction for sessions (JSON today, could be SQLite later)."""
|
|
127
|
+
|
|
128
|
+
@abstractmethod
|
|
129
|
+
def save(self, session: Session) -> None: ...
|
|
130
|
+
|
|
131
|
+
@abstractmethod
|
|
132
|
+
def load(self, name: str) -> Session: ...
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
def list(self) -> list[str]: ...
|
|
136
|
+
|
|
137
|
+
@abstractmethod
|
|
138
|
+
def exists(self, name: str) -> bool: ...
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
def delete(self, name: str) -> None: ...
|
|
142
|
+
|
|
143
|
+
@abstractmethod
|
|
144
|
+
def latest(self) -> Session | None: ...
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class JsonSessionRepository(SessionRepository):
|
|
148
|
+
"""Stores each session as ``<dir>/<name>.json``."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, directory: Path | None = None) -> None:
|
|
151
|
+
self.directory = directory or paths().ensure().sessions
|
|
152
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
|
|
154
|
+
def _path(self, name: str) -> Path:
|
|
155
|
+
safe = "".join(c for c in name if c.isalnum() or c in "-_.") or "session"
|
|
156
|
+
return self.directory / f"{safe}.json"
|
|
157
|
+
|
|
158
|
+
def save(self, session: Session) -> None:
|
|
159
|
+
session.updated_at = utc_timestamp()
|
|
160
|
+
try:
|
|
161
|
+
self._path(session.name).write_text(
|
|
162
|
+
json.dumps(session.model_dump(mode="json"), indent=2), encoding="utf-8"
|
|
163
|
+
)
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
raise SessionError(
|
|
166
|
+
f"Could not save session '{session.name}'.", detail=str(exc)
|
|
167
|
+
) from exc
|
|
168
|
+
|
|
169
|
+
def load(self, name: str) -> Session:
|
|
170
|
+
path = self._path(name)
|
|
171
|
+
if not path.exists():
|
|
172
|
+
raise SessionError(
|
|
173
|
+
f"No saved session named '{name}'.",
|
|
174
|
+
suggestions=["/sessions"],
|
|
175
|
+
)
|
|
176
|
+
try:
|
|
177
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
178
|
+
return Session.model_validate(data)
|
|
179
|
+
except (OSError, ValueError) as exc:
|
|
180
|
+
raise SessionError(
|
|
181
|
+
f"Session '{name}' is corrupted or unreadable.", detail=str(exc)
|
|
182
|
+
) from exc
|
|
183
|
+
|
|
184
|
+
def list(self) -> list[str]:
|
|
185
|
+
return sorted(p.stem for p in self.directory.glob("*.json"))
|
|
186
|
+
|
|
187
|
+
def exists(self, name: str) -> bool:
|
|
188
|
+
return self._path(name).exists()
|
|
189
|
+
|
|
190
|
+
def delete(self, name: str) -> None:
|
|
191
|
+
self._path(name).unlink(missing_ok=True)
|
|
192
|
+
|
|
193
|
+
def latest(self) -> Session | None:
|
|
194
|
+
files = list(self.directory.glob("*.json"))
|
|
195
|
+
if not files:
|
|
196
|
+
return None
|
|
197
|
+
newest = max(files, key=lambda p: p.stat().st_mtime)
|
|
198
|
+
return self.load(newest.stem)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def default_session_repository() -> SessionRepository:
|
|
202
|
+
return JsonSessionRepository()
|