denpex-mlflow 0.1.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .pytest_cache/
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.5
2
+ Name: denpex-mlflow
3
+ Version: 0.1.0
4
+ Summary: Attach Denpex GPU and training-failure diagnoses to your MLflow runs in one line.
5
+ Project-URL: Homepage, https://denpex.com
6
+ Project-URL: Documentation, https://denpex.com/docs/integrations
7
+ Project-URL: API Reference, https://denpex.com/docs/api
8
+ Project-URL: Python Agent & CLI, https://denpex.com/docs/agent
9
+ Project-URL: VS Code Extension, https://denpex.com/vscode
10
+ Project-URL: Open VSX Extension, https://denpex.com/openvsx
11
+ Project-URL: GPU Failure Encyclopedia, https://denpex.com/failures
12
+ Project-URL: Free GPU Debugging Tools, https://denpex.com/tools
13
+ Project-URL: Changelog, https://denpex.com/changelog
14
+ Project-URL: Repository, https://github.com/Denpex/gpu-failure-cheatsheet
15
+ Project-URL: Issue Tracker, https://denpex.com/contact
16
+ Author: Denpex
17
+ License: MIT
18
+ Keywords: cuda,denpex,diagnosis,gpu,mlflow,nccl,pytorch,training
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Requires-Python: >=3.8
23
+ Requires-Dist: mlflow>=2.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # denpex-mlflow
27
+
28
+ Attach **[Denpex](https://denpex.com)** GPU and training-failure diagnoses to your **MLflow** runs in one line.
29
+
30
+ When a run crashes, Denpex reads the traceback (and optionally your log file), diagnoses
31
+ the root cause (NCCL, Xid, CUDA, OOM, checkpoint, etc.), and writes the **root cause + fix** back
32
+ onto the active MLflow run as tags and a `denpex_diagnosis.md` artifact, then re-raises
33
+ your original exception so nothing is hidden.
34
+
35
+ ## Install
36
+ ```bash
37
+ pip install denpex-mlflow
38
+ ```
39
+
40
+ ## Use
41
+ ```python
42
+ import mlflow
43
+ import denpex_mlflow as denpex
44
+
45
+ with mlflow.start_run():
46
+ with denpex.watch(job_name="llama-7b-sft"):
47
+ train() # if this raises, Denpex diagnoses it and tags the run
48
+ ```
49
+
50
+ Decorator form:
51
+ ```python
52
+ @denpex.watch_run("llama-7b-sft")
53
+ def main():
54
+ train()
55
+ ```
56
+
57
+ One-shot (no MLflow needed):
58
+ ```python
59
+ diag = denpex.diagnose(open("train.err").read())
60
+ print(diag.failure_type, diag.confidence, diag.action)
61
+ ```
62
+
63
+ ## Auth
64
+ Set an API key (create one at **[denpex.com/dashboard](https://denpex.com/dashboard)** under API keys):
65
+ ```bash
66
+ export DENPEX_API_KEY=dpx_...
67
+ # optional, for self-hosted / staging:
68
+ export DENPEX_API_URL=https://api.denpex.com
69
+ ```
70
+
71
+ ## What gets written to the run
72
+ | MLflow tag | Value |
73
+ |---|---|
74
+ | `denpex.failure_type` | e.g. `NCCL_TIMEOUT`, `CUDA_OOM`, `GPU_XID_ECC` |
75
+ | `denpex.confidence` | 0 to 100 |
76
+ | `denpex.fix_summary` | the root cause (truncated) |
77
+ | `denpex.diagnosis_id` | link back to [denpex.com/dashboard](https://denpex.com/dashboard) |
78
+ | artifact `denpex_diagnosis.md` | full root cause + fix |
79
+
80
+ ## Notes
81
+ - **Best-effort & non-masking:** if Denpex is unreachable, unauthenticated, or over the
82
+ free-plan quota, it logs one line to stderr and still re-raises your real error.
83
+ - `KeyboardInterrupt` / `SystemExit` are passed through undiagnosed.
84
+ - The diagnosis runs through the same `/api/diagnose` endpoint as the rest of Denpex, so
85
+ it also lands in your dashboard history and fires your configured alert channels.
86
+ - Pass `logfile="train.err"` to `watch()` to include the tail of a log file alongside the
87
+ traceback.
88
+
89
+ ---
90
+
91
+ ## Official Denpex Ecosystem & Documentation
92
+
93
+ | Resource | Description | Link |
94
+ | :--- | :--- | :--- |
95
+ | **Denpex Platform** | Deterministic GPU and ML training failure diagnostics | [denpex.com](https://denpex.com) |
96
+ | **Documentation Hub** | Quickstart guides, architecture, and operational reference | [denpex.com/docs](https://denpex.com/docs) |
97
+ | **Python Agent & CLI** | On-host monitoring, evidence capture, and zero-egress CLI | [denpex.com/docs/agent](https://denpex.com/docs/agent) |
98
+ | **REST API Reference** | Programmatic `/api/diagnose` and preflight endpoints | [denpex.com/docs/api](https://denpex.com/docs/api) |
99
+ | **VS Code Extension** | Offline GPU crash diagnostics in VS Code | [denpex.com/vscode](https://denpex.com/vscode) |
100
+ | **Open VSX Extension** | Extension for Cursor, Windsurf, VSCodium, and Gitpod | [denpex.com/openvsx](https://denpex.com/openvsx) |
101
+ | **Failure Encyclopedia** | 500+ documented CUDA, NCCL, Xid, Slurm, and vLLM failures | [denpex.com/failures](https://denpex.com/failures) |
102
+ | **Free GPU Tools** | VRAM, NCCL, DeepSpeed ZeRO, and Slurm calculators | [denpex.com/tools](https://denpex.com/tools) |
103
+ | **NVIDIA Xid Reference** | Hardware vs software Xid fault routing table | [denpex.com/xid](https://denpex.com/xid) |
104
+ | **Release Changelog** | Platform, agent, SDK, and extension release notes | [denpex.com/changelog](https://denpex.com/changelog) |
105
+
@@ -0,0 +1,80 @@
1
+ # denpex-mlflow
2
+
3
+ Attach **[Denpex](https://denpex.com)** GPU and training-failure diagnoses to your **MLflow** runs in one line.
4
+
5
+ When a run crashes, Denpex reads the traceback (and optionally your log file), diagnoses
6
+ the root cause (NCCL, Xid, CUDA, OOM, checkpoint, etc.), and writes the **root cause + fix** back
7
+ onto the active MLflow run as tags and a `denpex_diagnosis.md` artifact, then re-raises
8
+ your original exception so nothing is hidden.
9
+
10
+ ## Install
11
+ ```bash
12
+ pip install denpex-mlflow
13
+ ```
14
+
15
+ ## Use
16
+ ```python
17
+ import mlflow
18
+ import denpex_mlflow as denpex
19
+
20
+ with mlflow.start_run():
21
+ with denpex.watch(job_name="llama-7b-sft"):
22
+ train() # if this raises, Denpex diagnoses it and tags the run
23
+ ```
24
+
25
+ Decorator form:
26
+ ```python
27
+ @denpex.watch_run("llama-7b-sft")
28
+ def main():
29
+ train()
30
+ ```
31
+
32
+ One-shot (no MLflow needed):
33
+ ```python
34
+ diag = denpex.diagnose(open("train.err").read())
35
+ print(diag.failure_type, diag.confidence, diag.action)
36
+ ```
37
+
38
+ ## Auth
39
+ Set an API key (create one at **[denpex.com/dashboard](https://denpex.com/dashboard)** under API keys):
40
+ ```bash
41
+ export DENPEX_API_KEY=dpx_...
42
+ # optional, for self-hosted / staging:
43
+ export DENPEX_API_URL=https://api.denpex.com
44
+ ```
45
+
46
+ ## What gets written to the run
47
+ | MLflow tag | Value |
48
+ |---|---|
49
+ | `denpex.failure_type` | e.g. `NCCL_TIMEOUT`, `CUDA_OOM`, `GPU_XID_ECC` |
50
+ | `denpex.confidence` | 0 to 100 |
51
+ | `denpex.fix_summary` | the root cause (truncated) |
52
+ | `denpex.diagnosis_id` | link back to [denpex.com/dashboard](https://denpex.com/dashboard) |
53
+ | artifact `denpex_diagnosis.md` | full root cause + fix |
54
+
55
+ ## Notes
56
+ - **Best-effort & non-masking:** if Denpex is unreachable, unauthenticated, or over the
57
+ free-plan quota, it logs one line to stderr and still re-raises your real error.
58
+ - `KeyboardInterrupt` / `SystemExit` are passed through undiagnosed.
59
+ - The diagnosis runs through the same `/api/diagnose` endpoint as the rest of Denpex, so
60
+ it also lands in your dashboard history and fires your configured alert channels.
61
+ - Pass `logfile="train.err"` to `watch()` to include the tail of a log file alongside the
62
+ traceback.
63
+
64
+ ---
65
+
66
+ ## Official Denpex Ecosystem & Documentation
67
+
68
+ | Resource | Description | Link |
69
+ | :--- | :--- | :--- |
70
+ | **Denpex Platform** | Deterministic GPU and ML training failure diagnostics | [denpex.com](https://denpex.com) |
71
+ | **Documentation Hub** | Quickstart guides, architecture, and operational reference | [denpex.com/docs](https://denpex.com/docs) |
72
+ | **Python Agent & CLI** | On-host monitoring, evidence capture, and zero-egress CLI | [denpex.com/docs/agent](https://denpex.com/docs/agent) |
73
+ | **REST API Reference** | Programmatic `/api/diagnose` and preflight endpoints | [denpex.com/docs/api](https://denpex.com/docs/api) |
74
+ | **VS Code Extension** | Offline GPU crash diagnostics in VS Code | [denpex.com/vscode](https://denpex.com/vscode) |
75
+ | **Open VSX Extension** | Extension for Cursor, Windsurf, VSCodium, and Gitpod | [denpex.com/openvsx](https://denpex.com/openvsx) |
76
+ | **Failure Encyclopedia** | 500+ documented CUDA, NCCL, Xid, Slurm, and vLLM failures | [denpex.com/failures](https://denpex.com/failures) |
77
+ | **Free GPU Tools** | VRAM, NCCL, DeepSpeed ZeRO, and Slurm calculators | [denpex.com/tools](https://denpex.com/tools) |
78
+ | **NVIDIA Xid Reference** | Hardware vs software Xid fault routing table | [denpex.com/xid](https://denpex.com/xid) |
79
+ | **Release Changelog** | Platform, agent, SDK, and extension release notes | [denpex.com/changelog](https://denpex.com/changelog) |
80
+
@@ -0,0 +1,31 @@
1
+ """denpex-mlflow — attach Denpex GPU/training-failure diagnoses to your MLflow runs.
2
+
3
+ import denpex_mlflow as denpex
4
+ with denpex.watch(job_name="llama-7b-sft"):
5
+ train()
6
+
7
+ On failure, Denpex diagnoses the crash and writes the root cause + fix to the active
8
+ MLflow run as tags (`denpex.failure_type`, `denpex.fix_summary`, …) and a
9
+ `denpex_diagnosis.md` artifact, then re-raises your original exception.
10
+ """
11
+ from ._client import (
12
+ DenpexAuthError,
13
+ DenpexClient,
14
+ DenpexError,
15
+ DenpexQuotaError,
16
+ Diagnosis,
17
+ )
18
+ from .watch import diagnose, watch, watch_run
19
+
20
+ __version__ = "0.1.0"
21
+ __all__ = [
22
+ "watch",
23
+ "watch_run",
24
+ "diagnose",
25
+ "DenpexClient",
26
+ "Diagnosis",
27
+ "DenpexError",
28
+ "DenpexAuthError",
29
+ "DenpexQuotaError",
30
+ "__version__",
31
+ ]
@@ -0,0 +1,233 @@
1
+ """Zero-dependency client for the Denpex diagnosis API.
2
+
3
+ Targets the live `POST /api/diagnose` endpoint (API-key auth via the `X-Denpex-Key`
4
+ header). This is the same path the website console and the cluster agent use, so a diagnosis
5
+ from here is saved to your history and fans out to your alert channels too.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, List, Optional
16
+
17
+ from .evidence import select_evidence
18
+
19
+ DEFAULT_BASE_URL = os.environ.get("DENPEX_API_URL", "https://api.denpex.com")
20
+ MAX_LOG_CHARS = 50_000
21
+
22
+
23
+ class DenpexError(Exception):
24
+ """Base error for any diagnosis failure."""
25
+
26
+
27
+ class DenpexAuthError(DenpexError):
28
+ """Missing/invalid API key (HTTP 401)."""
29
+
30
+
31
+ class DenpexQuotaError(DenpexError):
32
+ """Monthly free-plan quota exhausted (HTTP 429, UPGRADE_REQUIRED)."""
33
+
34
+
35
+ @dataclass
36
+ class Diagnosis:
37
+ """The canonical diagnosis contract, as every other Denpex surface returns it.
38
+
39
+ This carried six fields: type, confidence, summary, action, source, and id. It
40
+ silently discarded the rest of the response. What it dropped is the half an
41
+ operator acts on: the fallback when the primary fix does not work, the
42
+ emergency stopgap, the command that PROVES recovery, the evidence the verdict
43
+ rests on, which rank/GPU initiated the failure, whether a checkpoint is safe
44
+ to resume from, and the questions the engine asks when it cannot decide.
45
+
46
+ An MLflow user therefore saw a confident one-line answer with no escalation
47
+ path and no way to check whether it worked, from the same API call that
48
+ returned all of it.
49
+ """
50
+
51
+ failure_type: str
52
+ # CALIBRATED display value, not the raw score. `meta.confidence` includes
53
+ # bands measured to be unreliable; `fix.confidenceDisplay` is null when the
54
+ # band is not accurate enough to show, and None here means "withheld", never
55
+ # "zero". Printing the raw number re-adds a band with known-bad precision.
56
+ confidence: Optional[int]
57
+ summary: str
58
+ action: str
59
+ source: Optional[str] = None
60
+ diagnosis_id: Optional[str] = None
61
+
62
+ # ── The escalation ladder ──
63
+ fallback_action: Optional[str] = None
64
+ emergency_action: Optional[str] = None
65
+ verification_command: Optional[str] = None
66
+ resume_from: Optional[str] = None
67
+
68
+ # ── The reasoning trail ──
69
+ evidence: List[str] = field(default_factory=list)
70
+ initiator: Optional[str] = None
71
+ competing_hypotheses: List[str] = field(default_factory=list)
72
+ clarifying_questions: List[str] = field(default_factory=list)
73
+
74
+ def as_markdown(self) -> str:
75
+ confidence = f"{self.confidence}%" if self.confidence is not None else "withheld (calibration band too wide to report)"
76
+ parts = [
77
+ f"# Denpex diagnosis: {self.failure_type}\n",
78
+ f"**Confidence:** {confidence}\n",
79
+ ]
80
+ if self.initiator:
81
+ parts.append(f"**Initiating failure:** {self.initiator}\n")
82
+ parts.append(f"## Root cause\n{self.summary or '_n/a_'}\n")
83
+ parts.append(f"## Fix\n{self.action or '_n/a_'}\n")
84
+ if self.fallback_action:
85
+ parts.append(f"## If that does not work\n{self.fallback_action}\n")
86
+ if self.emergency_action:
87
+ parts.append(f"## Emergency stopgap\n{self.emergency_action}\n")
88
+ if self.verification_command:
89
+ # An exit code is not proof of recovery; this is what proves it.
90
+ parts.append(f"## Verify the fix worked\n```\n{self.verification_command}\n```\n")
91
+ if self.resume_from:
92
+ parts.append(f"## Safe resume point\n`{self.resume_from}`\n")
93
+ if self.evidence:
94
+ parts.append("## Evidence\n" + "\n".join(f"- {e}" for e in self.evidence) + "\n")
95
+ if self.competing_hypotheses:
96
+ parts.append("## Also considered\n" + "\n".join(f"- {h}" for h in self.competing_hypotheses) + "\n")
97
+ if self.clarifying_questions:
98
+ parts.append(
99
+ "## To narrow this down\n"
100
+ + "\n".join(f"- {q}" for q in self.clarifying_questions)
101
+ + "\n"
102
+ )
103
+ if self.diagnosis_id:
104
+ parts.append(f"\n_Diagnosis `{self.diagnosis_id}` · https://denpex.com/dashboard_\n")
105
+ return "\n".join(parts)
106
+
107
+
108
+ class DenpexClient:
109
+ """Thin client over POST /api/diagnose with bounded retry/backoff."""
110
+
111
+ def __init__(
112
+ self,
113
+ api_key: Optional[str] = None,
114
+ base_url: str = DEFAULT_BASE_URL,
115
+ # 300s, not 120s. The canonical deep tier has a 290s deadline, so a
116
+ # 120s client timeout abandoned answers the server was still producing
117
+ # and surfaced them to the user as a failure.
118
+ timeout: float = 300.0,
119
+ retries: int = 2,
120
+ backoff: float = 0.5,
121
+ ):
122
+ self.api_key = api_key if api_key is not None else os.environ.get("DENPEX_API_KEY", "")
123
+ self.base_url = base_url.rstrip("/")
124
+ self.timeout = timeout
125
+ self.retries = retries
126
+ self.backoff = backoff
127
+
128
+ def diagnose(self, logs: str, job_name: Optional[str] = None) -> Diagnosis:
129
+ if not self.api_key:
130
+ raise DenpexAuthError("DENPEX_API_KEY is not set (get a key at denpex.com/dashboard).")
131
+ if not logs or len(logs.strip()) < 6:
132
+ raise DenpexError("Provide the original machine event or failure log to diagnose.")
133
+
134
+ selected_logs, _truncated = select_evidence(logs, MAX_LOG_CHARS)
135
+ payload = json.dumps({"logs": selected_logs, "jobName": job_name}).encode("utf-8")
136
+ last_err: Optional[DenpexError] = None
137
+
138
+ for attempt in range(self.retries + 1):
139
+ req = urllib.request.Request(
140
+ self.base_url + "/api/diagnose",
141
+ data=payload,
142
+ method="POST",
143
+ headers={
144
+ "Content-Type": "application/json",
145
+ "X-Denpex-Key": self.api_key,
146
+ "User-Agent": "Denpex-MLflow/0.1",
147
+ },
148
+ )
149
+ try:
150
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
151
+ data = json.loads(r.read().decode("utf-8") or "{}")
152
+ return self._parse(data)
153
+ except urllib.error.HTTPError as e:
154
+ body = self._read_err(e)
155
+ if e.code == 401:
156
+ raise DenpexAuthError("Invalid DENPEX_API_KEY.") from None
157
+ if e.code == 429 and body.get("code") == "UPGRADE_REQUIRED":
158
+ raise DenpexQuotaError(body.get("error", "Monthly quota exceeded.")) from None
159
+ if e.code == 400:
160
+ raise DenpexError(body.get("guidance") or body.get("error", "Bad request.")) from None
161
+ # 429 rate-limit or 5xx -> retryable
162
+ last_err = DenpexError(body.get("error", f"HTTP {e.code}"))
163
+ if not (e.code == 429 or 500 <= e.code < 600):
164
+ raise last_err from None
165
+ except urllib.error.URLError as e:
166
+ last_err = DenpexError(f"Network error: {e.reason}")
167
+ except (TimeoutError, OSError) as e:
168
+ last_err = DenpexError(f"Connection error: {e}")
169
+
170
+ if attempt < self.retries:
171
+ time.sleep(self.backoff * (2 ** attempt))
172
+
173
+ raise last_err or DenpexError("Diagnosis failed.")
174
+
175
+ @staticmethod
176
+ def _read_err(e: urllib.error.HTTPError) -> dict:
177
+ try:
178
+ return json.loads(e.read().decode("utf-8"))
179
+ except Exception:
180
+ return {}
181
+
182
+ @staticmethod
183
+ def _parse(data: dict) -> Diagnosis:
184
+ if not data.get("success"):
185
+ raise DenpexError(data.get("error", "Diagnosis failed."))
186
+ meta = data.get("meta") or {}
187
+ fix = data.get("fix") or {}
188
+
189
+ def _strs(value: Any, limit: int = 12) -> List[str]:
190
+ """Response fields are untrusted input even from our own API."""
191
+ if not isinstance(value, list):
192
+ return []
193
+ out = []
194
+ for item in value[:limit]:
195
+ if isinstance(item, str) and item.strip():
196
+ out.append(item.strip())
197
+ elif isinstance(item, dict):
198
+ # clarifyingChoices / rankedHypotheses arrive as objects; take
199
+ # the human-readable field rather than dropping them entirely.
200
+ text = item.get("question") or item.get("theory") or item.get("label")
201
+ if isinstance(text, str) and text.strip():
202
+ out.append(text.strip())
203
+ return out
204
+
205
+ # `confidenceDisplay` is the calibrated value and may legitimately be
206
+ # null. That means "withheld", which is not the same as zero, so it is
207
+ # NOT coerced with `or 0`. Falling back to meta.confidence only when the
208
+ # key is absent entirely keeps older responses working.
209
+ display = fix.get("confidenceDisplay", "__missing__")
210
+ if display == "__missing__":
211
+ raw = meta.get("confidence")
212
+ confidence = int(raw) if isinstance(raw, (int, float)) else None
213
+ elif display is None:
214
+ confidence = None
215
+ else:
216
+ confidence = int(display)
217
+
218
+ return Diagnosis(
219
+ failure_type=meta.get("failureType", "UNKNOWN"),
220
+ confidence=confidence,
221
+ summary=fix.get("summary", ""),
222
+ action=fix.get("action", ""),
223
+ source=meta.get("source"),
224
+ diagnosis_id=data.get("diagnosisId"),
225
+ fallback_action=fix.get("fallbackAction") or None,
226
+ emergency_action=fix.get("emergencyAction") or None,
227
+ verification_command=fix.get("verificationCommand") or None,
228
+ resume_from=fix.get("resumeFrom") or None,
229
+ evidence=_strs(fix.get("evidence") or meta.get("evidence")),
230
+ initiator=(fix.get("initiator") or meta.get("initiator") or None),
231
+ competing_hypotheses=_strs(fix.get("competingHypotheses") or fix.get("rankedHypotheses")),
232
+ clarifying_questions=_strs(fix.get("clarifyingChoices") or fix.get("clarifyingQuestions")),
233
+ )
@@ -0,0 +1,72 @@
1
+ """Bounded causal evidence selection for the MLflow integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Dict, Tuple
7
+
8
+ _EVIDENCE = re.compile(
9
+ r"error|fail|fatal|traceback|exception|assert|panic|segfault|xid|nvrm|ecc|"
10
+ r"fallen off the bus|nccl|cuda|oom|out of memory|timeout|nan\b|\binf\b|"
11
+ r"node_fail|killed|terminated|version|driver|kernel|container|slurm|kubernetes|"
12
+ r"rank|node|host|gpu|pci|topology|nvlink|nvswitch|infiniband|rdma|roce",
13
+ re.I,
14
+ )
15
+ _CAUSAL = re.compile(
16
+ r"fatal|critical|traceback|exception|assert|panic|segfault|xid|uncorrectable|"
17
+ r"fallen off the bus|out of memory|watchdog timeout|crc mismatch|loss\s*=\s*(?:nan|inf)|"
18
+ r"insufficient|unhealthy|ibv_modify_qp|no space left|compil(?:e|ation) (?:error|failed)|"
19
+ r"no available memory|checksum mismatch|childfailederror|backendcompilerfailed|"
20
+ r"failedscheduling|input/output error|nonfinite|divergence|first failure|initiator|caused by",
21
+ re.I,
22
+ )
23
+
24
+
25
+ def _clip_line(line: str, maximum: int = 2000) -> str:
26
+ if len(line) <= maximum:
27
+ return line
28
+ marker = " [line clipped by Denpex] "
29
+ side = max(1, (maximum - len(marker)) // 2)
30
+ return (line[:side] + marker + line[-side:])[:maximum]
31
+
32
+
33
+ def select_evidence(logs: str, maximum: int) -> Tuple[str, bool]:
34
+ if len(logs) <= maximum:
35
+ return logs, False
36
+ lines = logs.splitlines()
37
+ candidates: Dict[int, int] = {}
38
+
39
+ def add(index: int, radius: int, priority: int) -> None:
40
+ for current in range(max(0, index - radius), min(len(lines), index + radius + 1)):
41
+ candidates[current] = max(priority, candidates.get(current, 0))
42
+
43
+ for index in range(min(48, len(lines))):
44
+ candidates[index] = 300
45
+ for index in range(max(0, len(lines) - 96), len(lines)):
46
+ candidates[index] = 800
47
+
48
+ found_first = False
49
+ for index, line in enumerate(lines):
50
+ if not _EVIDENCE.search(line):
51
+ continue
52
+ causal = bool(_CAUSAL.search(line))
53
+ add(index, 3 if causal else 1, 1000 if causal else 700)
54
+ if not found_first:
55
+ add(index, 6, 1100)
56
+ found_first = True
57
+
58
+ warning = (
59
+ f"[Denpex condensed input: input exceeded {maximum:,} characters. "
60
+ "Environment, causal, rank, topology, middle, and final-tail evidence were selected.]"
61
+ )
62
+ used = len(warning)
63
+ selected = set()
64
+ for index, _priority in sorted(candidates.items(), key=lambda item: (-item[1], item[0])):
65
+ line = _clip_line(lines[index])
66
+ if used + len(line) + 1 > maximum:
67
+ continue
68
+ selected.add(index)
69
+ used += len(line) + 1
70
+ output = [warning]
71
+ output.extend(_clip_line(lines[index]) for index in sorted(selected))
72
+ return "\n".join(output), True
@@ -0,0 +1,116 @@
1
+ """`denpex.watch()` — wrap a training run so that, on failure, Denpex diagnoses the
2
+ crash and attaches the root cause + fix straight to the active MLflow run.
3
+
4
+ import denpex_mlflow as denpex
5
+ with denpex.watch(job_name="llama-7b-sft"):
6
+ train() # raises -> diagnosed, tagged, re-raised
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import os
12
+ import sys
13
+ import traceback
14
+ from contextlib import contextmanager
15
+ from typing import Optional
16
+
17
+ from ._client import DenpexClient, Diagnosis, DenpexError
18
+
19
+
20
+ def _collect_logs(exc: BaseException, logfile: Optional[str], tail_lines: int = 200) -> str:
21
+ parts = []
22
+ if logfile:
23
+ try:
24
+ with open(logfile, "r", errors="replace") as f:
25
+ parts.append(f.read())
26
+ except OSError:
27
+ pass
28
+ parts.append("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
29
+ return "\n".join(p for p in parts if p and p.strip())
30
+
31
+
32
+ def _attach_to_mlflow(diag: Diagnosis) -> bool:
33
+ """Write the diagnosis to the active MLflow run as tags + an artifact. No-op if
34
+ mlflow isn't installed or there's no active run. Never raises."""
35
+ try:
36
+ import mlflow
37
+ except Exception:
38
+ return False
39
+ try:
40
+ if mlflow.active_run() is None:
41
+ return False
42
+ mlflow.set_tag("denpex.failure_type", diag.failure_type)
43
+ mlflow.set_tag("denpex.confidence", diag.confidence)
44
+ mlflow.set_tag("denpex.fix_summary", (diag.summary or "")[:500])
45
+ if diag.diagnosis_id:
46
+ mlflow.set_tag("denpex.diagnosis_id", diag.diagnosis_id)
47
+ mlflow.log_text(diag.as_markdown(), "denpex_diagnosis.md")
48
+ return True
49
+ except Exception:
50
+ return False
51
+
52
+
53
+ def _print_summary(diag: Diagnosis, attached: bool) -> None:
54
+ where = " (attached to MLflow run)" if attached else ""
55
+ # ASCII only — this writes to whatever stderr the training box has (may be cp1252/ascii).
56
+ sys.stderr.write(
57
+ f"\n[denpex] {diag.failure_type} - {diag.confidence}% confidence{where}\n"
58
+ f"[denpex] root cause: {diag.summary}\n"
59
+ f"[denpex] fix: {diag.action}\n"
60
+ )
61
+
62
+
63
+ @contextmanager
64
+ def watch(
65
+ job_name: Optional[str] = None,
66
+ *,
67
+ api_key: Optional[str] = None,
68
+ base_url: Optional[str] = None,
69
+ logfile: Optional[str] = None,
70
+ reraise: bool = True,
71
+ client: Optional[DenpexClient] = None,
72
+ ):
73
+ """Context manager that diagnoses any exception raised inside it.
74
+
75
+ The diagnosis is best-effort and never masks your original error: if Denpex is
76
+ unreachable or unauthenticated we log a line to stderr and still re-raise.
77
+ KeyboardInterrupt / SystemExit pass straight through, undiagnosed.
78
+ """
79
+ try:
80
+ yield
81
+ except (KeyboardInterrupt, SystemExit):
82
+ raise
83
+ except BaseException as exc: # noqa: BLE001 - we re-raise below
84
+ try:
85
+ c = client or DenpexClient(
86
+ api_key=api_key if api_key is not None else os.environ.get("DENPEX_API_KEY", ""),
87
+ base_url=base_url or os.environ.get("DENPEX_API_URL", "https://api.denpex.com"),
88
+ )
89
+ diag = c.diagnose(_collect_logs(exc, logfile), job_name=job_name)
90
+ _print_summary(diag, _attach_to_mlflow(diag))
91
+ except DenpexError as inner:
92
+ sys.stderr.write(f"[denpex] diagnosis skipped: {inner}\n")
93
+ except Exception as inner: # never let the SDK mask the real failure
94
+ sys.stderr.write(f"[denpex] diagnosis error: {inner}\n")
95
+ if reraise:
96
+ raise
97
+
98
+
99
+ def watch_run(job_name: Optional[str] = None, **watch_kwargs):
100
+ """Decorator form: `@denpex.watch_run("my-job")` around a training entrypoint."""
101
+ def decorator(fn):
102
+ @functools.wraps(fn)
103
+ def wrapper(*args, **kwargs):
104
+ with watch(job_name=job_name or fn.__name__, **watch_kwargs):
105
+ return fn(*args, **kwargs)
106
+ return wrapper
107
+ return decorator
108
+
109
+
110
+ def diagnose(logs: str, job_name: Optional[str] = None, *, api_key: Optional[str] = None, base_url: Optional[str] = None) -> Diagnosis:
111
+ """One-shot convenience: diagnose a log string directly."""
112
+ client = DenpexClient(
113
+ api_key=api_key if api_key is not None else os.environ.get("DENPEX_API_KEY", ""),
114
+ base_url=base_url or os.environ.get("DENPEX_API_URL", "https://api.denpex.com"),
115
+ )
116
+ return client.diagnose(logs, job_name=job_name)
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "denpex-mlflow"
7
+ version = "0.1.0"
8
+ description = "Attach Denpex GPU and training-failure diagnoses to your MLflow runs in one line."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Denpex" }]
13
+ keywords = ["mlflow", "gpu", "training", "diagnosis", "denpex", "nccl", "cuda", "pytorch"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
18
+ ]
19
+ # Runtime is intentionally light: the client uses only the stdlib. mlflow is the host
20
+ # tool you're already running; it's imported lazily so the client also works standalone.
21
+ dependencies = ["mlflow>=2.0"]
22
+
23
+ [project.urls]
24
+ "Homepage" = "https://denpex.com"
25
+ "Documentation" = "https://denpex.com/docs/integrations"
26
+ "API Reference" = "https://denpex.com/docs/api"
27
+ "Python Agent & CLI" = "https://denpex.com/docs/agent"
28
+ "VS Code Extension" = "https://denpex.com/vscode"
29
+ "Open VSX Extension" = "https://denpex.com/openvsx"
30
+ "GPU Failure Encyclopedia" = "https://denpex.com/failures"
31
+ "Free GPU Debugging Tools" = "https://denpex.com/tools"
32
+ "Changelog" = "https://denpex.com/changelog"
33
+ "Repository" = "https://github.com/Denpex/gpu-failure-cheatsheet"
34
+ "Issue Tracker" = "https://denpex.com/contact"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["denpex_mlflow"]
@@ -0,0 +1,288 @@
1
+ """Unit tests for denpex-mlflow. Stdlib unittest and mock only. No network or real
2
+ mlflow (a fake module is injected). Run from the package root:
3
+
4
+ cd packages/denpex-mlflow && python -m unittest discover -s tests -v
5
+ """
6
+ import io
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ import types
11
+ import unittest
12
+ import urllib.error
13
+ from unittest import mock
14
+
15
+ # Make the package importable when run from the package root.
16
+ sys.path.insert(0, ".")
17
+
18
+ import denpex_mlflow as denpex # noqa: E402
19
+ from denpex_mlflow._client import DenpexClient # noqa: E402
20
+
21
+
22
+ def _ok_response(payload: dict):
23
+ body = io.BytesIO()
24
+ import json
25
+ body.write(json.dumps(payload).encode())
26
+ body.seek(0)
27
+ cm = mock.MagicMock()
28
+ cm.__enter__.return_value.read.return_value = body.getvalue()
29
+ cm.__exit__.return_value = False
30
+ return cm
31
+
32
+
33
+ def _http_error(code: int, payload: dict):
34
+ import json
35
+ return urllib.error.HTTPError(
36
+ url="https://api.denpex.com/api/diagnose", code=code, msg="err",
37
+ hdrs=None, fp=io.BytesIO(json.dumps(payload).encode()),
38
+ )
39
+
40
+
41
+ SUCCESS = {
42
+ "success": True,
43
+ "diagnosisId": "diag123",
44
+ "fix": {"summary": "Rank 3 stalled the all-reduce.", "action": "Restart with NCCL_TIMEOUT=6000."},
45
+ "meta": {"failureType": "NCCL_TIMEOUT", "confidence": 91, "source": "pattern", "tier": "fast"},
46
+ }
47
+
48
+
49
+ class ClientTests(unittest.TestCase):
50
+ def test_diagnose_success_parses_and_sends_key(self):
51
+ c = DenpexClient(api_key="dpx_test")
52
+ with mock.patch("urllib.request.urlopen", return_value=_ok_response(SUCCESS)) as uo:
53
+ diag = c.diagnose("a" * 50, job_name="job-1")
54
+ self.assertEqual(diag.failure_type, "NCCL_TIMEOUT")
55
+ self.assertEqual(diag.confidence, 91)
56
+ self.assertEqual(diag.diagnosis_id, "diag123")
57
+ self.assertIn("NCCL_TIMEOUT", diag.as_markdown())
58
+ # request carried the API key + correct path
59
+ req = uo.call_args.args[0]
60
+ self.assertEqual(req.get_header("X-denpex-key"), "dpx_test")
61
+ self.assertEqual(req.get_header("User-agent"), "Denpex-MLflow/0.1")
62
+ self.assertTrue(req.full_url.endswith("/api/diagnose"))
63
+
64
+ def test_short_logs_rejected_without_network(self):
65
+ c = DenpexClient(api_key="dpx_test")
66
+ with mock.patch("urllib.request.urlopen", side_effect=AssertionError("should not call")):
67
+ with self.assertRaises(denpex.DenpexError):
68
+ c.diagnose("oom")
69
+
70
+ def test_missing_api_key(self):
71
+ with self.assertRaises(denpex.DenpexAuthError):
72
+ DenpexClient(api_key="").diagnose("a" * 50)
73
+
74
+ def test_auth_error_401(self):
75
+ c = DenpexClient(api_key="dpx_bad")
76
+ with mock.patch("urllib.request.urlopen", side_effect=_http_error(401, {"error": "Unauthorized"})):
77
+ with self.assertRaises(denpex.DenpexAuthError):
78
+ c.diagnose("a" * 50)
79
+
80
+ def test_quota_error_429_upgrade_required(self):
81
+ c = DenpexClient(api_key="dpx_test")
82
+ with mock.patch("urllib.request.urlopen", side_effect=_http_error(429, {"error": "Quota exceeded", "code": "UPGRADE_REQUIRED"})):
83
+ with self.assertRaises(denpex.DenpexQuotaError):
84
+ c.diagnose("a" * 50)
85
+
86
+ def test_input_quality_error_preserves_guidance(self):
87
+ c = DenpexClient(api_key="dpx_test")
88
+ response = _http_error(400, {
89
+ "error": "Input rejected.",
90
+ "code": "INSUFFICIENT_DIAGNOSTIC_SIGNAL",
91
+ "guidance": "Paste the original CUDA failure line.",
92
+ })
93
+ with mock.patch("urllib.request.urlopen", side_effect=response):
94
+ with self.assertRaisesRegex(denpex.DenpexError, "original CUDA failure"):
95
+ c.diagnose("hello hello")
96
+
97
+ def test_retry_on_5xx_then_success(self):
98
+ c = DenpexClient(api_key="dpx_test", retries=1, backoff=0)
99
+ seq = [_http_error(503, {"error": "boom"}), _ok_response(SUCCESS)]
100
+ with mock.patch("urllib.request.urlopen", side_effect=seq), mock.patch("time.sleep"):
101
+ diag = c.diagnose("a" * 50)
102
+ self.assertEqual(diag.failure_type, "NCCL_TIMEOUT")
103
+
104
+
105
+ class FakeClient:
106
+ def __init__(self):
107
+ self.calls = []
108
+
109
+ def diagnose(self, logs, job_name=None):
110
+ self.calls.append((logs, job_name))
111
+ return denpex.Diagnosis(failure_type="CUDA_OOM", confidence=88, summary="OOM at step 42", action="Lower batch size.", diagnosis_id="d1")
112
+
113
+
114
+ class WatchTests(unittest.TestCase):
115
+ def setUp(self):
116
+ # Inject a fake mlflow module that records tag/artifact writes.
117
+ self.tags = {}
118
+ self.artifacts = {}
119
+ fake = types.ModuleType("mlflow")
120
+ fake.active_run = lambda: object()
121
+ fake.set_tag = lambda k, v: self.tags.__setitem__(k, v)
122
+ fake.log_text = lambda text, path: self.artifacts.__setitem__(path, text)
123
+ self._saved = sys.modules.get("mlflow")
124
+ sys.modules["mlflow"] = fake
125
+
126
+ def tearDown(self):
127
+ if self._saved is not None:
128
+ sys.modules["mlflow"] = self._saved
129
+ else:
130
+ sys.modules.pop("mlflow", None)
131
+
132
+ def test_watch_diagnoses_attaches_and_reraises(self):
133
+ fc = FakeClient()
134
+ with self.assertRaises(ValueError):
135
+ with denpex.watch(job_name="jx", client=fc):
136
+ raise ValueError("boom at step 42")
137
+ self.assertEqual(len(fc.calls), 1)
138
+ self.assertIn("boom at step 42", fc.calls[0][0]) # traceback captured
139
+ self.assertEqual(fc.calls[0][1], "jx") # job_name passed through
140
+ self.assertEqual(self.tags["denpex.failure_type"], "CUDA_OOM")
141
+ self.assertIn("denpex_diagnosis.md", self.artifacts)
142
+
143
+ def test_watch_passes_complete_log_to_the_bounded_selector(self):
144
+ fc = FakeClient()
145
+ handle, path = tempfile.mkstemp(prefix="denpex-mlflow-", suffix=".log")
146
+ os.close(handle)
147
+ try:
148
+ with open(path, "w", encoding="utf-8") as stream:
149
+ stream.write("PyTorch version: 2.7.1\n")
150
+ stream.write("ordinary progress\n" * 400)
151
+ stream.write("[rank 7][node gpu-03] first failure: NCCL timeout\n")
152
+ with self.assertRaises(RuntimeError):
153
+ with denpex.watch(job_name="complete-log", client=fc, logfile=path):
154
+ raise RuntimeError("final fatal tail")
155
+ sent = fc.calls[0][0]
156
+ self.assertIn("PyTorch version: 2.7.1", sent)
157
+ self.assertIn("first failure: NCCL timeout", sent)
158
+ self.assertIn("final fatal tail", sent)
159
+ finally:
160
+ os.unlink(path)
161
+
162
+ def test_watch_keyboardinterrupt_passes_through_undiagnosed(self):
163
+ fc = FakeClient()
164
+ with self.assertRaises(KeyboardInterrupt):
165
+ with denpex.watch(client=fc):
166
+ raise KeyboardInterrupt()
167
+ self.assertEqual(fc.calls, [])
168
+
169
+ def test_watch_swallows_sdk_errors_but_reraises_original(self):
170
+ class Boom:
171
+ def diagnose(self, *a, **k):
172
+ raise denpex.DenpexError("api down")
173
+ with self.assertRaises(RuntimeError):
174
+ with denpex.watch(client=Boom()):
175
+ raise RuntimeError("real failure")
176
+
177
+
178
+ if __name__ == "__main__":
179
+ unittest.main()
180
+
181
+
182
+ # ── The canonical diagnosis contract (2026-08-21) ────────────────────────────
183
+ #
184
+ # The client parsed six fields and discarded the rest of the response: the
185
+ # fallback fix, the emergency stopgap, the command that PROVES recovery, the
186
+ # evidence, the initiating rank/GPU, the safe resume point, and the engine's
187
+ # clarifying questions. An MLflow user got a confident one-liner with no
188
+ # escalation path and no way to check whether it worked, from the same API
189
+ # call that returned all of it.
190
+ #
191
+ # It also printed `meta.confidence`, the RAW score. The calibrated value is
192
+ # `fix.confidenceDisplay`, which is deliberately null when the band is not
193
+ # accurate enough to report; rendering the raw number re-adds a band measured
194
+ # to be unreliable.
195
+
196
+ from denpex_mlflow._client import DenpexClient
197
+
198
+
199
+ def _full_response():
200
+ return {
201
+ "success": True,
202
+ "diagnosisId": "dx_1",
203
+ "meta": {"failureType": "GPU_XID_ECC_CASCADE", "confidence": 95, "source": "cascade"},
204
+ "fix": {
205
+ "summary": "Rank 3 failed first; the other timeouts are collateral.",
206
+ "action": "Drain the node and reset the GPU.",
207
+ "confidenceDisplay": 90,
208
+ "fallbackAction": "If the Xid recurs after reset, open an RMA.",
209
+ "emergencyAction": "Cordon the node now to stop new work landing on it.",
210
+ "verificationCommand": "nvidia-smi -q -d ECC",
211
+ "resumeFrom": "step_9000",
212
+ "evidence": ["NVRM: Xid (PCI:0000:1a:00): 48"],
213
+ "initiator": "rank 3 / GPU-aaaabbbb",
214
+ "competingHypotheses": [{"theory": "NCCL fabric flap"}],
215
+ "clarifyingChoices": [{"question": "Does it recur after a reset?"}],
216
+ },
217
+ }
218
+
219
+
220
+ def test_uses_the_calibrated_confidence_not_the_raw_score():
221
+ d = DenpexClient._parse(_full_response())
222
+ assert d.confidence == 90, "must use fix.confidenceDisplay, not meta.confidence (95)"
223
+
224
+
225
+ def test_withheld_confidence_is_none_not_zero():
226
+ # None means "we decline to give a number"; 0 means "we are certain it is
227
+ # zero percent". Coercing with `or 0` prints the second and means the first.
228
+ r = _full_response()
229
+ r["fix"]["confidenceDisplay"] = None
230
+ d = DenpexClient._parse(r)
231
+ assert d.confidence is None
232
+ assert "withheld" in d.as_markdown()
233
+ assert "0%" not in d.as_markdown()
234
+
235
+
236
+ def test_falls_back_to_meta_confidence_for_older_responses():
237
+ r = _full_response()
238
+ del r["fix"]["confidenceDisplay"]
239
+ assert DenpexClient._parse(r).confidence == 95
240
+
241
+
242
+ def test_carries_the_whole_escalation_ladder():
243
+ d = DenpexClient._parse(_full_response())
244
+ assert d.fallback_action and "RMA" in d.fallback_action
245
+ assert d.emergency_action and "Cordon" in d.emergency_action
246
+ assert d.verification_command == "nvidia-smi -q -d ECC"
247
+ assert d.resume_from == "step_9000"
248
+
249
+
250
+ def test_carries_the_reasoning_trail():
251
+ d = DenpexClient._parse(_full_response())
252
+ assert d.evidence == ["NVRM: Xid (PCI:0000:1a:00): 48"]
253
+ assert d.initiator == "rank 3 / GPU-aaaabbbb"
254
+ # Objects, not strings. The client must read the human-readable field
255
+ # rather than dropping structured entries on the floor.
256
+ assert d.competing_hypotheses == ["NCCL fabric flap"]
257
+ assert d.clarifying_questions == ["Does it recur after a reset?"]
258
+
259
+
260
+ def test_markdown_shows_the_operator_everything_it_received():
261
+ md = DenpexClient._parse(_full_response()).as_markdown()
262
+ for expected in ("If that does not work", "Emergency stopgap", "Verify the fix worked",
263
+ "Safe resume point", "Evidence", "Also considered", "To narrow this down"):
264
+ assert expected in md, f"markdown dropped: {expected}"
265
+
266
+
267
+ def test_a_sparse_response_does_not_render_empty_sections():
268
+ d = DenpexClient._parse({"success": True, "meta": {"failureType": "X", "confidence": 50},
269
+ "fix": {"summary": "s", "action": "a"}})
270
+ md = d.as_markdown()
271
+ assert "If that does not work" not in md
272
+ assert "Verify the fix worked" not in md
273
+
274
+
275
+ def test_malformed_list_fields_do_not_raise():
276
+ # The response is untrusted input even though we serve it.
277
+ r = _full_response()
278
+ r["fix"]["evidence"] = "not a list"
279
+ r["fix"]["clarifyingChoices"] = [None, 42, {"nothing": "useful"}]
280
+ d = DenpexClient._parse(r)
281
+ assert d.evidence == []
282
+ assert d.clarifying_questions == []
283
+
284
+
285
+ def test_client_timeout_outlives_the_server_deadline():
286
+ # The canonical deep tier runs to 290s. A 120s client timeout abandoned
287
+ # answers the server was still producing and reported them as failures.
288
+ assert DenpexClient().timeout >= 290.0