quner 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.
quner/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """quner — portable Linux efficiency daemon.
2
+
3
+ Tunes the operating point (CPU governor / RAPL package power / NVIDIA power-cap)
4
+ to the work-per-joule interior optimum and holds it, adapting between idle and
5
+ heavy-compute profiles. Capability-detecting and fail-loud: it tunes whatever
6
+ levers a host exposes and reports the absent ones rather than silently no-op'ing.
7
+ """
8
+
9
+ __version__ = "0.1.0"
quner/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m quner``."""
2
+
3
+ from quner.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
quner/calibration.py ADDED
@@ -0,0 +1,40 @@
1
+ """EXP-132 calibration — the one physics-derived input.
2
+
3
+ The QIG lattice experiment EXP-132 (qig-verification) found that information-per-
4
+ joule peaks at an INTERIOR operating point, not at maximum drive, and certified
5
+ that optimum with a confidence band (the same discipline that turned an apparent
6
+ 12.67% wobble into a certified ±1.91% invariant band).
7
+
8
+ Only two things transfer to silicon: (1) the SHAPE of the law — total-system
9
+ work-per-joule is a curve with an interior optimum, not a max-clock endpoint —
10
+ and (2) the band-certification METHODOLOGY. The lattice ``h≈1.5`` constant does
11
+ NOT transfer; quner re-measures the optimum on each host. This table is a prior
12
+ on shape and the ±1.91% threshold, nothing more.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ # Lattice info-per-joule vs drive h (EXP-132). Peak at the interior point h=1.5.
18
+ EXP132_INFO_PER_JOULE: dict[float, float] = {
19
+ 1.0: 0.557,
20
+ 1.5: 0.808, # interior optimum; band [0.793, 0.824]
21
+ 2.0: 0.799,
22
+ 2.2: 0.752,
23
+ 2.5: 0.582,
24
+ }
25
+
26
+ EXP132_INTERIOR_OPTIMUM_H = 1.5
27
+ EXP132_INVARIANT_BAND_REL = 0.0191 # ±1.91% certified invariant band (methodology)
28
+
29
+
30
+ def principle() -> str:
31
+ """Transferable physics statement, quoted in every tune report so the QIG
32
+ claim is never silently conflated with the engineering knobs."""
33
+ return (
34
+ "EXP-132: work-per-joule peaks at an INTERIOR operating point "
35
+ f"(lattice h={EXP132_INTERIOR_OPTIMUM_H}), not at maximum drive. "
36
+ "Silicon analogue (re-measured per host, not imported): total-system "
37
+ "work-per-joule peaks at an interior CPU/GPU operating point, not at "
38
+ "max clock / max TDP. Optimum certified with a confidence band, per the "
39
+ f"±{EXP132_INVARIANT_BAND_REL * 100:.2f}% invariant-band methodology."
40
+ )
quner/cli.py ADDED
@@ -0,0 +1,227 @@
1
+ """quner command-line interface.
2
+
3
+ quner doctor what levers this host exposes (fail-loud)
4
+ quner status telemetry + the EXP-132 principle
5
+ quner apply [--restore] apply the current profile's operating point
6
+ quner profile <name> apply a named profile (idle|training)
7
+ quner tune [--command C] profile work/joule, find the interior optimum
8
+ quner install | uninstall systemd service + re-tune timer
9
+ quner prime {status,enable,disable} reverse-PRIME (opt-in)
10
+ quner memguard one OOM-watchdog tick
11
+ quner selftest transient, auto-reverting proof the levers work
12
+ quner serve run the daemon loop (used by systemd)
13
+
14
+ Every mutating command takes ``--dry-run`` (prints, changes nothing).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+
23
+ from quner import __version__
24
+
25
+
26
+ def _print(obj) -> None:
27
+ print(json.dumps(obj, indent=2) if isinstance(obj, (dict, list)) else obj)
28
+
29
+
30
+ # ── command handlers ──────────────────────────────────────────────────────────
31
+
32
+ def _cmd_doctor(_args) -> int:
33
+ from quner import detect
34
+ print("quner doctor — capability report")
35
+ for line in detect.doctor_lines():
36
+ print(" " + line)
37
+ return 0
38
+
39
+
40
+ def _cmd_status(_args) -> int:
41
+ from quner import tune
42
+ _print(tune.status())
43
+ return 0
44
+
45
+
46
+ def _cmd_apply(args) -> int:
47
+ from quner import control, detect, profiles, tune
48
+ if args.restore:
49
+ _print({"restored": control.restore()})
50
+ return 0
51
+ profile = profiles.Detector(ticks=1).update()
52
+ target = (profiles.cache_get(detect.host_fingerprint(), profile)
53
+ or profiles.default_state_for(profile))
54
+ if args.dry_run:
55
+ print(f"[dry-run] profile={profile} target={target.label()} — nothing applied")
56
+ return 0
57
+ _print({"profile": profile, "target": target.label(),
58
+ "applied": tune.apply_state(target)})
59
+ return 0
60
+
61
+
62
+ def _cmd_profile(args) -> int:
63
+ from quner import profiles, tune
64
+ target = profiles.default_state_for(args.name)
65
+ if args.dry_run:
66
+ print(f"[dry-run] profile={args.name} target={target.label()} — nothing applied")
67
+ return 0
68
+ _print({"profile": args.name, "target": target.label(),
69
+ "applied": tune.apply_state(target)})
70
+ return 0
71
+
72
+
73
+ def _cmd_tune(args) -> int:
74
+ from quner import tune
75
+ run = tune.command_runner(args.command) if args.command \
76
+ else tune.command_runner("sleep 0.1")
77
+ rep = tune.tune(run, apply=args.apply, reps=args.reps)
78
+ _print(rep.as_dict())
79
+ return 0
80
+
81
+
82
+ def _cmd_install(args) -> int:
83
+ from quner import service
84
+ service.install(dry_run=args.dry_run, interval=args.interval)
85
+ return 0
86
+
87
+
88
+ def _cmd_uninstall(args) -> int:
89
+ from quner import service
90
+ service.uninstall(dry_run=args.dry_run)
91
+ return 0
92
+
93
+
94
+ def _cmd_prime(args) -> int:
95
+ from quner import prime
96
+ if args.prime_cmd == "status":
97
+ _print(prime.status())
98
+ elif args.prime_cmd == "enable":
99
+ _print(prime.enable(dry_run=args.dry_run))
100
+ elif args.prime_cmd == "disable":
101
+ _print(prime.disable(dry_run=args.dry_run))
102
+ else:
103
+ print("usage: quner prime {status,enable,disable}")
104
+ return 2
105
+ return 0
106
+
107
+
108
+ def _cmd_memguard(args) -> int:
109
+ from quner import memguard
110
+ _print(memguard.tick(dry_run=args.dry_run))
111
+ return 0
112
+
113
+
114
+ def _cmd_serve(args) -> int:
115
+ from quner import daemon
116
+ daemon.serve(interval=args.interval, dry_run=args.dry_run,
117
+ memguard_on=args.memguard)
118
+ return 0
119
+
120
+
121
+ def _cmd_selftest(_args) -> int:
122
+ """Transient, auto-reverting proof each present lever moves on THIS host.
123
+ Snapshots state, nudges each lever to a benign value, verifies, restores."""
124
+ from quner import control
125
+ from quner import telemetry as t
126
+
127
+ snap = control.snapshot(persist=False)
128
+ results: dict[str, str] = {}
129
+
130
+ # governor: flip to another available governor, then back
131
+ govs = t.available_governors()
132
+ cur = t.current_governor()
133
+ if govs and cur:
134
+ alt = next((g for g in govs if g != cur), None)
135
+ if alt:
136
+ ok = t.set_governor(alt)
137
+ restored = t.set_governor(cur)
138
+ results["governor"] = ("PASS" if ok and restored and t.current_governor() == cur
139
+ else "FAIL")
140
+ else:
141
+ results["governor"] = "skip (only one governor offered)"
142
+ else:
143
+ results["governor"] = "unavailable"
144
+
145
+ # rapl: write current PL1 back to itself (no net change, exercises the write)
146
+ cur_uw = control.read_rapl_pl1_uw()
147
+ results["rapl"] = ("PASS" if cur_uw and control._write_rapl_uw(cur_uw)
148
+ else ("unavailable" if not cur_uw else "FAIL"))
149
+
150
+ # gpu cap: set to its own max (= default/uncap; benign no-op change)
151
+ rng = t.gpu_power_limit_range_w()
152
+ if rng:
153
+ results["gpu_cap"] = "PASS" if t.set_gpu_power_limit_w(rng[1]) else "FAIL"
154
+ else:
155
+ results["gpu_cap"] = "unavailable"
156
+
157
+ control.restore(snap) # belt-and-suspenders full restore
158
+ print("quner selftest — transient, auto-reverted")
159
+ for lever, verdict in results.items():
160
+ print(f" {lever:>10}: {verdict}")
161
+ failed = [k for k, v in results.items() if v == "FAIL"]
162
+ return 1 if failed else 0
163
+
164
+
165
+ # ── parser ────────────────────────────────────────────────────────────────────
166
+
167
+ def _build_parser() -> argparse.ArgumentParser:
168
+ p = argparse.ArgumentParser(prog="quner", description=__doc__,
169
+ formatter_class=argparse.RawDescriptionHelpFormatter)
170
+ p.add_argument("--version", "-V", action="version", version=f"quner {__version__}")
171
+ sub = p.add_subparsers(dest="cmd")
172
+
173
+ sub.add_parser("doctor", help="what levers this host exposes").set_defaults(func=_cmd_doctor)
174
+ sub.add_parser("status", help="telemetry + principle").set_defaults(func=_cmd_status)
175
+
176
+ ap = sub.add_parser("apply", help="apply the current profile's operating point")
177
+ ap.add_argument("--restore", action="store_true", help="restore the saved baseline")
178
+ ap.add_argument("--dry-run", action="store_true")
179
+ ap.set_defaults(func=_cmd_apply)
180
+
181
+ pr = sub.add_parser("profile", help="apply a named profile")
182
+ pr.add_argument("name", choices=["idle", "training"])
183
+ pr.add_argument("--dry-run", action="store_true")
184
+ pr.set_defaults(func=_cmd_profile)
185
+
186
+ tu = sub.add_parser("tune", help="find the interior work/joule optimum")
187
+ tu.add_argument("--command", help="workload command to profile")
188
+ tu.add_argument("--apply", action="store_true", help="apply + hold the chosen state")
189
+ tu.add_argument("--reps", type=int, default=3)
190
+ tu.set_defaults(func=_cmd_tune)
191
+
192
+ ins = sub.add_parser("install", help="install the systemd service + timer")
193
+ ins.add_argument("--dry-run", action="store_true")
194
+ ins.add_argument("--interval", default="30min", help="re-tune timer interval")
195
+ ins.set_defaults(func=_cmd_install)
196
+
197
+ uni = sub.add_parser("uninstall", help="remove the service + restore baseline")
198
+ uni.add_argument("--dry-run", action="store_true")
199
+ uni.set_defaults(func=_cmd_uninstall)
200
+
201
+ pm = sub.add_parser("prime", help="reverse-PRIME (opt-in)")
202
+ pm.add_argument("prime_cmd", choices=["status", "enable", "disable"])
203
+ pm.add_argument("--dry-run", action="store_true")
204
+ pm.set_defaults(func=_cmd_prime)
205
+
206
+ mg = sub.add_parser("memguard", help="one OOM-watchdog tick")
207
+ mg.add_argument("--dry-run", action="store_true")
208
+ mg.set_defaults(func=_cmd_memguard)
209
+
210
+ sv = sub.add_parser("serve", help="run the daemon loop (systemd)")
211
+ sv.add_argument("--interval", type=float, default=10.0)
212
+ sv.add_argument("--dry-run", action="store_true")
213
+ sv.add_argument("--memguard", action="store_true")
214
+ sv.set_defaults(func=_cmd_serve)
215
+
216
+ sub.add_parser("selftest", help="transient, auto-reverting lever proof").set_defaults(func=_cmd_selftest)
217
+ return p
218
+
219
+
220
+ def main(argv: list[str] | None = None) -> int:
221
+ argv = sys.argv[1:] if argv is None else argv
222
+ parser = _build_parser()
223
+ args = parser.parse_args(argv)
224
+ if not getattr(args, "func", None):
225
+ parser.print_help()
226
+ return 0
227
+ return args.func(args)
quner/control.py ADDED
@@ -0,0 +1,135 @@
1
+ """Privileged operating-point levers + snapshot/rollback.
2
+
3
+ The write side of the daemon: set the CPU governor, the Intel RAPL package power
4
+ limit (PL1), and the NVIDIA power-cap — each clamped/allowlisted before any
5
+ write. Every apply can be captured by ``snapshot()`` and undone by ``restore()``,
6
+ so the daemon (and ``uninstall``) always return the host to where it started.
7
+
8
+ All writes need root; on a host without a given lever the corresponding call
9
+ returns ``False``/no-op (fail-loud is the caller's job, via ``detect``).
10
+ State (the rollback file) lives under ``STATE_DIR()`` (``QUNER_STATE_DIR``).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+
18
+ from quner import telemetry as t
19
+
20
+ __all__ = [
21
+ "STATE_DIR", "ROLLBACK_PATH",
22
+ "rapl_pl1_max_w", "read_rapl_pl1_uw", "set_rapl_pl1_w",
23
+ "snapshot", "restore",
24
+ ]
25
+
26
+
27
+ def STATE_DIR() -> str:
28
+ return os.environ.get("QUNER_STATE_DIR", "/var/lib/quner")
29
+
30
+
31
+ def ROLLBACK_PATH() -> str:
32
+ return os.path.join(STATE_DIR(), "rollback.json")
33
+
34
+
35
+ # ── RAPL PL1 (net-new writer; telemetry only reads RAPL energy) ────────────────
36
+
37
+ def _read_int(path: str) -> int | None:
38
+ try:
39
+ with open(path) as fh:
40
+ return int(fh.read().strip())
41
+ except (OSError, ValueError):
42
+ return None
43
+
44
+
45
+ def rapl_pl1_max_w() -> dict[str, float]:
46
+ """Per-package PL1 ceiling in watts (from ``constraint_0_max_power_uw``)."""
47
+ out: dict[str, float] = {}
48
+ for d in t.rapl_package_dirs():
49
+ mx = _read_int(os.path.join(d, "constraint_0_max_power_uw"))
50
+ if mx and mx > 0:
51
+ out[os.path.basename(d)] = mx / 1e6
52
+ return out
53
+
54
+
55
+ def read_rapl_pl1_uw() -> dict[str, int]:
56
+ """Per-package current PL1 long-term limit in micro-watts."""
57
+ out: dict[str, int] = {}
58
+ for d in t.rapl_package_dirs():
59
+ v = _read_int(os.path.join(d, "constraint_0_power_limit_uw"))
60
+ if v is not None:
61
+ out[os.path.basename(d)] = v
62
+ return out
63
+
64
+
65
+ def set_rapl_pl1_w(watts: float) -> bool:
66
+ """Set RAPL PL1 on every package, each clamped to [1.0, its own max]. True
67
+ if all packages wrote. No-op → False when RAPL is absent."""
68
+ dirs = t.rapl_package_dirs()
69
+ if not dirs:
70
+ return False
71
+ maxes = rapl_pl1_max_w()
72
+ ok = True
73
+ for d in dirs:
74
+ pkg = os.path.basename(d)
75
+ hi = maxes.get(pkg)
76
+ w = max(1.0, min(hi, watts)) if hi else max(1.0, watts)
77
+ try:
78
+ with open(os.path.join(d, "constraint_0_power_limit_uw"), "w") as fh:
79
+ fh.write(str(int(round(w * 1e6))))
80
+ except OSError:
81
+ ok = False
82
+ return ok
83
+
84
+
85
+ def _write_rapl_uw(pkg_uw: dict[str, int]) -> bool:
86
+ """Restore exact per-package PL1 micro-watt values (used by ``restore``)."""
87
+ ok = True
88
+ by_name = {os.path.basename(d): d for d in t.rapl_package_dirs()}
89
+ for pkg, uw in pkg_uw.items():
90
+ d = by_name.get(pkg)
91
+ if d is None:
92
+ ok = False
93
+ continue
94
+ try:
95
+ with open(os.path.join(d, "constraint_0_power_limit_uw"), "w") as fh:
96
+ fh.write(str(int(uw)))
97
+ except OSError:
98
+ ok = False
99
+ return ok
100
+
101
+
102
+ # ── snapshot / restore ─────────────────────────────────────────────────────────
103
+
104
+ def snapshot(persist: bool = True) -> dict:
105
+ """Capture current governor / RAPL PL1 / GPU uncap target. Persisted to the
106
+ rollback file (so a later process — e.g. ``uninstall`` — can restore)."""
107
+ rng = t.gpu_power_limit_range_w()
108
+ snap = {
109
+ "governor": t.current_governor(),
110
+ "rapl_pl1_uw": read_rapl_pl1_uw(),
111
+ "gpu_uncap_w": (rng[1] if rng else None), # max = uncapped
112
+ }
113
+ if persist:
114
+ os.makedirs(STATE_DIR(), exist_ok=True)
115
+ with open(ROLLBACK_PATH(), "w") as fh:
116
+ json.dump(snap, fh, indent=2)
117
+ return snap
118
+
119
+
120
+ def restore(snap: dict | None = None) -> dict[str, bool]:
121
+ """Return the host to a snapshot (the argument, or the persisted file)."""
122
+ if snap is None:
123
+ try:
124
+ with open(ROLLBACK_PATH()) as fh:
125
+ snap = json.load(fh)
126
+ except (OSError, ValueError):
127
+ return {}
128
+ applied: dict[str, bool] = {}
129
+ if snap.get("governor"):
130
+ applied["governor"] = t.set_governor(snap["governor"])
131
+ if snap.get("rapl_pl1_uw"):
132
+ applied["rapl"] = _write_rapl_uw(snap["rapl_pl1_uw"])
133
+ if snap.get("gpu_uncap_w") is not None:
134
+ applied["gpu"] = t.set_gpu_power_limit_w(snap["gpu_uncap_w"])
135
+ return applied
quner/daemon.py ADDED
@@ -0,0 +1,93 @@
1
+ """The daemon service loop.
2
+
3
+ Each tick: detect the workload profile → apply the cached (or default) operating
4
+ point for it → run a memguard tick → publish ``status.json``. The loop only
5
+ *applies* cheap cached states; the expensive *exploratory* re-tune runs
6
+ separately (the systemd timer, or ``quner tune``) and is REFUSED while the
7
+ profile is ``training`` — so quner never perturbs an active run with a power
8
+ sweep (coordination seam #4).
9
+
10
+ Paths are env-overridable (``QUNER_RUN_DIR`` for status.json), so the whole loop
11
+ runs in the sandbox with zero host impact.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import signal
19
+ import time
20
+
21
+ from quner import control, detect, memguard, profiles, tune
22
+
23
+
24
+ def RUN_DIR() -> str:
25
+ return os.environ.get("QUNER_RUN_DIR", "/run/quner")
26
+
27
+
28
+ def write_status(status: dict) -> str:
29
+ d = RUN_DIR()
30
+ os.makedirs(d, exist_ok=True)
31
+ path = os.path.join(d, "status.json")
32
+ with open(path, "w") as fh:
33
+ json.dump(status, fh, indent=2)
34
+ return path
35
+
36
+
37
+ def run_once(detector: profiles.Detector, *, dry_run: bool = False,
38
+ memguard_on: bool = False) -> dict:
39
+ """One daemon tick. Applies the cached/default state for the current
40
+ profile (unless dry_run) and writes status.json."""
41
+ profile = detector.update()
42
+ fp = detect.host_fingerprint()
43
+ target = profiles.cache_get(fp, profile) or profiles.default_state_for(profile)
44
+ applied = {} if dry_run else tune.apply_state(target)
45
+ mg = memguard.tick(dry_run=dry_run) if memguard_on else {"action": "disabled"}
46
+ status = {
47
+ "profile": profile,
48
+ "target_state": target.label(),
49
+ "applied": applied,
50
+ "memguard": mg,
51
+ "dry_run": dry_run,
52
+ "capabilities": {k: v["available"] for k, v in detect.capabilities().items()},
53
+ }
54
+ write_status(status)
55
+ return status
56
+
57
+
58
+ def retune(profile: str, work_fn=None):
59
+ """Exploratory re-tune for a profile → cache the chosen state. REFUSED while
60
+ ``training`` (never sweep power mid-run). Returns the TuneReport, or None if
61
+ refused."""
62
+ if profile == profiles.TRAINING:
63
+ return None
64
+ run = work_fn or tune.command_runner("sleep 0.05", work_units=1.0)
65
+ rep = tune.tune(run, apply=True)
66
+ if rep.chosen_state is not None:
67
+ profiles.cache_put(detect.host_fingerprint(), profile, rep.chosen_state)
68
+ return rep
69
+
70
+
71
+ def serve(interval: float = 10.0, dry_run: bool = False,
72
+ memguard_on: bool = False) -> None:
73
+ """The service loop: snapshot baseline, tick until SIGTERM/SIGINT, restore."""
74
+ if not dry_run:
75
+ control.snapshot() # baseline for ExecStop / crash restore
76
+ detector = profiles.Detector()
77
+ stop = {"v": False}
78
+
79
+ def _sig(*_a):
80
+ stop["v"] = True
81
+
82
+ signal.signal(signal.SIGTERM, _sig)
83
+ signal.signal(signal.SIGINT, _sig)
84
+ try:
85
+ while not stop["v"]:
86
+ run_once(detector, dry_run=dry_run, memguard_on=memguard_on)
87
+ slept = 0.0
88
+ while slept < interval and not stop["v"]:
89
+ time.sleep(min(0.5, interval - slept))
90
+ slept += 0.5
91
+ finally:
92
+ if not dry_run:
93
+ control.restore()
quner/detect.py ADDED
@@ -0,0 +1,101 @@
1
+ """Capability detection — the ``doctor`` report + host fingerprint.
2
+
3
+ quner tunes whatever levers a host exposes and reports the absent ones. This
4
+ module turns the raw telemetry probes into a structured capability map and
5
+ human-readable lines, and computes a stable host fingerprint used to key the
6
+ per-host operating-point cache.
7
+
8
+ fail-loud: an absent lever is reported as ``available: False`` with a reason,
9
+ never silently omitted.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import os
16
+
17
+ from quner import telemetry as t
18
+
19
+
20
+ def _cpu_model() -> str:
21
+ path = os.path.join(t.PROC_ROOT(), "cpuinfo")
22
+ try:
23
+ with open(path) as fh:
24
+ for line in fh:
25
+ if line.startswith("model name"):
26
+ return line.split(":", 1)[1].strip()
27
+ except OSError:
28
+ pass
29
+ return "unknown-cpu"
30
+
31
+
32
+ def _gpu_name() -> str | None:
33
+ smi = t.nvidia_smi()
34
+ if smi is None:
35
+ return None
36
+ import subprocess
37
+ try:
38
+ out = subprocess.check_output(
39
+ [smi, "--query-gpu=name", "--format=csv,noheader"],
40
+ text=True, timeout=10,
41
+ ).strip().splitlines()
42
+ return out[0] if out else None
43
+ except Exception:
44
+ return None
45
+
46
+
47
+ def capabilities() -> dict:
48
+ """Per-lever ``{available, detail}`` map for governor / rapl / gpu_cap /
49
+ display_prime."""
50
+ govs = t.available_governors()
51
+ gpu_rng = t.gpu_power_limit_range_w()
52
+ gpu_name = _gpu_name()
53
+ rapl_pkgs = list(t.read_rapl_energy_uj().keys())
54
+
55
+ return {
56
+ "governor": {
57
+ "available": t.cpufreq_available(),
58
+ "detail": (f"governors: {', '.join(govs)}; current: {t.current_governor()}"
59
+ if govs else "no cpufreq (VM or unsupported)"),
60
+ },
61
+ "rapl": {
62
+ "available": t.rapl_available(),
63
+ "detail": (f"packages: {', '.join(rapl_pkgs)}" if rapl_pkgs
64
+ else "no intel-rapl powercap (not exposed here)"),
65
+ },
66
+ "gpu_cap": {
67
+ "available": gpu_rng is not None,
68
+ "detail": (f"{gpu_name}: cap {gpu_rng[0]:.0f}-{gpu_rng[1]:.0f}W"
69
+ if gpu_rng else
70
+ ("nvidia-smi present but power-cap not settable"
71
+ if gpu_name else "no nvidia-smi (driver not installed)")),
72
+ },
73
+ "display_prime": {
74
+ "available": gpu_name is not None,
75
+ "detail": ("reverse-PRIME possible (dGPU present)" if gpu_name
76
+ else "no dGPU driver — already iGPU-only, PRIME is moot"),
77
+ },
78
+ }
79
+
80
+
81
+ def doctor_lines() -> list[str]:
82
+ """Human-readable capability lines (the ``quner doctor`` body)."""
83
+ caps = capabilities()
84
+ label = {
85
+ "governor": "cpu governor",
86
+ "rapl": "rapl pkg power",
87
+ "gpu_cap": "nvidia -pl",
88
+ "display_prime": "reverse-PRIME",
89
+ }
90
+ lines = []
91
+ for key, name in label.items():
92
+ c = caps[key]
93
+ mark = "available" if c["available"] else "unavailable"
94
+ lines.append(f"{name:>15}: {mark} — {c['detail']}")
95
+ return lines
96
+
97
+
98
+ def host_fingerprint() -> str:
99
+ """Stable short hash of CPU model + governors + GPU name (cache key)."""
100
+ parts = [_cpu_model(), ",".join(t.available_governors()), _gpu_name() or "no-gpu"]
101
+ return hashlib.sha256("|".join(parts).encode()).hexdigest()[:16]