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,206 @@
1
+ """Wizard entry: search for a model and inspect it (metadata only).
2
+
3
+ As the user types, the Hub is searched live (debounced) and results are shown
4
+ ranked by popularity. Selecting one (↑↓ + Enter, or a mouse click) inspects it
5
+ and advances straight to the quantization step — the model's details are shown
6
+ there, so this screen stays a clean search view.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from rich.text import Text
12
+ from textual import on, work
13
+ from textual.app import ComposeResult
14
+ from textual.binding import Binding
15
+ from textual.containers import Vertical
16
+ from textual.screen import Screen
17
+ from textual.widgets import Footer, Input, OptionList, Static
18
+ from textual.widgets.option_list import Option
19
+
20
+ from zeroquantz.agent.intents import Intent, IntentKind
21
+ from zeroquantz.models.search import ModelHit, search_models
22
+
23
+ _SUGGESTIONS: tuple[tuple[str, str], ...] = (
24
+ ("Qwen/Qwen3-8B", "8.2B · dense · GQA"),
25
+ ("Qwen/Qwen2.5-0.5B-Instruct", "0.5B · tiny, fast to inspect"),
26
+ ("meta-llama/Llama-3.1-8B-Instruct", "8B · gated repo"),
27
+ ("mistralai/Mistral-7B-Instruct-v0.3", "7B · popular baseline"),
28
+ ("microsoft/Phi-3.5-mini-instruct", "3.8B · small, strong"),
29
+ )
30
+
31
+ _DEBOUNCE_SECONDS = 0.35
32
+ _MIN_QUERY = 2
33
+
34
+
35
+ class ModelSelectScreen(Screen):
36
+ BINDINGS = [Binding("escape", "back", "Back")]
37
+
38
+ def compose(self) -> ComposeResult:
39
+ with Vertical(id="model-page"):
40
+ yield Static(
41
+ "Search a model to optimize · ↑↓ to move · Enter or click to load",
42
+ classes="page-subtitle",
43
+ )
44
+ yield Input(placeholder="search models… e.g. qwen3, llama, phi", id="model-input")
45
+ yield Static("", id="search-status", classes="page-hint")
46
+ yield OptionList(id="model-suggestions")
47
+ yield Footer()
48
+
49
+ def on_mount(self) -> None:
50
+ self._debounce = None
51
+ self._pending_query = ""
52
+ self._suppress_search = False
53
+ self._loading = False
54
+ self._show_defaults()
55
+ self.query_one("#model-input", Input).focus()
56
+
57
+ def on_screen_resume(self) -> None:
58
+ # Returning from the quantization step: clear the transient "inspecting…"
59
+ # status and restore focus for continued searching.
60
+ self._loading = False
61
+ count = self.query_one("#model-suggestions", OptionList).option_count
62
+ self.query_one("#search-status", Static).update(
63
+ Text(f"{count} models" if count else "", style="#6e7681")
64
+ )
65
+ self.query_one("#model-input", Input).focus()
66
+
67
+ # ---- live search --------------------------------------------------------
68
+
69
+ @on(Input.Changed, "#model-input")
70
+ def _changed(self, event: Input.Changed) -> None:
71
+ if self._suppress_search:
72
+ self._suppress_search = False
73
+ return
74
+ self._pending_query = event.value.strip()
75
+ if self._debounce is not None:
76
+ self._debounce.stop()
77
+ self._debounce = self.set_timer(_DEBOUNCE_SECONDS, self._fire_search)
78
+
79
+ def _fire_search(self) -> None:
80
+ query = self._pending_query
81
+ if len(query) < _MIN_QUERY:
82
+ self._show_defaults()
83
+ return
84
+ self.query_one("#search-status", Static).update(Text(f"searching “{query}” …", style="#d29922"))
85
+ self._search(query)
86
+
87
+ @work(thread=True, exclusive=True, group="model-search")
88
+ def _search(self, query: str) -> None:
89
+ try:
90
+ hits: list[ModelHit] | None = search_models(query, limit=20)
91
+ except Exception:
92
+ hits = None
93
+ self.app.call_from_thread(self._populate, query, hits)
94
+
95
+ def _populate(self, query: str, hits: list[ModelHit] | None) -> None:
96
+ if self.query_one("#model-input", Input).value.strip() != query:
97
+ return
98
+ options = self.query_one("#model-suggestions", OptionList)
99
+ status = self.query_one("#search-status", Static)
100
+ if hits is None:
101
+ status.update(Text("offline — showing common models", style="#d29922"))
102
+ self._show_defaults()
103
+ return
104
+ if not hits:
105
+ status.update(Text("no matching models", style="#6e7681"))
106
+ options.clear_options()
107
+ return
108
+ status.update(Text(f"{len(hits)} models — most downloaded first", style="#6e7681"))
109
+ options.clear_options()
110
+ for hit in hits:
111
+ text = Text()
112
+ text.append("● ", style="#3fb950")
113
+ text.append(hit.id, style="bold #58a6ff")
114
+ meta = hit.meta_line()
115
+ if meta:
116
+ text.append(f" {meta}", style="#6e7681")
117
+ options.add_option(Option(text, id=hit.id))
118
+
119
+ def _show_defaults(self) -> None:
120
+ options = self.query_one("#model-suggestions", OptionList)
121
+ options.clear_options()
122
+ for model_id, note in _SUGGESTIONS:
123
+ text = Text()
124
+ text.append("● ", style="#3fb950")
125
+ text.append(model_id, style="bold #58a6ff")
126
+ text.append(f" {note}", style="#6e7681")
127
+ options.add_option(Option(text, id=model_id))
128
+
129
+ # ---- navigation & selection --------------------------------------------
130
+
131
+ def on_key(self, event) -> None: # noqa: ANN001
132
+ """Drive the results list with ↑/↓ while the search box keeps focus."""
133
+ if self._loading:
134
+ return
135
+ if not self.query_one("#model-input", Input).has_focus:
136
+ return
137
+ if event.key == "down":
138
+ self._move_highlight(1)
139
+ event.stop()
140
+ event.prevent_default()
141
+ elif event.key == "up":
142
+ self._move_highlight(-1)
143
+ event.stop()
144
+ event.prevent_default()
145
+
146
+ def _move_highlight(self, delta: int) -> None:
147
+ options = self.query_one("#model-suggestions", OptionList)
148
+ count = options.option_count
149
+ if count == 0:
150
+ return
151
+ current = options.highlighted
152
+ nxt = 0 if current is None else max(0, min(count - 1, current + delta))
153
+ options.highlighted = nxt
154
+ try:
155
+ options.scroll_to_highlight()
156
+ except Exception:
157
+ pass
158
+
159
+ @on(Input.Submitted, "#model-input")
160
+ def _submit(self, event: Input.Submitted) -> None:
161
+ options = self.query_one("#model-suggestions", OptionList)
162
+ model_id: str | None = None
163
+ if options.highlighted is not None and options.option_count:
164
+ model_id = str(options.get_option_at_index(options.highlighted).id)
165
+ if not model_id:
166
+ model_id = event.value.strip()
167
+ if model_id:
168
+ self._load(model_id)
169
+
170
+ @on(OptionList.OptionSelected, "#model-suggestions")
171
+ def _pick(self, event: OptionList.OptionSelected) -> None:
172
+ model_id = str(event.option.id)
173
+ self._suppress_search = True
174
+ self.query_one("#model-input", Input).value = model_id
175
+ if self._debounce is not None:
176
+ self._debounce.stop()
177
+ self._load(model_id)
178
+
179
+ # ---- loading ------------------------------------------------------------
180
+
181
+ def _load(self, model_id: str) -> None:
182
+ if self._loading:
183
+ return
184
+ self._loading = True
185
+ self.query_one("#search-status", Static).update(
186
+ Text(f"inspecting {model_id} … (metadata only, no weights downloaded)", style="#d29922")
187
+ )
188
+ self._inspect(model_id)
189
+
190
+ @work(thread=True, exclusive=True, group="model-inspect")
191
+ def _inspect(self, model_id: str) -> None:
192
+ result = self.app.dispatcher.dispatch(
193
+ Intent(IntentKind.LOAD_MODEL, {"model_id": model_id}), self.app.ctx
194
+ )
195
+ self.app.call_from_thread(self._after_inspect, result)
196
+
197
+ def _after_inspect(self, result) -> None: # noqa: ANN001
198
+ self._loading = False
199
+ if result.success:
200
+ self.app.push_quantize_select() # decide the plan first; download happens in Execute
201
+ else:
202
+ message = (result.message or "could not load model").splitlines()[0]
203
+ self.query_one("#search-status", Static).update(Text(f"✗ {message}", style="#f85149"))
204
+
205
+ def action_back(self) -> None:
206
+ self.app.pop_screen()
@@ -0,0 +1,177 @@
1
+ """Plan screen: review the full decision (model + quantization + deploy target)
2
+ before executing. Choosing everything up front, then Execute runs
3
+ download → quantize → deploy.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from rich.console import Group
9
+ from rich.panel import Panel
10
+ from rich.text import Text
11
+ from textual import on
12
+ from textual.app import ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Horizontal, Vertical, VerticalScroll
15
+ from textual.screen import Screen
16
+ from textual.widgets import Footer, Static
17
+
18
+ from zeroquantz.deploy import deploy_targets_for, get_target
19
+ from zeroquantz.optimization.planner import MixedPrecisionPlanner
20
+ from zeroquantz.quantization.catalog import get_format
21
+ from zeroquantz.render import render_plan
22
+ from zeroquantz.tui.widgets import Chip
23
+
24
+
25
+ class PlanScreen(Screen):
26
+ BINDINGS = [
27
+ Binding("escape", "back", "Back"),
28
+ Binding("enter", "execute", "Execute"),
29
+ Binding("left", "target_prev", "deploy target", priority=True),
30
+ Binding("right", "target_next", "deploy target", priority=True, show=False),
31
+ Binding("r", "save_assets", "Save plan"),
32
+ Binding("h", "home", "Home"),
33
+ ]
34
+
35
+ def compose(self) -> ComposeResult:
36
+ with Vertical(id="plan-page"):
37
+ with Horizontal(classes="ctl-row"):
38
+ yield Static("Deploy to", classes="ctl-label")
39
+ with Horizontal(id="deploy-chips", classes="ctl-row"):
40
+ yield Static("", id="deploy-chip-holder")
41
+ with VerticalScroll(id="plan-scroll"):
42
+ yield Static("", id="plan-body")
43
+ yield Static("", id="plan-status", classes="page-hint")
44
+ yield Footer()
45
+
46
+ def on_mount(self) -> None:
47
+ candidate = self.app.selected_candidate
48
+ fmt = get_format(candidate.format_id) if candidate else None
49
+ targets = deploy_targets_for(fmt) if fmt else []
50
+ self._targets = targets
51
+ # default deploy target: the goal runtime if compatible, else the first.
52
+ goal_rt = self.app.ctx.session.goal.runtime
53
+ default = next((t for t in targets if t.name == goal_rt), targets[0] if targets else None)
54
+ self.app.deploy_target = default.name if default else None
55
+ holder = self.query_one("#deploy-chips", Horizontal)
56
+ holder.remove_children()
57
+ for t in targets:
58
+ holder.mount(Chip(t.label, group="deploy", value=t.name,
59
+ selected=t.name == self.app.deploy_target))
60
+ self._refresh_body()
61
+
62
+ # ---- deploy target selection -------------------------------------------
63
+
64
+ @on(Chip.Selected)
65
+ def _chip(self, event: Chip.Selected) -> None:
66
+ if event.group == "deploy":
67
+ self.app.deploy_target = event.value
68
+ for chip in self.query(Chip):
69
+ chip.set_selected(chip.chip_value == event.value)
70
+ self._refresh_body()
71
+
72
+ def action_target_next(self) -> None:
73
+ self._cycle_target(1)
74
+
75
+ def action_target_prev(self) -> None:
76
+ self._cycle_target(-1)
77
+
78
+ def _cycle_target(self, direction: int) -> None:
79
+ if not self._targets:
80
+ return
81
+ names = [t.name for t in self._targets]
82
+ idx = names.index(self.app.deploy_target) if self.app.deploy_target in names else 0
83
+ self.app.deploy_target = names[(idx + direction) % len(names)]
84
+ for chip in self.query(Chip):
85
+ chip.set_selected(chip.chip_value == self.app.deploy_target)
86
+ self._refresh_body()
87
+
88
+ # ---- body ---------------------------------------------------------------
89
+
90
+ def _refresh_body(self) -> None:
91
+ candidate = self.app.selected_candidate
92
+ ctx = self.app.ctx
93
+ model = ctx.session.model_profile
94
+ if candidate is None or model is None:
95
+ self.query_one("#plan-body", Static).update(Text("Nothing selected.", style="#d29922"))
96
+ return
97
+ fmt = get_format(candidate.format_id)
98
+ target = get_target(self.app.deploy_target) if self.app.deploy_target else None
99
+
100
+ fit_ok = candidate.fits_memory
101
+ header = Text()
102
+ header.append(f"{model.model_id} · {candidate.label}\n", style="bold #58a6ff")
103
+ header.append(
104
+ f"{candidate.estimated_size_gb:.1f} GB · {candidate.estimated_vram_gb:.1f} GB VRAM · "
105
+ f"quality {candidate.quality_risk_label} · {candidate.estimated_speedup:.1f}x · "
106
+ f"{candidate.calibration} calibration",
107
+ style="#8b949e",
108
+ )
109
+ header.append(" ")
110
+ header.append("✓ fits" if fit_ok else "✗ over budget", style="#3fb950" if fit_ok else "#f85149")
111
+ parts = [Panel(header, title="[b]Quantization[/b]",
112
+ border_style="#3fb950" if fit_ok else "#d29922")]
113
+
114
+ if target is not None:
115
+ compatible = target.name in fmt.runtimes
116
+ dep = Text()
117
+ dep.append(f"{target.label}", style="bold #58a6ff")
118
+ dep.append(" ")
119
+ dep.append("✓ compatible" if compatible else "✗ not supported for this format",
120
+ style="#3fb950" if compatible else "#f85149")
121
+ dep.append(f"\n{target.note}\n\n", style="#8b949e")
122
+ dep.append("Serve command\n", style="#8b949e")
123
+ dep.append(f" {target.serve_command(_deploy_path(ctx, model), fmt)}", style="#3fb950")
124
+ parts.append(Panel(dep, title="[b]Deploy target[/b]",
125
+ border_style="#3fb950" if compatible else "#f85149"))
126
+
127
+ if candidate.method == "mixed":
128
+ plan = MixedPrecisionPlanner.plan(model, ctx.hardware, ctx.session.goal, ctx.sensitivity)
129
+ ctx.session.plan = plan
130
+ parts.append(render_plan(plan))
131
+
132
+ self.query_one("#plan-body", Static).update(Group(*parts))
133
+ self.query_one("#plan-status", Static).update(
134
+ Text("Enter: execute (download → quantize → deploy) · r: save plan & assets only",
135
+ style="#6e7681")
136
+ )
137
+
138
+ # ---- actions ------------------------------------------------------------
139
+
140
+ def action_execute(self) -> None:
141
+ self.app.push_execute()
142
+
143
+ def action_save_assets(self) -> None:
144
+ from pathlib import Path
145
+
146
+ from zeroquantz.deploy import generate_assets
147
+ from zeroquantz.export.exporter import Exporter
148
+ from zeroquantz.export.report import build_report
149
+
150
+ ctx = self.app.ctx
151
+ candidate = self.app.selected_candidate
152
+ model = ctx.session.model_profile
153
+ fmt = get_format(candidate.format_id)
154
+ target = get_target(self.app.deploy_target)
155
+ name = (ctx.session.model_id or "model").split("/")[-1]
156
+ out = Path.cwd() / f"zeroquantz-{name}-{candidate.method}"
157
+ status = self.query_one("#plan-status", Static)
158
+ try:
159
+ report = build_report(model, config=candidate.config, hardware=ctx.hardware,
160
+ goal=ctx.session.goal,
161
+ plan=ctx.session.plan if candidate.method == "mixed" else None)
162
+ Exporter.write_report(report, out)
163
+ bundle = generate_assets(_deploy_path(ctx, model), fmt, target, out / "deploy")
164
+ status.update(Text(f"✓ Plan + deploy assets written to {out}", style="#3fb950"))
165
+ except Exception as exc: # pragma: no cover
166
+ status.update(Text(f"✗ {exc}", style="#f85149"))
167
+
168
+ def action_back(self) -> None:
169
+ self.app.pop_screen()
170
+
171
+ def action_home(self) -> None:
172
+ self.app.go_home()
173
+
174
+
175
+ def _deploy_path(ctx, model) -> str: # noqa: ANN001
176
+ """Path to reference in deploy assets: local quantized dir, else the model id."""
177
+ return ctx.session.model_local_path or ctx.session.model_id or model.model_id
@@ -0,0 +1,272 @@
1
+ """Wizard step 2: pick a quantization strategy from the full catalog.
2
+
3
+ Constraints (budget, context, runtime, objective, filter) are inline **segmented
4
+ chips** above the table — every option is visible; click one (or focus it and
5
+ press Enter) to change it. The fit column, max-context, and ranking update live.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from rich.text import Text
11
+ from textual import on
12
+ from textual.app import ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Horizontal, Vertical
15
+ from textual.screen import Screen
16
+ from textual.widgets import DataTable, Footer, Static
17
+
18
+ from zeroquantz.optimization.candidate import CandidateGenerator
19
+ from zeroquantz.optimization.constraints import Objective
20
+ from zeroquantz.optimization.recommender import Recommender
21
+ from zeroquantz.tui.widgets import Chip
22
+ from zeroquantz.utils import units
23
+
24
+ _EXEC_LABEL = {"in_env": "in-env", "isolated": "sub-env", "external": "produce", "planned": "roadmap"}
25
+ _OBJECTIVES = (("Balanced", "balanced"), ("Quality", "quality"), ("Speed", "speed"), ("Memory", "memory"))
26
+ _RUNTIMES = (
27
+ ("any", "any"), ("vLLM", "vllm"), ("Transformers", "transformers"),
28
+ ("llama.cpp", "llamacpp"), ("ExLlamaV2", "exllamav2"),
29
+ )
30
+ _CONTEXTS = (1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072)
31
+
32
+
33
+ class QuantizeSelectScreen(Screen):
34
+ BINDINGS = [
35
+ Binding("escape", "back", "Back"),
36
+ Binding("enter", "select", "Select"),
37
+ Binding("left", "obj_prev", "objective", priority=True),
38
+ Binding("right", "obj_next", "objective", priority=True, show=False),
39
+ Binding("shift+left", "rt_prev", "runtime", priority=True),
40
+ Binding("shift+right", "rt_next", "runtime", priority=True, show=False),
41
+ ]
42
+
43
+ def __init__(self) -> None:
44
+ super().__init__()
45
+ self._ranked: list = []
46
+
47
+ def compose(self) -> ComposeResult:
48
+ with Vertical(id="quant-page"):
49
+ yield Static("", id="quant-model")
50
+ with Horizontal(classes="ctl-row"):
51
+ yield Static("Budget", classes="ctl-label")
52
+ yield Chip("–", group="budget", value="-")
53
+ yield Static("", id="budget-value", classes="ctl-value")
54
+ yield Chip("+", group="budget", value="+")
55
+ yield Static("Context", classes="ctl-label")
56
+ yield Chip("–", group="context", value="-")
57
+ yield Static("", id="context-value", classes="ctl-value")
58
+ yield Chip("+", group="context", value="+")
59
+ with Horizontal(classes="ctl-row"):
60
+ yield Static("Objective", classes="ctl-label")
61
+ for label, value in _OBJECTIVES:
62
+ yield Chip(label, group="objective", value=value)
63
+ yield Static("Show", classes="ctl-label")
64
+ yield Chip("all", group="show", value="all")
65
+ yield Chip("fits", group="show", value="fits")
66
+ with Horizontal(classes="ctl-row"):
67
+ yield Static("Runtime", classes="ctl-label")
68
+ for label, value in _RUNTIMES:
69
+ yield Chip(label, group="runtime", value=value)
70
+ yield DataTable(id="quant-table", cursor_type="row", zebra_stripes=True)
71
+ yield Footer()
72
+
73
+ def on_mount(self) -> None:
74
+ table = self.query_one("#quant-table", DataTable)
75
+ table.add_columns(
76
+ "Strategy", "bpw", "Size", "VRAM", "Fits", "Max ctx", "Quality", "Speed", "Run", "Exec"
77
+ )
78
+ goal = self.app.ctx.session.goal
79
+ if goal.max_vram_gb is None and self.app.ctx.hardware.total_vram_gb:
80
+ self.app.ctx.session.goal = goal.with_updates(
81
+ max_vram_gb=round(self.app.ctx.hardware.total_vram_gb)
82
+ )
83
+ self._refresh()
84
+ self._sync_chips()
85
+ table.focus()
86
+
87
+ # ---- chip interactions --------------------------------------------------
88
+
89
+ @on(Chip.Selected)
90
+ def _chip(self, event: Chip.Selected) -> None:
91
+ goal = self.app.ctx.session.goal
92
+ g, v = event.group, event.value
93
+ if g == "budget":
94
+ cur = goal.max_vram_gb or 8
95
+ self.app.ctx.session.goal = goal.with_updates(max_vram_gb=max(1.0, min(256.0, cur + (1 if v == "+" else -1))))
96
+ elif g == "context":
97
+ idx = min(range(len(_CONTEXTS)), key=lambda i: abs(_CONTEXTS[i] - goal.context_length))
98
+ idx = max(0, min(len(_CONTEXTS) - 1, idx + (1 if v == "+" else -1)))
99
+ self.app.ctx.session.goal = goal.with_updates(context_length=_CONTEXTS[idx])
100
+ elif g == "objective":
101
+ self.app.ctx.session.goal = goal.model_copy(update={"objective": Objective(v)})
102
+ elif g == "runtime":
103
+ self.app.ctx.session.goal = goal.model_copy(update={"runtime": None if v == "any" else v})
104
+ elif g == "show":
105
+ self.app.quant_fits_only = v == "fits"
106
+ self._refresh()
107
+ self._sync_chips()
108
+
109
+ def action_obj_next(self) -> None:
110
+ self._cycle_objective(1)
111
+
112
+ def action_obj_prev(self) -> None:
113
+ self._cycle_objective(-1)
114
+
115
+ def action_rt_next(self) -> None:
116
+ self._cycle_runtime(1)
117
+
118
+ def action_rt_prev(self) -> None:
119
+ self._cycle_runtime(-1)
120
+
121
+ def _cycle_objective(self, direction: int) -> None:
122
+ goal = self.app.ctx.session.goal
123
+ values = [v for _, v in _OBJECTIVES]
124
+ idx = values.index(str(goal.objective)) if str(goal.objective) in values else 0
125
+ new = values[(idx + direction) % len(values)]
126
+ self.app.ctx.session.goal = goal.model_copy(update={"objective": Objective(new)})
127
+ self._refresh()
128
+ self._sync_chips()
129
+
130
+ def _cycle_runtime(self, direction: int) -> None:
131
+ goal = self.app.ctx.session.goal
132
+ values = [v for _, v in _RUNTIMES]
133
+ current = goal.runtime or "any"
134
+ idx = values.index(current) if current in values else 0
135
+ new = values[(idx + direction) % len(values)]
136
+ self.app.ctx.session.goal = goal.model_copy(update={"runtime": None if new == "any" else new})
137
+ self._refresh()
138
+ self._sync_chips()
139
+
140
+ def _sync_chips(self) -> None:
141
+ goal = self.app.ctx.session.goal
142
+ fits_only = getattr(self.app, "quant_fits_only", False)
143
+ for chip in self.query(Chip):
144
+ if chip.chip_group == "objective":
145
+ chip.set_selected(chip.chip_value == str(goal.objective))
146
+ elif chip.chip_group == "runtime":
147
+ chip.set_selected(chip.chip_value == (goal.runtime or "any"))
148
+ elif chip.chip_group == "show":
149
+ chip.set_selected(chip.chip_value == ("fits" if fits_only else "all"))
150
+ self.query_one("#budget-value", Static).update(
151
+ Text(f"{goal.max_vram_gb:g} GB" if goal.max_vram_gb else "unset", style="bold #3fb950")
152
+ )
153
+ self.query_one("#context-value", Static).update(
154
+ Text(f"{goal.context_length}", style="bold #58a6ff")
155
+ )
156
+
157
+ # ---- table --------------------------------------------------------------
158
+
159
+ def _refresh(self) -> None:
160
+ ctx = self.app.ctx
161
+ model = ctx.session.model_profile
162
+ if model is None:
163
+ self.query_one("#quant-model", Static).update(
164
+ Text("No model loaded — go back and select one.", style="#d29922")
165
+ )
166
+ return
167
+ goal = ctx.session.goal
168
+ cands = CandidateGenerator.generate(model, ctx.hardware, goal, registry=ctx.backends)
169
+ self._ranked = Recommender.rank(cands, goal)
170
+ best = Recommender.recommend(cands, goal)
171
+ self._best_id = best.candidate.format_id if best else None
172
+ self.query_one("#quant-model", Static).update(self._model_text(model))
173
+
174
+ fits_only = getattr(self.app, "quant_fits_only", False)
175
+ # "fits" = fits the budget AND is usable on the selected runtime — not
176
+ # "auto-runnable" (GGUF/BitNet are produce-only yet still valid choices).
177
+ rows = [
178
+ r for r in self._ranked
179
+ if not fits_only
180
+ or (r.candidate.fits_memory and r.candidate.runtime_compat.rank >= 1)
181
+ ]
182
+ self._rows = rows
183
+
184
+ table = self.query_one("#quant-table", DataTable)
185
+ table.clear()
186
+ best_row = 0
187
+ for i, rec in enumerate(rows):
188
+ c = rec.candidate
189
+ recommended = c.format_id == self._best_id
190
+ if recommended:
191
+ best_row = i
192
+ strategy = Text(("★ " if recommended else " ") + c.label,
193
+ style="bold white" if recommended else "white")
194
+ fits = Text("✓", style="#3fb950") if c.fits_memory else Text("✗", style="#f85149")
195
+ vram = Text(f"{c.estimated_vram_gb:.1f}", style="white" if c.fits_memory else "#f85149")
196
+ table.add_row(
197
+ strategy, f"{c.bits_per_weight:.2f}", f"{c.estimated_size_gb:.1f}", vram, fits,
198
+ _fmt_ctx(c.max_context), Text(c.quality_risk_label, style=_risk_style(c.quality_risk_label)),
199
+ f"{c.estimated_speedup:.1f}x", _run_cell(c),
200
+ Text(_EXEC_LABEL.get(c.execution, c.execution), style=_exec_style(c.execution)),
201
+ )
202
+ if rows:
203
+ table.move_cursor(row=min(best_row, len(rows) - 1))
204
+
205
+ def _model_text(self, model) -> Text: # noqa: ANN001
206
+ text = Text()
207
+ text.append(model.model_id, style="bold #58a6ff")
208
+ if model.architecture:
209
+ text.append(f" {model.architecture}", style="#8b949e")
210
+ text.append("\n")
211
+ parts = [f"{units.humanize_params(model.parameter_count)} params", model.dtype.upper()]
212
+ if model.num_layers:
213
+ parts.append(f"{model.num_layers} layers")
214
+ if model.hidden_size:
215
+ parts.append(f"hidden {model.hidden_size}")
216
+ if model.num_attention_heads:
217
+ heads = f"{model.num_attention_heads} heads"
218
+ if model.num_key_value_heads and model.num_key_value_heads != model.num_attention_heads:
219
+ heads += f" (KV {model.num_key_value_heads})"
220
+ parts.append(heads)
221
+ parts.append(f"~{model.estimated_weight_size_gb:.1f} GB original")
222
+ text.append(" · ".join(parts), style="#8b949e")
223
+ return text
224
+
225
+ # ---- selection ----------------------------------------------------------
226
+
227
+ def action_select(self) -> None:
228
+ self._select(self.query_one("#quant-table", DataTable).cursor_row)
229
+
230
+ @on(DataTable.RowSelected, "#quant-table")
231
+ def _row_selected(self, event: DataTable.RowSelected) -> None:
232
+ self._select(event.cursor_row)
233
+
234
+ def _select(self, row: int) -> None:
235
+ rows = getattr(self, "_rows", [])
236
+ if row is None or row < 0 or row >= len(rows):
237
+ return
238
+ candidate = rows[row].candidate
239
+ self.app.ctx.session.selected_method = candidate.method
240
+ self.app.ctx.session.selected_format_id = candidate.format_id
241
+ self.app.push_plan(candidate)
242
+
243
+ def action_back(self) -> None:
244
+ self.app.pop_screen()
245
+
246
+
247
+ def _fmt_ctx(n: int | None) -> str:
248
+ if not n:
249
+ return "—"
250
+ return f"{n / 1000:.1f}k" if n >= 1000 else str(n)
251
+
252
+
253
+ def _risk_style(label: str) -> str:
254
+ return {
255
+ "very low": "#3fb950", "low": "#3fb950", "moderate": "#d29922",
256
+ "high": "#f85149", "very high": "bold #f85149",
257
+ }.get(label, "white")
258
+
259
+
260
+ def _exec_style(execution: str) -> str:
261
+ return {"in_env": "#3fb950", "isolated": "#58a6ff", "external": "#d29922", "planned": "#6e7681"}.get(
262
+ execution, "white"
263
+ )
264
+
265
+
266
+ def _run_cell(candidate) -> Text: # noqa: ANN001
267
+ rank = candidate.runtime_compat.rank
268
+ if rank >= 3:
269
+ return Text("✓", style="#3fb950")
270
+ if rank >= 1:
271
+ return Text("~", style="#d29922")
272
+ return Text("✗", style="#f85149")