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.
Files changed (105) hide show
  1. zeroquantz/__init__.py +14 -0
  2. zeroquantz/__main__.py +8 -0
  3. zeroquantz/agent/__init__.py +16 -0
  4. zeroquantz/agent/dispatcher.py +520 -0
  5. zeroquantz/agent/intents.py +46 -0
  6. zeroquantz/agent/parser.py +255 -0
  7. zeroquantz/benchmark/__init__.py +7 -0
  8. zeroquantz/benchmark/latency.py +66 -0
  9. zeroquantz/benchmark/memory.py +41 -0
  10. zeroquantz/benchmark/quality.py +38 -0
  11. zeroquantz/benchmark/runner.py +151 -0
  12. zeroquantz/cli/__init__.py +7 -0
  13. zeroquantz/cli/app.py +98 -0
  14. zeroquantz/cli/commands.py +459 -0
  15. zeroquantz/cli/interactive.py +56 -0
  16. zeroquantz/core/__init__.py +7 -0
  17. zeroquantz/core/artifacts.py +179 -0
  18. zeroquantz/core/context.py +127 -0
  19. zeroquantz/core/events.py +30 -0
  20. zeroquantz/core/exceptions.py +105 -0
  21. zeroquantz/core/session.py +202 -0
  22. zeroquantz/core/subenv.py +202 -0
  23. zeroquantz/deploy/__init__.py +25 -0
  24. zeroquantz/deploy/assets.py +161 -0
  25. zeroquantz/deploy/launcher.py +80 -0
  26. zeroquantz/deploy/runtime_env.py +66 -0
  27. zeroquantz/deploy/targets.py +154 -0
  28. zeroquantz/export/__init__.py +8 -0
  29. zeroquantz/export/exporter.py +68 -0
  30. zeroquantz/export/report.py +203 -0
  31. zeroquantz/hardware/__init__.py +15 -0
  32. zeroquantz/hardware/capabilities.py +152 -0
  33. zeroquantz/hardware/detector.py +200 -0
  34. zeroquantz/hardware/gpu.py +31 -0
  35. zeroquantz/models/__init__.py +8 -0
  36. zeroquantz/models/architecture.py +168 -0
  37. zeroquantz/models/downloader.py +161 -0
  38. zeroquantz/models/hf_auth.py +105 -0
  39. zeroquantz/models/inspector.py +249 -0
  40. zeroquantz/models/metadata.py +108 -0
  41. zeroquantz/models/search.py +71 -0
  42. zeroquantz/optimization/__init__.py +22 -0
  43. zeroquantz/optimization/candidate.py +272 -0
  44. zeroquantz/optimization/constraints.py +70 -0
  45. zeroquantz/optimization/fit.py +203 -0
  46. zeroquantz/optimization/pareto.py +66 -0
  47. zeroquantz/optimization/planner.py +297 -0
  48. zeroquantz/optimization/recommender.py +149 -0
  49. zeroquantz/profiling/__init__.py +18 -0
  50. zeroquantz/profiling/calibration.py +74 -0
  51. zeroquantz/profiling/sensitivity.py +234 -0
  52. zeroquantz/quantization/__init__.py +17 -0
  53. zeroquantz/quantization/backends/__init__.py +8 -0
  54. zeroquantz/quantization/backends/bitsandbytes.py +210 -0
  55. zeroquantz/quantization/backends/torchao.py +198 -0
  56. zeroquantz/quantization/base.py +136 -0
  57. zeroquantz/quantization/catalog.py +321 -0
  58. zeroquantz/quantization/config.py +106 -0
  59. zeroquantz/quantization/gguf_pipeline.py +210 -0
  60. zeroquantz/quantization/isolated.py +248 -0
  61. zeroquantz/quantization/memory.py +133 -0
  62. zeroquantz/quantization/native.py +91 -0
  63. zeroquantz/quantization/registry.py +101 -0
  64. zeroquantz/render.py +341 -0
  65. zeroquantz/runtimes/__init__.py +18 -0
  66. zeroquantz/runtimes/base.py +64 -0
  67. zeroquantz/runtimes/compatibility.py +91 -0
  68. zeroquantz/runtimes/registry.py +70 -0
  69. zeroquantz/runtimes/transformers.py +53 -0
  70. zeroquantz/runtimes/vllm.py +83 -0
  71. zeroquantz/tui/__init__.py +13 -0
  72. zeroquantz/tui/app.py +77 -0
  73. zeroquantz/tui/banner.py +47 -0
  74. zeroquantz/tui/screens/__init__.py +25 -0
  75. zeroquantz/tui/screens/confirm.py +41 -0
  76. zeroquantz/tui/screens/execute.py +194 -0
  77. zeroquantz/tui/screens/model_select.py +206 -0
  78. zeroquantz/tui/screens/plan.py +177 -0
  79. zeroquantz/tui/screens/quantize_select.py +272 -0
  80. zeroquantz/tui/screens/settings.py +219 -0
  81. zeroquantz/tui/screens/token.py +94 -0
  82. zeroquantz/tui/screens/welcome.py +128 -0
  83. zeroquantz/tui/screens/workspace.py +175 -0
  84. zeroquantz/tui/styles/app.tcss +424 -0
  85. zeroquantz/tui/widgets/__init__.py +9 -0
  86. zeroquantz/tui/widgets/chip.py +36 -0
  87. zeroquantz/tui/widgets/sidebar.py +107 -0
  88. zeroquantz/tui/widgets/status_bar.py +43 -0
  89. zeroquantz/utils/__init__.py +8 -0
  90. zeroquantz/utils/config.py +46 -0
  91. zeroquantz/utils/env.py +78 -0
  92. zeroquantz/utils/logging.py +73 -0
  93. zeroquantz/utils/metrics.py +98 -0
  94. zeroquantz/utils/paths.py +57 -0
  95. zeroquantz/utils/units.py +134 -0
  96. zeroquantz/verification/__init__.py +17 -0
  97. zeroquantz/verification/logits.py +55 -0
  98. zeroquantz/verification/report.py +186 -0
  99. zeroquantz/verification/weights.py +44 -0
  100. zeroquantz/version.py +8 -0
  101. zeroquantz-0.1.0.dist-info/METADATA +72 -0
  102. zeroquantz-0.1.0.dist-info/RECORD +105 -0
  103. zeroquantz-0.1.0.dist-info/WHEEL +4 -0
  104. zeroquantz-0.1.0.dist-info/entry_points.txt +2 -0
  105. 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()