torchwatch-tui 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.
Files changed (43) hide show
  1. torchwatch_tui-0.1.0/.gitignore +18 -0
  2. torchwatch_tui-0.1.0/LICENSE +21 -0
  3. torchwatch_tui-0.1.0/PKG-INFO +138 -0
  4. torchwatch_tui-0.1.0/README.md +105 -0
  5. torchwatch_tui-0.1.0/pyproject.toml +66 -0
  6. torchwatch_tui-0.1.0/tests/fixtures/README.md +14 -0
  7. torchwatch_tui-0.1.0/tests/fixtures/hf_trainer.log +7 -0
  8. torchwatch_tui-0.1.0/tests/fixtures/lightning.log +5 -0
  9. torchwatch_tui-0.1.0/tests/fixtures/noise.log +6 -0
  10. torchwatch_tui-0.1.0/tests/fixtures/plain.log +6 -0
  11. torchwatch_tui-0.1.0/tests/fixtures/tqdm.log +1 -0
  12. torchwatch_tui-0.1.0/tests/test_alert_log.py +65 -0
  13. torchwatch_tui-0.1.0/tests/test_alert_wiring.py +145 -0
  14. torchwatch_tui-0.1.0/tests/test_alerts.py +49 -0
  15. torchwatch_tui-0.1.0/tests/test_app.py +89 -0
  16. torchwatch_tui-0.1.0/tests/test_cli.py +48 -0
  17. torchwatch_tui-0.1.0/tests/test_demo.py +11 -0
  18. torchwatch_tui-0.1.0/tests/test_eta.py +64 -0
  19. torchwatch_tui-0.1.0/tests/test_nvidia.py +17 -0
  20. torchwatch_tui-0.1.0/tests/test_proc.py +48 -0
  21. torchwatch_tui-0.1.0/tests/test_sparkline.py +32 -0
  22. torchwatch_tui-0.1.0/tests/test_stdout_parser.py +75 -0
  23. torchwatch_tui-0.1.0/tests/test_stream.py +44 -0
  24. torchwatch_tui-0.1.0/tests/test_tail.py +76 -0
  25. torchwatch_tui-0.1.0/tests/test_wrapper.py +37 -0
  26. torchwatch_tui-0.1.0/torchwatch/__init__.py +3 -0
  27. torchwatch_tui-0.1.0/torchwatch/alerts.py +141 -0
  28. torchwatch_tui-0.1.0/torchwatch/app.py +264 -0
  29. torchwatch_tui-0.1.0/torchwatch/cli.py +98 -0
  30. torchwatch_tui-0.1.0/torchwatch/collector/__init__.py +1 -0
  31. torchwatch_tui-0.1.0/torchwatch/collector/demo.py +41 -0
  32. torchwatch_tui-0.1.0/torchwatch/collector/nvidia.py +141 -0
  33. torchwatch_tui-0.1.0/torchwatch/collector/proc.py +83 -0
  34. torchwatch_tui-0.1.0/torchwatch/collector/stdout.py +116 -0
  35. torchwatch_tui-0.1.0/torchwatch/collector/stream.py +40 -0
  36. torchwatch_tui-0.1.0/torchwatch/collector/tail.py +130 -0
  37. torchwatch_tui-0.1.0/torchwatch/collector/wrapper.py +114 -0
  38. torchwatch_tui-0.1.0/torchwatch/eta.py +69 -0
  39. torchwatch_tui-0.1.0/torchwatch/widgets/__init__.py +1 -0
  40. torchwatch_tui-0.1.0/torchwatch/widgets/alert_panel.py +42 -0
  41. torchwatch_tui-0.1.0/torchwatch/widgets/eta_bar.py +28 -0
  42. torchwatch_tui-0.1.0/torchwatch/widgets/gpu_panel.py +114 -0
  43. torchwatch_tui-0.1.0/torchwatch/widgets/sparkline.py +62 -0
@@ -0,0 +1,18 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+
8
+ # Virtual environments
9
+ .venv/
10
+ venv/
11
+
12
+ # Tooling caches
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .pytest_cache/
16
+
17
+ # OS
18
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jay Patel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: torchwatch-tui
3
+ Version: 0.1.0
4
+ Summary: A btop for your PyTorch GPU jobs: live terminal dashboard for training runs
5
+ Project-URL: Homepage, https://github.com/JayPatel095/TorchWatch
6
+ Project-URL: Repository, https://github.com/JayPatel095/TorchWatch
7
+ Project-URL: Issues, https://github.com/JayPatel095/TorchWatch/issues
8
+ Author-email: Jay Patel <jayashish.patel@mail.utoronto.ca>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: gpu,monitoring,pytorch,terminal,training,tui
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: System :: Monitoring
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: click
25
+ Requires-Dist: nvidia-ml-py
26
+ Requires-Dist: psutil
27
+ Requires-Dist: textual
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy; extra == 'dev'
30
+ Requires-Dist: pytest; extra == 'dev'
31
+ Requires-Dist: ruff; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # torchwatch
35
+
36
+ A btop for your PyTorch GPU jobs with live per-GPU utilization, VRAM pressure, training
37
+ throughput, loss curve, and ETA, all in your terminal, with zero code changes.
38
+
39
+ ![torchwatch demo](https://raw.githubusercontent.com/JayPatel095/TorchWatch/main/docs/demo.gif)
40
+
41
+ ## Install
42
+
43
+ ```
44
+ pip install torchwatch-tui
45
+ ```
46
+
47
+ (The command and package are `torchwatch`; only the PyPI name carries the suffix.)
48
+
49
+ ## Run
50
+
51
+ The easiest way is to launch your training command under torchwatch:
52
+
53
+ ```
54
+ torchwatch run -- python train.py --epochs 10
55
+ ```
56
+
57
+ Your script runs as a child of torchwatch under a pseudo-terminal. Its output is
58
+ parsed live, and quitting the dashboard (`q`) terminates it.
59
+
60
+ Attach to a job that's already running:
61
+
62
+ ```
63
+ torchwatch list # list running PyTorch processes
64
+ torchwatch --pid 12345 # attach to one of them
65
+ ```
66
+
67
+ Attaching reads the process's stdout via `/proc` (Linux only), and only works when
68
+ stdout is redirected to a file (`python train.py > train.log`). If it isn't,
69
+ torchwatch tells you why and suggests alternatives instead of guessing.
70
+
71
+ Smoke-test the dashboard on synthetic data
72
+
73
+ ```
74
+ torchwatch --demo
75
+ ```
76
+
77
+ ## Current Features
78
+
79
+ - **Per-GPU panels** — utilization, VRAM (used/total and a pressure-colored gauge),
80
+ temperature, and power with one tile per device
81
+ - **Loss sparkline** — scrolling curve of recent loss values
82
+ - **Throughput + ETA** — steps/sec over a rolling window, elapsed time, and a
83
+ time-to-completion estimate through epoch restarts
84
+ - **Alerts area** — appears only when something needs attention, and lingers long
85
+ enough to read:
86
+ - VRAM above 95% on a specific GPU, with concrete fixes (smaller batch size,
87
+ mixed precision, gradient checkpointing)
88
+ - loss stalled (no meaningful change over the last 100 updates)
89
+ - loss spike (latest value far above the recent average)
90
+
91
+ Keys: `q` quit · `p` pause
92
+
93
+ ## Supported log formats
94
+
95
+ torchwatch parses training progress straight from stdout with no imports or callbacks.
96
+ Detected automatically:
97
+
98
+ | format | example line |
99
+ |---|---|
100
+ | tqdm | ` 42/500 [00:12<02:15, 3.4it/s, loss=0.693]` |
101
+ | PyTorch Lightning | `Epoch 3/10: 42/500 [... loss=0.693 ...]` |
102
+ | HF Trainer | `{'loss': 0.693, 'learning_rate': 5e-05, 'epoch': 1.2}` |
103
+ | plain | `step 42/500 loss: 0.693` |
104
+
105
+ The header shows which format was detected. Lines that match nothing are ignored.
106
+
107
+ ## Notes
108
+
109
+ - GPU stats come from NVML (NVIDIA). Without a usable NVML (e.g. macOS, no driver),
110
+ torchwatch falls back to clearly-labeled mock data so the dashboard still runs.
111
+ - `--pid` attach and process detection use `/proc` and process memory maps, so they
112
+ are Linux-only. `torchwatch run` works everywhere.
113
+
114
+ ## Development
115
+
116
+ ```
117
+ pip install -e ".[dev]"
118
+ pytest
119
+ ruff check .
120
+ ```
121
+
122
+ ## Planned
123
+
124
+ Roughly in order of likelihood:
125
+
126
+ - **More alert rules** — NaN/inf loss (the run is dead, say so immediately),
127
+ GPU temperature, sustained throughput drops (often a dataloader bottleneck)
128
+ - **Alert logging** — opt-in `--alert-log alerts.log`: a timestamped record of
129
+ every alert, so a spike at 3am is still explainable at 9am
130
+ - **Configurable thresholds** — `--vram-warn` / `--vram-alert` instead of the
131
+ built-in 85/95
132
+ - **Generic metric sparklines** — track any `key=value` metric your logs already
133
+ print (accuracy, val_loss, grad_norm, …) and chart the ones you pick
134
+ - **Appearance** — theme selection, optional extra GPU stats
135
+ - **Config file** — persistent defaults once the flag count justifies it
136
+ - **Raw output pane** — the wrapped process's actual stdout, scrolling under
137
+ the dashboard
138
+ - **Multi-job dashboard** — one dashboard, several training processes
@@ -0,0 +1,105 @@
1
+ # torchwatch
2
+
3
+ A btop for your PyTorch GPU jobs with live per-GPU utilization, VRAM pressure, training
4
+ throughput, loss curve, and ETA, all in your terminal, with zero code changes.
5
+
6
+ ![torchwatch demo](https://raw.githubusercontent.com/JayPatel095/TorchWatch/main/docs/demo.gif)
7
+
8
+ ## Install
9
+
10
+ ```
11
+ pip install torchwatch-tui
12
+ ```
13
+
14
+ (The command and package are `torchwatch`; only the PyPI name carries the suffix.)
15
+
16
+ ## Run
17
+
18
+ The easiest way is to launch your training command under torchwatch:
19
+
20
+ ```
21
+ torchwatch run -- python train.py --epochs 10
22
+ ```
23
+
24
+ Your script runs as a child of torchwatch under a pseudo-terminal. Its output is
25
+ parsed live, and quitting the dashboard (`q`) terminates it.
26
+
27
+ Attach to a job that's already running:
28
+
29
+ ```
30
+ torchwatch list # list running PyTorch processes
31
+ torchwatch --pid 12345 # attach to one of them
32
+ ```
33
+
34
+ Attaching reads the process's stdout via `/proc` (Linux only), and only works when
35
+ stdout is redirected to a file (`python train.py > train.log`). If it isn't,
36
+ torchwatch tells you why and suggests alternatives instead of guessing.
37
+
38
+ Smoke-test the dashboard on synthetic data
39
+
40
+ ```
41
+ torchwatch --demo
42
+ ```
43
+
44
+ ## Current Features
45
+
46
+ - **Per-GPU panels** — utilization, VRAM (used/total and a pressure-colored gauge),
47
+ temperature, and power with one tile per device
48
+ - **Loss sparkline** — scrolling curve of recent loss values
49
+ - **Throughput + ETA** — steps/sec over a rolling window, elapsed time, and a
50
+ time-to-completion estimate through epoch restarts
51
+ - **Alerts area** — appears only when something needs attention, and lingers long
52
+ enough to read:
53
+ - VRAM above 95% on a specific GPU, with concrete fixes (smaller batch size,
54
+ mixed precision, gradient checkpointing)
55
+ - loss stalled (no meaningful change over the last 100 updates)
56
+ - loss spike (latest value far above the recent average)
57
+
58
+ Keys: `q` quit · `p` pause
59
+
60
+ ## Supported log formats
61
+
62
+ torchwatch parses training progress straight from stdout with no imports or callbacks.
63
+ Detected automatically:
64
+
65
+ | format | example line |
66
+ |---|---|
67
+ | tqdm | ` 42/500 [00:12<02:15, 3.4it/s, loss=0.693]` |
68
+ | PyTorch Lightning | `Epoch 3/10: 42/500 [... loss=0.693 ...]` |
69
+ | HF Trainer | `{'loss': 0.693, 'learning_rate': 5e-05, 'epoch': 1.2}` |
70
+ | plain | `step 42/500 loss: 0.693` |
71
+
72
+ The header shows which format was detected. Lines that match nothing are ignored.
73
+
74
+ ## Notes
75
+
76
+ - GPU stats come from NVML (NVIDIA). Without a usable NVML (e.g. macOS, no driver),
77
+ torchwatch falls back to clearly-labeled mock data so the dashboard still runs.
78
+ - `--pid` attach and process detection use `/proc` and process memory maps, so they
79
+ are Linux-only. `torchwatch run` works everywhere.
80
+
81
+ ## Development
82
+
83
+ ```
84
+ pip install -e ".[dev]"
85
+ pytest
86
+ ruff check .
87
+ ```
88
+
89
+ ## Planned
90
+
91
+ Roughly in order of likelihood:
92
+
93
+ - **More alert rules** — NaN/inf loss (the run is dead, say so immediately),
94
+ GPU temperature, sustained throughput drops (often a dataloader bottleneck)
95
+ - **Alert logging** — opt-in `--alert-log alerts.log`: a timestamped record of
96
+ every alert, so a spike at 3am is still explainable at 9am
97
+ - **Configurable thresholds** — `--vram-warn` / `--vram-alert` instead of the
98
+ built-in 85/95
99
+ - **Generic metric sparklines** — track any `key=value` metric your logs already
100
+ print (accuracy, val_loss, grad_norm, …) and chart the ones you pick
101
+ - **Appearance** — theme selection, optional extra GPU stats
102
+ - **Config file** — persistent defaults once the flag count justifies it
103
+ - **Raw output pane** — the wrapped process's actual stdout, scrolling under
104
+ the dashboard
105
+ - **Multi-job dashboard** — one dashboard, several training processes
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ # PyPI distribution name; the import package and CLI are still `torchwatch`.
7
+ # (pypi.org/project/torchwatch is an abandoned 0.0.0 placeholder.)
8
+ name = "torchwatch-tui"
9
+ version = "0.1.0"
10
+ description = "A btop for your PyTorch GPU jobs: live terminal dashboard for training runs"
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ license = "MIT"
14
+ authors = [
15
+ { name = "Jay Patel", email = "jayashish.patel@mail.utoronto.ca" },
16
+ ]
17
+ keywords = ["pytorch", "gpu", "monitoring", "tui", "terminal", "training"]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Environment :: Console",
21
+ "Intended Audience :: Developers",
22
+ "Operating System :: POSIX :: Linux",
23
+ "Operating System :: MacOS",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
29
+ "Topic :: System :: Monitoring",
30
+ ]
31
+ dependencies = [
32
+ "textual",
33
+ "nvidia-ml-py",
34
+ "psutil",
35
+ "click",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/JayPatel095/TorchWatch"
40
+ Repository = "https://github.com/JayPatel095/TorchWatch"
41
+ Issues = "https://github.com/JayPatel095/TorchWatch/issues"
42
+
43
+ [project.scripts]
44
+ torchwatch = "torchwatch.cli:main"
45
+
46
+ [project.optional-dependencies]
47
+ dev = [
48
+ "ruff",
49
+ "mypy",
50
+ "pytest",
51
+ ]
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["torchwatch"]
55
+
56
+ [tool.hatch.build.targets.sdist]
57
+ # Ship the code and tests, not the demo assets or planning docs.
58
+ exclude = ["docs/", "torchwatch_project_brief.md"]
59
+
60
+ [tool.ruff]
61
+ line-length = 100
62
+ target-version = "py310"
63
+
64
+ [tool.mypy]
65
+ python_version = "3.10"
66
+ strict = true
@@ -0,0 +1,14 @@
1
+ # Log fixtures
2
+
3
+ Real or faithful captures of training-log formats the stdout parser must handle.
4
+
5
+ | file | provenance |
6
+ |---|---|
7
+ | `tqdm.log` | **Real capture**: `tqdm==4.x` `trange` with a `loss` postfix, stderr piped to a file (non-tty — exactly what torchwatch sees when attached). Frames are `\r`-separated. |
8
+ | `hf_trainer.log` | Hand-written to match HuggingFace `Trainer` stdout: dict-repr log lines interleaved with bare tqdm progress. The `eval_loss` / `train_loss` lines are traps — they must NOT parse as training loss. |
9
+ | `plain.log` | Hand-written `print()`-style lines, plus one `Loss=` capitalization variant. |
10
+ | `lightning.log` | Hand-written PyTorch Lightning progress lines (Lightning wraps tqdm, hence the resemblance). |
11
+ | `noise.log` | Typical non-metric stdout: warnings, wandb banner, torchvision download note, a metrics dict with no loss. Nothing here should match. |
12
+
13
+ When a real-world format breaks the parser, capture the offending lines into a
14
+ new fixture *first*, then fix the regex against it.
@@ -0,0 +1,7 @@
1
+ 0%| | 0/500 [00:00<?, ?it/s]
2
+ {'loss': 2.4915, 'grad_norm': 8.301953315734863, 'learning_rate': 4.95e-05, 'epoch': 0.1}
3
+ 10%|█ | 50/500 [00:12<01:52, 4.01it/s]
4
+ {'loss': 1.9847, 'grad_norm': 6.192838191986084, 'learning_rate': 4.85e-05, 'epoch': 0.2}
5
+ {'eval_loss': 1.7523119449615479, 'eval_runtime': 12.4817, 'eval_samples_per_second': 80.117, 'epoch': 1.0}
6
+ 100%|██████████| 500/500 [02:05<00:00, 4.00it/s]
7
+ {'train_runtime': 125.9012, 'train_samples_per_second': 79.428, 'train_steps_per_second': 3.972, 'train_loss': 1.8912, 'epoch': 2.0}
@@ -0,0 +1,5 @@
1
+ GPU available: True (cuda), used: True
2
+ Epoch 0/9: 0%| | 0/500 [00:00<?, ?it/s]
3
+ Epoch 0/9: 45%|████▌ | 225/500 [00:05<00:06, 41.72it/s, loss=1.892, v_num=3]
4
+ Epoch 0/9: 100%|██████████| 500/500 [00:12<00:00, 41.20it/s, loss=1.204, v_num=3]
5
+ Epoch 1/9: 60%|██████ | 300/500 [00:07<00:04, 41.55it/s, loss=0.987, v_num=3]
@@ -0,0 +1,6 @@
1
+ /usr/lib/python3/site-packages/torch/cuda/__init__.py:123: UserWarning: CUDA initialization: forked subprocess
2
+ INFO: Using device cuda:0 (NVIDIA A100 80GB)
3
+ wandb: View run at https://wandb.ai/jay/proj/runs/abc123
4
+ Files already downloaded and verified
5
+ {'accuracy': 0.912, 'f1': 0.884}
6
+ Saving checkpoint to ./checkpoints/epoch-1/
@@ -0,0 +1,6 @@
1
+ Starting training with 1000 steps, lr=0.001, batch_size=64
2
+ Step 100/1000 | loss: 0.4230 | lr: 0.001
3
+ Step 200/1000 | loss: 0.3891 | lr: 0.001
4
+ Step 300/1000 | loss: 0.3514 | lr: 0.0009
5
+ Epoch 2 | Step 350/1000 | Loss=0.3312
6
+ Step 400/1000 | loss: 0.3120 | lr: 0.0009
@@ -0,0 +1,65 @@
1
+ """Acceptance tests for AlertLog: dedup, lingering, and expiry."""
2
+
3
+ from torchwatch.alerts import AlertLog
4
+
5
+
6
+ def test_reported_alert_is_active():
7
+ log = AlertLog(ttl_s=10.0)
8
+ log.report("vram:0", "vram high", now=0.0)
9
+ (alert,) = log.active(now=0.0)
10
+ assert alert.key == "vram:0"
11
+ assert alert.message == "vram high"
12
+ assert alert.first_seen == 0.0
13
+ assert alert.last_seen == 0.0
14
+
15
+
16
+ def test_empty_log_has_no_active_alerts():
17
+ assert AlertLog().active(now=100.0) == []
18
+
19
+
20
+ def test_rereport_dedups_by_key():
21
+ log = AlertLog(ttl_s=10.0)
22
+ log.report("vram:0", "vram at 95.1%", now=0.0)
23
+ log.report("vram:0", "vram at 96.4%", now=1.0)
24
+ (alert,) = log.active(now=1.0)
25
+ assert alert.message == "vram at 96.4%" # latest numbers win
26
+ assert alert.first_seen == 0.0 # but the alert is still the same event
27
+ assert alert.last_seen == 1.0
28
+
29
+
30
+ def test_alert_lingers_after_condition_stops():
31
+ log = AlertLog(ttl_s=10.0)
32
+ log.report("spike", "loss spike", now=0.0)
33
+ assert len(log.active(now=5.0)) == 1 # condition gone, still readable
34
+ assert len(log.active(now=10.0)) == 1 # boundary: now - last_seen == ttl
35
+
36
+
37
+ def test_alert_expires_after_ttl():
38
+ log = AlertLog(ttl_s=10.0)
39
+ log.report("spike", "loss spike", now=0.0)
40
+ assert log.active(now=10.1) == []
41
+
42
+
43
+ def test_rereport_refreshes_lifespan():
44
+ log = AlertLog(ttl_s=10.0)
45
+ log.report("stall", "loss stalled", now=0.0)
46
+ log.report("stall", "loss stalled", now=8.0)
47
+ assert len(log.active(now=15.0)) == 1 # 15 - 8 = 7 < ttl
48
+ assert log.active(now=18.1) == []
49
+
50
+
51
+ def test_active_orders_by_first_seen():
52
+ log = AlertLog(ttl_s=100.0)
53
+ log.report("b-later-refire", "b", now=0.0)
54
+ log.report("a-newcomer", "a", now=1.0)
55
+ log.report("b-later-refire", "b", now=2.0) # re-fire must not reorder
56
+ assert [a.key for a in log.active(now=2.0)] == ["b-later-refire", "a-newcomer"]
57
+
58
+
59
+ def test_expired_alert_can_fire_again_as_new():
60
+ log = AlertLog(ttl_s=10.0)
61
+ log.report("spike", "loss spike", now=0.0)
62
+ assert log.active(now=50.0) == [] # long gone
63
+ log.report("spike", "loss spike again", now=50.0)
64
+ (alert,) = log.active(now=50.0)
65
+ assert alert.first_seen == 50.0 # a fresh event, not the old one revived
@@ -0,0 +1,145 @@
1
+ """Alerts must flow from the rules through the AlertLog into the alerts area."""
2
+
3
+ import asyncio
4
+
5
+ from textual.app import App
6
+
7
+ from torchwatch.alerts import Alert
8
+ from torchwatch.app import TorchwatchApp
9
+ from torchwatch.collector.nvidia import GiB, GpuSample
10
+ from torchwatch.collector.stdout import TrainingUpdate
11
+ from torchwatch.widgets.alert_panel import AlertPanel
12
+ from torchwatch.widgets.gpu_panel import GpuPanel
13
+
14
+
15
+ def _sample(vram_pct: float) -> GpuSample:
16
+ total = 80 * GiB
17
+ return GpuSample(
18
+ index=0,
19
+ name="Fake A100",
20
+ utilization_pct=50,
21
+ vram_used_bytes=int(total * vram_pct / 100),
22
+ vram_total_bytes=total,
23
+ temperature_c=60,
24
+ power_w=250.0,
25
+ )
26
+
27
+
28
+ class FakeCollector:
29
+ """Always reports the same sample, so alert conditions are deterministic."""
30
+
31
+ is_mock = True
32
+
33
+ def __init__(self, vram_pct: float) -> None:
34
+ self._vram_pct = vram_pct
35
+
36
+ def gpu_count(self) -> int:
37
+ return 1
38
+
39
+ def sample(self) -> list[GpuSample]:
40
+ return [_sample(self._vram_pct)]
41
+
42
+
43
+ class ScriptedSource:
44
+ """Replays a fixed list of losses, then goes quiet."""
45
+
46
+ label = "scripted"
47
+ interval_s = 0.01
48
+
49
+ def __init__(self, losses: list[float]) -> None:
50
+ self._updates = [
51
+ TrainingUpdate(step=i + 1, total_steps=len(losses), loss=loss, format_name="plain")
52
+ for i, loss in enumerate(losses)
53
+ ]
54
+
55
+ def next_update(self) -> TrainingUpdate | None:
56
+ return self._updates.pop(0) if self._updates else None
57
+
58
+ def close(self) -> None:
59
+ pass
60
+
61
+
62
+ class PanelHarness(App[None]):
63
+ def compose(self):
64
+ yield AlertPanel(id="alert-panel")
65
+
66
+
67
+ def test_alert_panel_hidden_when_empty():
68
+ async def scenario() -> None:
69
+ app = PanelHarness()
70
+ async with app.run_test(size=(120, 20)) as pilot:
71
+ panel = app.query_one(AlertPanel)
72
+ assert panel.display is False # collapsed from the start
73
+
74
+ panel.show_alerts(
75
+ [Alert(key="k", message="something bad", first_seen=0.0, last_seen=0.0)]
76
+ )
77
+ await pilot.pause()
78
+ assert panel.display is True
79
+ assert "something bad" in str(panel.render())
80
+
81
+ panel.show_alerts([])
82
+ await pilot.pause()
83
+ assert panel.display is False
84
+
85
+ asyncio.run(scenario())
86
+
87
+
88
+ async def _wait_for_alert(app: TorchwatchApp, pilot, needle: str) -> str:
89
+ for _ in range(60):
90
+ await pilot.pause(0.05)
91
+ panel = app.query_one("#alert-panel", AlertPanel)
92
+ rendered = str(panel.render())
93
+ if panel.display and needle in rendered.lower():
94
+ return rendered
95
+ raise AssertionError(f"no active alert containing {needle!r} ever appeared")
96
+
97
+
98
+ def test_vram_alert_reaches_alert_panel():
99
+ async def scenario() -> None:
100
+ app = TorchwatchApp(poll_ms=25, collector=FakeCollector(vram_pct=97.0))
101
+ async with app.run_test(size=(120, 30)) as pilot:
102
+ rendered = await _wait_for_alert(app, pilot, "batch")
103
+ assert "vram" in rendered.lower()
104
+ assert "gpu 0" in rendered.lower() # says WHICH gpu is under pressure
105
+ # the suggestion lives in the alerts area now, not the GPU panel
106
+ gpu_text = str(app.query_one(GpuPanel).render())
107
+ assert "batch" not in gpu_text.lower()
108
+
109
+ asyncio.run(scenario())
110
+
111
+
112
+ def test_healthy_vram_raises_no_alert():
113
+ async def scenario() -> None:
114
+ app = TorchwatchApp(poll_ms=25, collector=FakeCollector(vram_pct=60.0))
115
+ async with app.run_test(size=(120, 30)) as pilot:
116
+ await pilot.pause(0.5)
117
+ assert app.query_one("#alert-panel", AlertPanel).display is False
118
+
119
+ asyncio.run(scenario())
120
+
121
+
122
+ def test_loss_spike_reaches_alert_panel():
123
+ async def scenario() -> None:
124
+ app = TorchwatchApp(
125
+ poll_ms=200,
126
+ collector=FakeCollector(vram_pct=50.0),
127
+ metrics_source=ScriptedSource([1.0] * 19 + [2.5]),
128
+ )
129
+ async with app.run_test(size=(120, 30)) as pilot:
130
+ await _wait_for_alert(app, pilot, "spike")
131
+
132
+ asyncio.run(scenario())
133
+
134
+
135
+ def test_loss_stall_reaches_alert_panel():
136
+ async def scenario() -> None:
137
+ app = TorchwatchApp(
138
+ poll_ms=200,
139
+ collector=FakeCollector(vram_pct=50.0),
140
+ metrics_source=ScriptedSource([0.5] * 100),
141
+ )
142
+ async with app.run_test(size=(120, 30)) as pilot:
143
+ await _wait_for_alert(app, pilot, "stall")
144
+
145
+ asyncio.run(scenario())
@@ -0,0 +1,49 @@
1
+ """Acceptance tests for alert rules."""
2
+
3
+ from torchwatch.alerts import is_spiking, is_stalled, vram_suggestion
4
+
5
+
6
+ def test_no_suggestion_below_alert_threshold():
7
+ assert vram_suggestion(50.0) is None
8
+ assert vram_suggestion(94.9) is None # warn zone colors, but no nagging
9
+
10
+
11
+ def test_suggestion_at_alert_threshold():
12
+ hint = vram_suggestion(95.0)
13
+ assert hint is not None
14
+ lowered = hint.lower()
15
+ assert "batch" in lowered # cheapest fix must be mentioned
16
+ assert "amp" in lowered or "precision" in lowered
17
+ assert "checkpoint" in lowered
18
+
19
+
20
+ def test_flat_loss_stalls():
21
+ assert is_stalled([1.0] * 100)
22
+
23
+
24
+ def test_stall_needs_a_full_window():
25
+ assert not is_stalled([1.0] * 99)
26
+ assert not is_stalled([])
27
+
28
+
29
+ def test_improving_loss_is_not_stalled():
30
+ losses = [1.0 - 0.005 * i for i in range(100)]
31
+ assert not is_stalled(losses)
32
+
33
+
34
+ def test_stall_looks_only_at_recent_window():
35
+ # improved long ago, flat for the last 100 → stalled
36
+ losses = [2.0 - 0.01 * i for i in range(100)] + [0.5] * 100
37
+ assert is_stalled(losses)
38
+
39
+
40
+ def test_spike_detected():
41
+ assert is_spiking([1.0] * 19 + [2.5])
42
+
43
+
44
+ def test_steady_loss_no_spike():
45
+ assert not is_spiking([1.0] * 20)
46
+
47
+
48
+ def test_spike_needs_a_full_window():
49
+ assert not is_spiking([1.0, 5.0])