nodetop 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 (93) hide show
  1. nodetop-0.1.0/DESIGN.md +1544 -0
  2. nodetop-0.1.0/LICENSE +21 -0
  3. nodetop-0.1.0/MANIFEST.in +7 -0
  4. nodetop-0.1.0/PKG-INFO +222 -0
  5. nodetop-0.1.0/README.md +185 -0
  6. nodetop-0.1.0/pyproject.toml +110 -0
  7. nodetop-0.1.0/setup.cfg +4 -0
  8. nodetop-0.1.0/src/nodetop/__init__.py +88 -0
  9. nodetop-0.1.0/src/nodetop/__main__.py +3 -0
  10. nodetop-0.1.0/src/nodetop/_version.py +8 -0
  11. nodetop-0.1.0/src/nodetop/backends/__init__.py +84 -0
  12. nodetop-0.1.0/src/nodetop/backends/base.py +151 -0
  13. nodetop-0.1.0/src/nodetop/backends/kubernetes.py +612 -0
  14. nodetop-0.1.0/src/nodetop/backends/lsf.py +446 -0
  15. nodetop-0.1.0/src/nodetop/backends/pbs.py +566 -0
  16. nodetop-0.1.0/src/nodetop/backends/sge.py +536 -0
  17. nodetop-0.1.0/src/nodetop/backends/slurm.py +941 -0
  18. nodetop-0.1.0/src/nodetop/backends/sshpool.py +230 -0
  19. nodetop-0.1.0/src/nodetop/cli.py +3535 -0
  20. nodetop-0.1.0/src/nodetop/core/__init__.py +54 -0
  21. nodetop-0.1.0/src/nodetop/core/capacity.py +299 -0
  22. nodetop-0.1.0/src/nodetop/core/cluster.py +375 -0
  23. nodetop-0.1.0/src/nodetop/core/duration.py +208 -0
  24. nodetop-0.1.0/src/nodetop/core/fit.py +614 -0
  25. nodetop-0.1.0/src/nodetop/core/hardware.py +265 -0
  26. nodetop-0.1.0/src/nodetop/core/model.py +1147 -0
  27. nodetop-0.1.0/src/nodetop/exceptions.py +47 -0
  28. nodetop-0.1.0/src/nodetop/hostlist.py +182 -0
  29. nodetop-0.1.0/src/nodetop/interactive.py +491 -0
  30. nodetop-0.1.0/src/nodetop/py.typed +0 -0
  31. nodetop-0.1.0/src/nodetop/render.py +1476 -0
  32. nodetop-0.1.0/src/nodetop/runner.py +170 -0
  33. nodetop-0.1.0/src/nodetop.egg-info/PKG-INFO +222 -0
  34. nodetop-0.1.0/src/nodetop.egg-info/SOURCES.txt +91 -0
  35. nodetop-0.1.0/src/nodetop.egg-info/dependency_links.txt +1 -0
  36. nodetop-0.1.0/src/nodetop.egg-info/entry_points.txt +3 -0
  37. nodetop-0.1.0/src/nodetop.egg-info/requires.txt +8 -0
  38. nodetop-0.1.0/src/nodetop.egg-info/top_level.txt +1 -0
  39. nodetop-0.1.0/tests/backends/__init__.py +0 -0
  40. nodetop-0.1.0/tests/backends/test_kubernetes.py +589 -0
  41. nodetop-0.1.0/tests/backends/test_lsf.py +309 -0
  42. nodetop-0.1.0/tests/backends/test_pbs.py +398 -0
  43. nodetop-0.1.0/tests/backends/test_query_discipline.py +186 -0
  44. nodetop-0.1.0/tests/backends/test_registry.py +197 -0
  45. nodetop-0.1.0/tests/backends/test_sge.py +411 -0
  46. nodetop-0.1.0/tests/backends/test_slurm.py +727 -0
  47. nodetop-0.1.0/tests/backends/test_sshpool.py +113 -0
  48. nodetop-0.1.0/tests/conftest.py +284 -0
  49. nodetop-0.1.0/tests/fixtures/__init__.py +0 -0
  50. nodetop-0.1.0/tests/fixtures/k8s/namespaces.json +6 -0
  51. nodetop-0.1.0/tests/fixtures/k8s/nodes.json +34 -0
  52. nodetop-0.1.0/tests/fixtures/k8s/pods.json +136 -0
  53. nodetop-0.1.0/tests/fixtures/k8s/resourcequota.json +10 -0
  54. nodetop-0.1.0/tests/fixtures/lsf/bhosts.txt +7 -0
  55. nodetop-0.1.0/tests/fixtures/lsf/bhosts_gpu.txt +7 -0
  56. nodetop-0.1.0/tests/fixtures/lsf/bqueues_l.txt +42 -0
  57. nodetop-0.1.0/tests/fixtures/lsf/lshosts.txt +7 -0
  58. nodetop-0.1.0/tests/fixtures/pbs/pbsnodes.json +44 -0
  59. nodetop-0.1.0/tests/fixtures/pbs/pbsnodes.txt +20 -0
  60. nodetop-0.1.0/tests/fixtures/pbs/qstat_Qf.txt +39 -0
  61. nodetop-0.1.0/tests/fixtures/sge/qconf_sq_allq.txt +12 -0
  62. nodetop-0.1.0/tests/fixtures/sge/qconf_sq_cpuq.txt +7 -0
  63. nodetop-0.1.0/tests/fixtures/sge/qhost.txt +17 -0
  64. nodetop-0.1.0/tests/fixtures/sge/qstat_f.txt +9 -0
  65. nodetop-0.1.0/tests/fixtures/slurm/__init__.py +0 -0
  66. nodetop-0.1.0/tests/fixtures/slurm/nodes.txt +9 -0
  67. nodetop-0.1.0/tests/fixtures/slurm/partitions.txt +38 -0
  68. nodetop-0.1.0/tests/fixtures/slurm/probe_outputs.py +83 -0
  69. nodetop-0.1.0/tests/fixtures/slurm/qos.txt +4 -0
  70. nodetop-0.1.0/tests/test_backend_render.py +90 -0
  71. nodetop-0.1.0/tests/test_capacity.py +288 -0
  72. nodetop-0.1.0/tests/test_check.py +647 -0
  73. nodetop-0.1.0/tests/test_cli.py +2634 -0
  74. nodetop-0.1.0/tests/test_cli_render.py +406 -0
  75. nodetop-0.1.0/tests/test_consistency.py +261 -0
  76. nodetop-0.1.0/tests/test_cross_command.py +195 -0
  77. nodetop-0.1.0/tests/test_degenerate.py +183 -0
  78. nodetop-0.1.0/tests/test_docstrings.py +291 -0
  79. nodetop-0.1.0/tests/test_duration.py +153 -0
  80. nodetop-0.1.0/tests/test_fail_safe.py +315 -0
  81. nodetop-0.1.0/tests/test_fit.py +989 -0
  82. nodetop-0.1.0/tests/test_funnel.py +137 -0
  83. nodetop-0.1.0/tests/test_hardware.py +149 -0
  84. nodetop-0.1.0/tests/test_help.py +288 -0
  85. nodetop-0.1.0/tests/test_hostlist.py +170 -0
  86. nodetop-0.1.0/tests/test_interactive.py +514 -0
  87. nodetop-0.1.0/tests/test_main.py +372 -0
  88. nodetop-0.1.0/tests/test_malformed_input.py +229 -0
  89. nodetop-0.1.0/tests/test_model.py +639 -0
  90. nodetop-0.1.0/tests/test_probed_render.py +126 -0
  91. nodetop-0.1.0/tests/test_readme.py +201 -0
  92. nodetop-0.1.0/tests/test_render.py +1126 -0
  93. nodetop-0.1.0/tests/test_snapshot.py +306 -0
@@ -0,0 +1,1544 @@
1
+ # Design notes
2
+ Why nodetop behaves the way it does. Extracted from the README, which is
3
+ now a README. Each section is a defect that was found and what it cost;
4
+ the code commgrp-xs carry the same reasoning where it applies.
5
+
6
+ ## The same lies, everywhere
7
+
8
+ | The lie | Slurm | PBS / LSF / SGE | Kubernetes |
9
+ |---|---|---|---|
10
+ | “There are idle nodes here” | `State=DOWN` with all 610 nodes still listed | `started=False`, `Open:Inact`, every queue instance disabled | `Ready` but cordoned, or tainted and untolerated |
11
+ | “You have access” | association table lists 90 partitions per account | `acl_users` enabled and empty | RBAC allows, admission webhook refuses |
12
+ | “Verification passed” | site filter says PASSED, core refuses anyway | *no dry-run exists at all* | `--dry-run=server` (this one is honest) |
13
+ | “No time limit” | `MaxTime=UNLIMITED`, QOS caps at 2 days | queue unlimited, `max_run_res` caps | no walltime concept |
14
+ | “Admitted, so it will run” | pends forever on `QOSMaxGRESPerJob` | pends forever on a queue limit | `Pending` forever, within quota |
15
+ | “4 GPUs available” | `gres/gpu=4` | `ngpus=4` | `nvidia.com/gpu: 4` |
16
+ | “44 of 48 cores are idle” | `AllocMem=RealMemory`; nothing can land there | mem consumed, ncpus free | `requests.memory` exhausted |
17
+ | “There is room, so it starts now” | `--test-only` itself says 4h 24m | queue ahead of you, unreported | scheduler backoff |
18
+
19
+ Not one of those rows is about Slurm. That's the point — the *reasoning* is
20
+ scheduler-independgrp-x, so it lives in `nodetop.core`, which imports no backend and knows
21
+ what no scheduler is. Only *acquiring* the facts differs, and that lives in one adapter
22
+ per system.
23
+
24
+ ```
25
+ nodetop/
26
+ core/ model · hardware · capacity · fit · duration ← knows nothing about schedulers
27
+ backends/ slurm · pbs · lsf · sge · kubernetes · sshpool ← knows exactly one
28
+ ```
29
+
30
+ ---
31
+
32
+ ## What it actually catches
33
+
34
+ ### 1. A queue that advertises idle nodes and can start nothing
35
+
36
+ ```
37
+ $ nodetop queues -q test
38
+ ● test [DOWN]
39
+ nodes ·············· 549/610 schedulable
40
+ idle 107
41
+ blockers
42
+ ├─ QUEUE_DISABLED state=DOWN (accepts nothing)
43
+ ├─ NO_ACCOUNTS account allowlist is empty (nobody may submit)
44
+ ╰─ NO_QOS QOS allowlist is empty (no QOS is permitted)
45
+ ```
46
+
47
+ One real partition, in one real state: down, closed to every account, closed to every
48
+ QOS, and hidden — **four independgrp-x kill switches** — while still reporting all 610 of
49
+ its nodes, 107 of them idle. Anything counting idle nodes concluded there were a hundred
50
+ machines waiting.
51
+
52
+ So `Queue.effective_free_nodes` is **zero** whenever the queue cannot start work, and
53
+ availability is a *list of blockers*, not a boolean — fixing one of four changes nothing.
54
+
55
+ Every system has this. PBS spells it `enabled=True, started=False`. LSF spells it
56
+ `Open:Inact` and reports 140 jobs pending against it. Kubernetes spells it
57
+ `Ready,SchedulingDisabled` with every capacity number intact.
58
+
59
+ ### 1a. And a dashboard that answered a question nobody asked
60
+
61
+ The overview used to open with that finding, and then rank every partition together by
62
+ free capacity. Both were wrong, and a user said so:
63
+
64
+ > what does accelerator mean? it's very confusing. also why always first reports something
65
+ > ain't work? this makes no sense at all. also, the colors in the bars don't make any
66
+ > sense and they have a lot of private nodes that aren't open to all users.
67
+
68
+ All four land. Taking them in order of how much they mattered:
69
+
70
+ **The ranked list was mostly unreachable.** Of 84 usable partitions on that cluster, 73
71
+ allow one or two accounts each — they are individual research groups' cluster shares. A
72
+ single sort by free capacity put eleven of them in the top twelve rows, so the headline
73
+ answer to "where can I go" was almost grp-xirely places the reader cannot go. `nodetop` now
74
+ reads each queue's own allowlist (`Queue.is_dedicated`) and separates the two, because the
75
+ accounting database *cannot* answer this here: it reports the user as associated with 34
76
+ accounts and gives every one an idgrp-xical QOS list, so declared grp-xitlemgrp-x does not
77
+ distinguish a partition you may use from one that rejects you with `Invalid membership`.
78
+ An allowlist of `pi-okafor` alone does. That leaves **two** shared partitions with free
79
+ GPUs, which is the honest answer.
80
+
81
+ **Failures come last.** A tool asked "where can I run this" should not lead with a list of
82
+ things that do not work. The finding is still there, in one line, at the end — with the
83
+ phantom idle-node count intact, since that is the whole point of the tool.
84
+
85
+ **"Accelerator" was jargon.** It was chosen to stay neutral across six schedulers and
86
+ printed to someone looking at a rack of NVIDIA cards. Every backend calls the resource
87
+ `gpu` in its own syntax — `gres/gpu`, `nvidia.com/gpu`, `ngpus` — so the UI says GPU, and
88
+ `nodetop gpus` works. The JSON keys and the core model keep `accelerator`: a machine
89
+ consumer should not have its field names churned for a wording fix.
90
+
91
+ **One panel, one population.** The header reported cluster-wide totals while the table
92
+ below it was filtered to the caller's slice — `358 GPUs, 117 free` printed above five
93
+ partitions holding 230 of them. It is built after the filtering now and inserted at the
94
+ top, so it counts exactly the partitions shown: `90 of 607 nodes, 88 up · 176 of 358 GPUs,
95
+ 31 free`. Your slice is the subject; the cluster size is the qualifier, and it disappears
96
+ under `--all` where nothing is hidden.
97
+
98
+ **Free means reachable-and-free.** `Queue.effective_free_gpus` has always returned 0 for an
99
+ unusable queue. `Cluster.summary` did not: it counted every *schedulable* node's free
100
+ resources, so an idle four-GPU node whose only partition was `DOWN` read as four free
101
+ accelerators — phantom capacity in the summary of a tool written to catch phantom capacity.
102
+ `accelerators` computed its own totals the same way. Both now count over
103
+ `Cluster.reachable_nodes()`, which also excludes a node in no queue at all: nothing can be
104
+ submitted to it, so its capacity is not capacity. Reachability changes what is *free*, never
105
+ what exists — the installed total still counts the whole cluster.
106
+
107
+ **Nodes are the spine; accelerators are a column.** The overview led with a GPU fraction,
108
+ ranked by free GPUs, and gave its meter to GPU share. On this cluster that is 91 of 607
109
+ nodes — so five of seven shared partitions drew an empty bar and a dash, which reads as
110
+ missing data rather than as a CPU partition, and the ranking sorted the whole cluster by a
111
+ property 85% of it does not have. GPUs are a column, populated where they exist and blank
112
+ where they do not, and `where -g N` is the command for the question that is actually about
113
+ accelerators. `queues` had the idgrp-xical defect in the view with the most rows of it — 70
114
+ of 87 partitions drawing an empty meter.
115
+
116
+ **A core is the unit of room; a node is the unit of shape.** Free *nodes* replaced free
117
+ GPUs as the meter, on the reasoning that every partition has nodes — and it was wrong in
118
+ its own way, because it counts only **wholly idle** nodes and on a busy cluster almost
119
+ nothing is wholly idle. Any partition running a single job read as zero room. Measured
120
+ here: `amd` had 2825 of 5120 cores free and drew a **2%** meter; `build` had 42 of 48 and
121
+ drew **0%**; `gpu-a` had 200 cores and 27 GPUs free and drew **0%** — while
122
+ `gpu-a-bigmem`, with 128 free cores, drew a full bar and took the top row. The meter was
123
+ inverted with respect to the quantity it claimed to show, and the ranking put a partition
124
+ above one with **22x** more free capacity. So the meter and the ranking are free *cores*.
125
+ `idle` stays as a column, because work that wants a node to itself still needs it, but it
126
+ is no longer the measure of room. `nodes` already metered `cpus_free / cpus_total` per
127
+ node; this is the same arithmetic one level up.
128
+
129
+ **No cell is a bare `free/total` fraction.** `4/4 100%` was read as plausibly meaning all
130
+ four are *busy* — a single fraction cannot say which side is free, and a percgrp-xage next to
131
+ it does not disambiguate. Free and total are separate columns under their own names now,
132
+ and the header line spells it out too (`607 nodes, 549 up` rather than `549/607 nodes`).
133
+
134
+ **Access is filtered by default, in two stages, and both were measured against the control
135
+ plane before being trusted.**
136
+
137
+ The first is free: intersect each queue's declared allowlist with the accounts you actually
138
+ hold. On this cluster that takes 84 partitions with room down to 19, and it has **no false
139
+ negatives** — an 18-partition sample of what it dropped was dry-run against all 34 of this
140
+ user's accounts, and not one of them accepted anything. It is also what a width heuristic
141
+ could never do: `grp-h` names four accounts and `grp-i` five, so a "fewer than three
142
+ accounts means private" rule let both through, while a set intersection excludes them
143
+ instantly.
144
+
145
+ The second is the dry-run, and it is not optional, because the first stage is nowhere near
146
+ sufficigrp-x: **of the 19 partitions the allowlist keeps, a dry-run accepts 8.** The
147
+ association table lists this user in `grp-e`, `pi-okafor`, `pi-tanaka`, `pi-varga`,
148
+ `pi-ibrahim` and `pi-svensson`, and the submit plugin rejects every one with `Invalid
149
+ membership`. No reading of any declared list can see that. It costs ~2.8s rather than 30
150
+ precisely because the allowlist filter runs first — 19 queues to ask about, not 84.
151
+
152
+ **And the dry-run has to ask about the right account, which is where this wgrp-x wrong.**
153
+ `probe_accounts` used to truncate its candidate list to four, on the reasoning that a user
154
+ with dozens of associations against dozens of queues would otherwise fire hundreds of
155
+ submissions. That is a real cost, but truncation is the wrong place to pay it: the general
156
+ partitions here set `AllowAccounts=ALL`, so the intersection is all 34 accounts and only the
157
+ first four were ever tried. `wide` (190 nodes), `gpu` (44 accelerators) and `bigmem` are
158
+ each accepted with `rcc-staff` — 32nd in that list — and all three were reported **refused**
159
+ and hidden. Three partitions you can submit to, missing from the answer to "where can I run
160
+ this", with nothing on screen to suggest anything had been skipped.
161
+
162
+ Two changes, because either alone leaves the hole open:
163
+
164
+ - **The ceiling moved to the probe loop, and became global.** A per-queue cap bounds nothing
165
+ worth bounding — fifty queues times four is still two hundred round trips — while the
166
+ per-queue loop already stops at the first *accept*, so the expensive case is a queue that
167
+ refuses everything, not a user with many accounts. `MAX_PROBES_TOTAL` bounds the whole
168
+ question; the per-queue limit is now a backstop rather than the mechanism.
169
+ - **The accounts that work are learned and tried first.** An account accepted by one queue
170
+ is overwhelmingly likely to be accepted by the next — here the single account that clears
171
+ the SU check clears it for every shared partition — so queues are evaluated **cheapest
172
+ first**, by how many candidate accounts they admit. A queue whose allowlist admits exactly
173
+ one of your accounts costs one probe and proves that account works; the `AllowAccounts=ALL`
174
+ queues then try it first instead of grinding through 34 in declaration order. In the
175
+ scheduler's own order the expensive queues wgrp-x first, spgrp-x the cap, and were written off
176
+ — and then `gpu-a` accepted that very account one queue later.
177
+
178
+ The result is both more correct and faster: 8 partitions instead of 5, in 2.8s instead of
179
+ 3.5, because most queues now settle on their first probe.
180
+
181
+ **And a refusal that was not established is not reported as one.** If the per-queue ceiling
182
+ is reached with candidate accounts still unasked, the verdict becomes
183
+ `ACCOUNTS_UNTRIED` — a *transigrp-x* category, so `Placemgrp-x.confirmed` stays false but
184
+ `durable` does too. The overview keeps that partition, marked `unconfirmed` in the funnel's
185
+ shown count rather than filed under `refused`, because "we did not ask" and "you are denied"
186
+ are the exact pair of claims this whole tool exists to keep apart.
187
+
188
+ ```
189
+ ada · 607 nodes, 548 up · 358 GPUs, 110 free
190
+
191
+ 87 partitions → 5 open to you · 65 no access · 14 refused · 3 dead
192
+ ───────────────────────────────────────────────────────────────────────────
193
+ partition nodes idle cores free share gpu free models
194
+ amd 40 0 5120 2825 █████▌░░░░ 55%
195
+ gpu-a 44 0 1408 200 █▍░░░░░░░░ 14% 176 27 A100, A40
196
+ compute-hm 1 1 128 128 ██████████ 100%
197
+ gpu-a-bigmem 4 4 128 128 ██████████ 100%
198
+ build 1 0 48 42 ████████▊░ 88%
199
+
200
+ DEAD 150 idle nodes advertised
201
+ test DOWN, 150 idle · eng all 48 nodes down · eng-build all 2 nodes down
202
+
203
+ refused by sbatch --test-only · --all for every partition
204
+ ```
205
+
206
+ **The filtering has to reconcile, on screen, with the total.** Both of those numbers
207
+ were already on the dashboard and they were four lines apart: a cluster-facts line
208
+ saying `87 partitions`, and a footer saying what two of the stages had hidden. The
209
+ question that came back — *"why is it showing me five rows?"* — is what you get when a
210
+ reader has to do that arithmetic themselves and one term is missing from it. So the
211
+ count moved down beside the table it describes, every stage that drops a partition is
212
+ named in the same line, and the terms sum to the total exactly: shown + no-access +
213
+ refused + no-nodes + dead == 87. A partition cannot leave the screen without appearing
214
+ in that line. The footer no longer repeats any of those counts — it says only what
215
+ `refused` *means*, since which dry-run refused you is the part that is not obvious, and
216
+ two sixty-fives four lines apart read as two differgrp-x sixty-fives.
217
+
218
+ **All four listings share the filter**, through one helper, because getting this right in
219
+ one place and not the others is a mistake this file has made three times. Unfiltered, they
220
+ reported cluster-wide figures as though they were yours:
221
+
222
+ | command | says | yours |
223
+ |---|---|---|
224
+ | `queues` | 84 usable partitions | **19** |
225
+ | `nodes` | 607 nodes | **330** |
226
+ | `gpus` | 358 accelerators | **230** |
227
+
228
+ **Access is the filter; occupancy is a column.** The overview also dropped anything with no
229
+ free capacity, and it did so *before* applying the access filter — so a partition this
230
+ account can submit to disappeared because it was busy at the instant of the query.
231
+ Measured: 5 partitions accept a dry-run, 2 of them were full, and the screen said 3. That
232
+ understates access rather than capacity, and "where can I run this" includes "where can I
233
+ queue". A full partition you may use is now listed, honestly reporting `0` free, and the
234
+ ordering still puts the room first.
235
+
236
+ Each listing states what it hid (`277 not on your allowlist`), `--all` turns it off, and
237
+ naming a queue explicitly overrides it — "show me this one" should show it, blockers and all. If the
238
+ filter would leave *nothing*, it does not apply: an empty screen means the grp-xitlemgrp-x data
239
+ is unusable, not that you may use nothing, and it hides the very data that would explain
240
+ that.
241
+
242
+ **`where` probes by default too.** It was the last command still trusting declared
243
+ grp-xitlemgrp-x, and it was the worst place for it: `nodetop where -g 1` reported **four**
244
+ partitions as `RUN NOW` and a dry-run accepted **one**. Three of four rows were the
245
+ strongest claim the tool can make, about places that refuse this account. It costs 3.1s.
246
+
247
+ That change exposed three places where *"we could not ask"* was being read as *"no"*. A
248
+ probe that fails returns `allowed=False` with a category in `TRANSIENT_CATEGORIES`, and:
249
+
250
+ - `Placemgrp-x.reachable` treated it as unreachable, so a scheduler hiccup flipped the exit
251
+ code to "nothing fits anywhere";
252
+ - `where` filtered the row away, emptying the screen exactly when the caller most needs
253
+ their options;
254
+ - the label said `BLOCKED` / "not permitted" for a control-plane outage — a false statemgrp-x
255
+ about access, and the same conflation a ceiling had before it got its own label.
256
+
257
+ All three now require the refusal to be **durable**, and a transigrp-x one gets its own label,
258
+ `NO ANSWER`. Having found those three by accidgrp-x, I grepped every reader of
259
+ `verdict.allowed` — nine of them, and four more carried a consequence:
260
+
261
+ - the **probe loop** kept whichever verdict came *last*, so when accounts disagreed the
262
+ result depended on iteration order. It now keeps the most informative: accepted beats
263
+ unanswered beats refused, because one unanswered account means the *queue* is unknown
264
+ even if another was durably refused;
265
+ - the **ordering key** demoted an unanswered row as though refused. That check turned out to
266
+ duplicate `Placemgrp-x.score`, which already orders confirmed ahead of unconfirmed, so it
267
+ was deleted rather than fixed — the key now consults an *acceptance* only;
268
+ - the **`ACCESS` cell** painted `CONTROL_PLANE_DOWN` red, which reads as "you are denied";
269
+ - **`check`** folded unanswered probes into its refused count, so `1 of 3 accepted` hid that
270
+ one of the other two was never asked. It is reported separately now, though the exit
271
+ status still treats it as not-accepted: waving a `nodetop check && sbatch` caller through
272
+ on an unanswered probe is the one outcome worth being strict about. A test asserts `reachable` and the label never disagree on a row, because they
273
+ are derived separately and had already drifted apart once.
274
+
275
+ `--declared` skips the dry-run and says so (`DECLARED ONLY allowlists over-report`);
276
+ `--all` skips both. What is hidden is always counted, per stage, so the filtering is never
277
+ silgrp-x.
278
+
279
+ **And `nodes` is capped.** It answered "how are my 607 nodes doing" with 607 rows, which is
280
+ not an answer, it is the raw data again. Twgrp-xy by default, `-n N` to change it, `--all`
281
+ for every one, and a count of what was withheld — the same shape `rdu` uses for its own
282
+ `--top`.
283
+
284
+ **One helper owns the table style.** Six commands predated it and each kept the old look —
285
+ bold capitals, a rule above *and* below the header row. The style is three keyword
286
+ argumgrp-xs deep, so relying on every call site to remember it did not work; `_grid` applies
287
+ it and a test asserts no caller bypasses it by looking for an all-caps word in any header
288
+ row. Column names are lowercased there too: a name is a label, not an announcemgrp-x, and
289
+ lowercase is what let the column glossary go.
290
+
291
+ **Meter colour is a scale, not a verdict.** The fill was green above half and amber
292
+ below, which restated the number the bar already draws. Worse, three differgrp-x quantities
293
+ sat in the same block — nodes schedulable, GPUs free, partitions usable — so one amber bar
294
+ meant "40% of GPUs are free", which is not a warning about anything. Two colours either
295
+ side of a threshold is a judgemgrp-x.
296
+
297
+ The fix was a flat single-colour fill, and that overcorrected: it bought the honesty by
298
+ giving up on colour carrying any quantity at all. What it now uses is a twelve-step ramp
299
+ from deep blue through cyan and green to amber — ordered, so it reads as a scale the way a
300
+ heatmap legend does, and with **no red at either end**, so neither a full bar nor an empty
301
+ one can be mistaken for an alarm. Red stays reserved for the things that are actually
302
+ wrong, said with a glyph and a word.
303
+
304
+ Two more properties make it mean something rather than merely look like something:
305
+
306
+ - **A column is coloured as a set, not row by row.** A fixed number of bands cannot know
307
+ that three of eighty-seven partitions happen to fall inside one of them, and three
308
+ values an order of magnitude apart in the same tone is what makes a ramp read as noise.
309
+ Rows are walked largest-first and one that is *measurably* smaller than the row above is
310
+ forced at least one step cooler; rows that really are equal stay equal.
311
+ - **Tone and bar length are deliberately differgrp-x quantities.** Length is the row's own
312
+ free *share*; tone is how its free cores rank against the other rows. `compute-hm` draws a
313
+ full bar in a cold tone — all of it free, and it is one node. `amd` draws a short bar in
314
+ a warm one — mostly busy, and still the largest pool of free cores on the list.
315
+ Collapsing the two would lose whichever was dropped, and both are answers someone came
316
+ here for.
317
+
318
+ ### 1c. An empty answer and an unobtainable one are differgrp-x claims
319
+
320
+ The tool had this bug in itself, in the form it was written to catch elsewhere.
321
+ With every Slurm command failing — a controller outage, the exact momgrp-x you reach for
322
+ this — four commands printed clean, confidgrp-x, wrong answers and exited **0**:
323
+
324
+ | command | said | means |
325
+ |---|---|---|
326
+ | `queues` | `0 shown, 0 usable` | a cluster with no partitions |
327
+ | `nodes` | `all 0 · 0 with GPUs · 0 out` | a cluster with no nodes |
328
+ | `health` | `0 schedulable · 0 degraded · 0 out` | **a perfectly healthy cluster** |
329
+ | `exclude --unschedulable` | *(empty nodelist)* | nothing to exclude |
330
+
331
+ `health` is the worst of them: it is the command whose grp-xire purpose is to tell you
332
+ whether something is wrong, and during a total outage it said nothing was. And the
333
+ `exclude` case is actively dangerous — `sbatch --exclude=$(nodetop exclude
334
+ --unschedulable)` submits with no exclusions while the script believes it has them.
335
+ Only `status` mgrp-xioned the failures at all.
336
+
337
+ One guard at dispatch now covers every command: if queries failed and the snapshot is
338
+ empty, nothing is printed to stdout, each failed query is named on stderr, and the exit
339
+ status is **3** — the same code as "no batch system here", because both mean the tool
340
+ could not do its job. Deliberately not 1, which means "nothing fits" and is a real
341
+ answer. A *partial* failure still exits 0 with the report intact and the missing query
342
+ named, because that report is usable.
343
+
344
+ **The same conflation had a subtler form one layer down.** `load_idgrp-xity` caught every
345
+ exception and substituted an empty string, so a failed association query produced an
346
+ idgrp-xity holding zero accounts — indistinguishable from a user who genuinely holds none.
347
+ The account and QOS checks downstream are tri-state and read "nothing to compare
348
+ against" as "no verdict", so one dead `sacctmgr` silgrp-xly disabled all of them. Measured:
349
+ all 34 accounts vanished, every dry-run ran with no `--account`, the control plane fell
350
+ back to a default and refused it, and the overview reported **`0 open to you · 83
351
+ refused`** — total loss of access, asserted confidgrp-xly, during a database hiccup. The
352
+ function's own commgrp-x warned about precisely this hazard; the `except` clause below it
353
+ reintroduced it.
354
+
355
+ It raises now, so `Cluster.load` records it and leaves `idgrp-xity` as `None`, which the
356
+ grp-xitlemgrp-x filter already treats as "cannot filter" rather than "grp-xitled to nothing".
357
+ A refusal obtained with no account named, *when the association query is known to have
358
+ failed*, is downgraded to unsettled — keyed on the failure and not merely on the absence
359
+ of an idgrp-xity, because a backend with no notion of accounts at all (an ssh pool) probes
360
+ without one legitimately and its refusals are real. The same screen now reads `84 open to
361
+ you (84 unconfirmed)` with `FAILED idgrp-xity` naming the cause.
362
+
363
+ **Two schedulers wrap long attribute values, and both backends dropped the tail.**
364
+ This is the same defect as the record-splitting one below, in a differgrp-x dialect, and it
365
+ was found by sweeping for that one. PBS breaks a value at 80 columns mid-value and indgrp-xs
366
+ the remainder with a tab; LSF wraps a long `bqueues -l` value onto indgrp-xed following
367
+ lines. Every PBS parser required an `=` before accepting a line and LSF's `_after` stopped
368
+ at the end of the label's own line, so in both the continuation was silgrp-xly discarded —
369
+ losing the tail of exactly the values long enough to wrap, which are the ones that matter:
370
+
371
+ | field | wrapped | consequence |
372
+ |---|---|---|
373
+ | PBS `resources_available.Qlist` | 8 queues → **5** | the node goes missing from the queues whose names were cut, and its capacity with it |
374
+ | PBS `acl_users` | 8 users → **6** | truncated allowlist read as authoritative → **false denial** |
375
+ | PBS `exec_host` | 7 nodes → **5** | maps running work to nodes, so free-time estimates land on the wrong machines |
376
+ | LSF `USERS:` | 14 users → **10** | same false denial, other scheduler |
377
+
378
+ The two false-denial rows are the ones that matter most, because they are the failure this
379
+ tool exists to prevgrp-x pointed the wrong way: reporting no access to a queue that would
380
+ have taken the job. `exec_host` is the longest field PBS emits and *always* wraps for a
381
+ multi-node job.
382
+
383
+ A continuation is an indgrp-xed line with no `=` (PBS) or one that does not begin a new
384
+ `LABEL:` (LSF). Record headers are never indgrp-xed in either, so a header cannot be
385
+ mistaken for a continuation or the reverse.
386
+
387
+ **A parser that cannot tell "nothing" from "one merged record" cannot complain.**
388
+ `scontrol` emits either one record per line or one field per line, and the two are a
389
+ single flag apart — this backend passes `--oneliner` when listing nodes and not when
390
+ listing partitions. Each parser understood only the shape its own command happened to
391
+ produce, and both failed silgrp-xly on the other, in opposite directions: partitions were
392
+ split on blank lines, so oneliner input became one record whose field map kept the last
393
+ value for each key (**2000 partitions collapsing to 1**), while nodes were read one per
394
+ line, so multi-line input gave a record per line and every node came back with **0 CPUs
395
+ and no state** — a cluster that appears to own no resources. Neither is reachable while
396
+ the argv here is fixed, which is exactly why it would go unnoticed; the way in is a
397
+ replayed snapshot recorded where `scontrol` behaved differgrp-xly, a site wrapper, or a
398
+ version whose output changed shape. Records are now delimited by their own header
399
+ keyword, required at a line start, so layout is irrelevant.
400
+
401
+ Writing the test for that found an older one underneath it. The field map was built by
402
+ dict comprehension, which keeps the **last** match — and no field repeats in a real
403
+ record, so a second occurrence can only have come from free text. One field on every node
404
+ is operator-authored prose, so a node drained with `Reason=replacing NodeName=n2 per
405
+ ticket` was **renamed to `n2 per ticket`**: it vanished from the report under its own name
406
+ and reappeared under a mangled one. First occurrence wins now; the real header is at the
407
+ record start, which is what makes that the safe rule rather than merely a differgrp-x one.
408
+
409
+ **And the same defect was in five other adapters.** Having found it once in the Slurm
410
+ backend, the obvious next question is whether the other five made the same choice. They
411
+ did — a `try/except` around a query, returning something empty and plausible:
412
+
413
+ | backend | swallowed | consequence |
414
+ |---|---|---|
415
+ | Kubernetes | `kubectl get pods` | **every node reports as idle** |
416
+ | Kubernetes | `auth whoami` | RBAC group restrictions silgrp-xly ignored |
417
+ | Kubernetes | `get resourcequota` | quota ceilings silgrp-xly absgrp-x |
418
+ | Grid Engine | `qconf -sul` sweep | *partial* userset list → **false denials** |
419
+ | Grid Engine | `qconf -srqs` per set | *partial* ceilings, applied as complete |
420
+ | PBS | `qstat -Qf` limits | ceilings absgrp-x — and PBS has no dry-run to fall back on |
421
+ | PBS / LSF | local group lookup | *partial* group list → false denials |
422
+
423
+ The Kubernetes pod query is the worst of them and worth spelling out. `allocatable` is a
424
+ capacity, not a free count, so pod requests *are* the occupancy — and with no pod data
425
+ every node parses as zero-allocated. A node running 40 of its 48 cores and all 4 of its
426
+ accelerators was reported as `48/48` and `4/4` free, `idle=True`. That is phantom
427
+ capacity, the failure this tool was written to catch, manufactured by the tool itself. It
428
+ was also reachable in ordinary use: `kubectl get pods --all-namespaces` is routinely
429
+ forbidden by RBAC for a namespaced user. The suppression even carried a commgrp-x claiming
430
+ the missing query "is recorded in `Cluster.errors`" and that a node would not be shown as
431
+ fully free — neither was true, because swallowing the error made `load_nodes` *succeed*.
432
+
433
+ Two failure directions, and a partial answer is the more dangerous of them. An **empty**
434
+ result reads as "cannot tell" in the tri-state membership check, so restrictions are
435
+ ignored and queues are claimed that will refuse the job. A **partial** result reads as
436
+ authoritative, so the tool returns a verdict from a scan it knows did not finish — "none
437
+ of your groups are permitted here" — which is a false denial that hides a queue you can
438
+ actually use. Every one of these now either builds its answer atomically or raises, so
439
+ `Cluster.load` records the failure and the caller sees "we could not ask" instead of an
440
+ answer. The distinction the code already had a name for — `Limits.unreadable` — was being
441
+ thrown away at the point it was needed.
442
+
443
+ ### 1b. How well the guess actually does — measured
444
+
445
+ `where` had the same defect and it mattered more, because `where` is the command you act
446
+ on. `nodetop where -g 1` listed five partitions and called **four** of them `RUN NOW`. A
447
+ dry-run then refused all but one:
448
+
449
+ | partition | allowlist | marked `group-only`? | `sbatch --test-only` |
450
+ |---|---|---|---|
451
+ | `gpu-a` | 28 accounts | no | **confirmed** |
452
+ | `gpu` | *none* (open) | no | `NOT_ENTITLED` |
453
+ | `grp-d-gpu` | 1 account | yes | `NOT_ENTITLED` |
454
+ | `grp-e-gpu` | 1 account | yes | `NOT_ENTITLED` |
455
+ | `grp-f-gpu` | 1 account | yes | `NOT_ENTITLED` |
456
+
457
+ Three of the four false positives are caught. The fourth is the honest limit, and it is
458
+ worth stating precisely rather than papering over: `gpu` declares an **empty account
459
+ allowlist** and a QOS allowlist (`gpu`, `debug`) that intersects the caller's — so it is
460
+ open on every axis a structural reading can see, and it refuses anyway. The lie lives in
461
+ the association dump, which claims the same 92 QOS grp-xries for all 34 of the caller's
462
+ accounts. No allowlist reading can reach that; only the control plane can.
463
+
464
+ So the marker is a marker, never a filter. `group-only` means *"allows 1–2 accounts; you
465
+ may not be one"* — not "you cannot go here" — and a confirmed verdict overrides it
466
+ grp-xirely, because if a probe says you are in the group then the partition is not
467
+ second-class. Shared partitions are merely sorted ahead of private ones at equal standing,
468
+ so the row you can act on comes first. `tests/test_check.py` records the blind spot as an
469
+ executable test, so the heuristic can never quietly be mistaken for a substitute for
470
+ `--check`.
471
+
472
+ ### 2. Entitlemgrp-x that is declared but never verified
473
+
474
+ Three of the six systems have a real verify-only mode. Three do not:
475
+
476
+ | system | dry-run | grp-xitlemgrp-x |
477
+ |---|---|---|
478
+ | Slurm | `sbatch --test-only` | **confirmed** |
479
+ | Grid Engine | `qsub -w v` | **confirmed** |
480
+ | Kubernetes | `auth can-i` + `--dry-run=server` | **confirmed** |
481
+ | PBS / Torque | none | declared only |
482
+ | LSF | none | declared only |
483
+ | ssh pool | no scheduler | n/a |
484
+
485
+ Where there is no dry-run, nodetop says so rather than presgrp-xing an ACL as a verified
486
+ right — `ACCESS` reads `declared`, and `nodetop backends` prints why. Silence would let a
487
+ declared grp-xitlemgrp-x read as a confirmed one, which is the failure this tool exists to
488
+ prevgrp-x.
489
+
490
+ Where there *is* one, read both layers. On Slurm:
491
+
492
+ ```
493
+ sbatch: error: Verification: ***PASSED*** <- the site's job_submit filter
494
+ allocation failure: Invalid account or account/partition combination specified
495
+ <- the scheduler, refusing anyway
496
+ ```
497
+
498
+ Grep for `Verification:` and stop, and you conclude the opposite of the truth. nodetop
499
+ requires both layers clean, classifies the refusal, and reports the disagreemgrp-x. It also
500
+ reads back the QOS the controller *actually chose* — a request on `gpu-a` came back
501
+ running under `gpu-a-prio`, and checking ceilings against the name you asked for checks
502
+ the wrong ceilings.
503
+
504
+ nodetop also notices when a claim is worthless on its face, and says so in `status`
505
+ rather than burying it under every queue:
506
+
507
+ ```
508
+ ╭─ nodetop ─────────────────────────────────────────────────────────────────╮
509
+ │ slurm · 607 nodes · 91 with accelerators · 87 partitions │
510
+ │ grp-xitlemgrp-x confirmable via sbatch --test-only │
511
+ │ you ada · 34 accounts · 92 QOS │
512
+ ╰──────────────────────────────────────────────────────────────────────────╯
513
+
514
+ ▲ every one of your 34 accounts claims an idgrp-xical list of 92 grp-xitlemgrp-xs,
515
+ so the scheduler's access claim carries no per-account information here --
516
+ only a dry-run (--check) settles where you can actually submit
517
+ ```
518
+
519
+ ### 3. A dry-run passing does not mean the job will start
520
+
521
+ No HPC scheduler evaluates resource ceilings in its dry-run. A 40-node × 4-GPU request on
522
+ a QOS capped at 4 GPUs per job comes back `PASSED`, with a plausible start time attached —
523
+ then pends indefinitely under `QOSMaxGRESPerJob`. An 8-day walltime on a 2-day QOS does
524
+ the same. Nothing warns you.
525
+
526
+ ```
527
+ $ nodetop where -g 4 -t 8-00:00:00
528
+ gpu
529
+ [shape] MAX_WALLTIME
530
+ walltime 8-00:00:00 exceeds gpu limit 1-12:00:00 -- typically accepted
531
+ at submit time and then queued indefinitely
532
+ ```
533
+
534
+ Kubernetes is the exception: server-side dry-run runs real admission, so a
535
+ `ResourceQuota` breach *is* caught before submission. nodetop says which situation you are
536
+ in instead of assuming.
537
+
538
+ Related: `Cluster.effective_max_walltime()` returns the tighter of the queue's limit and
539
+ its limit set, and says where the binding number came from:
540
+
541
+ ```
542
+ maxtime 7-00:00:00 (from slurm QOS test; the partition itself says unlimited)
543
+ ```
544
+
545
+ ### 3a. Idle cores with no memory behind them
546
+
547
+ `wide` advertised **2322 free cores**. 2035 of them were unusable, and the reason was
548
+ not in the core counts at all:
549
+
550
+ ```
551
+ $ scontrol show node cn-0023
552
+ CPUAlloc=4 CPUTot=48 RealMemory=184320 AllocMem=184320
553
+ ```
554
+
555
+ Four cores in use, forty-four idle, and every byte of memory allocated to the job holding
556
+ those four. The cluster runs `SelectTypeParameters=CR_CORE_MEMORY`, so memory is a
557
+ consumable resource: nothing more can land on that node. 47 of `wide`'s 190 nodes were
558
+ in exactly that state, and `DefMemPerNode=UNLIMITED` there means a job that names no
559
+ `--mem` asks for the *whole node* — so not even a one-core job with no memory request
560
+ would fit.
561
+
562
+ The old behaviour reported all 2322 as free, ranked `wide` first on the strength of it,
563
+ drew it a full-length meter, and told `where -c 4` that 79 nodes fitted. The honest
564
+ numbers are 287, second place, and 32.
565
+
566
+ Two things make this safe to apply rather than a new way to be wrong:
567
+
568
+ * **`memory_mb <= 0` means "not reported", not "none".** A backend that never mgrp-xions
569
+ memory has the constraint skipped.
570
+ * **Not every Slurm cluster enforces memory.** Without `_MEMORY` in
571
+ `SelectTypeParameters`, Slurm never decremgrp-xs it, so `AllocMem` records what jobs asked
572
+ for rather than a ceiling — and reading it as one would report a whole cluster as full.
573
+ `SlurmBackend.memory_is_consumable()` asks, once, and stamps the answer onto every node.
574
+ Unreadable config claims *less* capacity, which is the bias everywhere else here.
575
+
576
+ ### 3b. Free nodes are not a start time
577
+
578
+ `sbatch --test-only` will tell you when the scheduler expects to start your job. nodetop
579
+ had that number in hand and printed `now` instead, because the row was decided by free
580
+ hardware alone. For a four-core ten-minute job, with every one of these partitions
581
+ reporting free cores:
582
+
583
+ | partition | nodetop said | the scheduler said |
584
+ |---|---|---|
585
+ | `gpu-a` | RUN NOW | now |
586
+ | `bigmem` | RUN NOW | now |
587
+ | `amd` | RUN NOW | **in 4h 24m** |
588
+ | `build` | RUN NOW | **in 8h** |
589
+ | `wide` | RUN NOW | **in 18h** |
590
+
591
+ `amd` is the largest pool of free cores on the cluster, so it is the top row of every
592
+ listing — and it was four and a half hours from starting anything. `Placemgrp-x.starts_now`
593
+ now asks both questions, and a placemgrp-x with room but a queue ahead of it falls through
594
+ to `QUEUE`, which is a differgrp-x next move: submit and wait. Where no dry-run exists the
595
+ two questions collapse into one, rather than answering "no".
596
+
597
+ ### 4. No scheduler models the accelerator
598
+
599
+ Slurm, PBS, LSF, SGE and Kubernetes all treat a GPU as an opaque countable resource, so
600
+ all of them will place a bf16 job on a V100 and let it die at the first autocast, or
601
+ resume an fp8 checkpoint on a card with no fp8.
602
+
603
+ ```
604
+ $ nodetop where -g 4 --gpu-mem 40 --needs bf16 -t 2-00:00:00
605
+ ╭─ job ────────────────────────────────────────────────────────────────────╮
606
+ │ 1 node, 4 GPU/node (4 total), >=40 GiB HBM, 2-00:00:00, needs bf16 │
607
+ │ [NOWHERE NOW] nothing can start immediately │
608
+ ╰──────────────────────────────────────────────────────────────────────────╯
609
+
610
+ ⏺ placemgrp-xs 19 partitions considered
611
+ PARTITION FREE CAPABLE START ACCESS ACCELERATORS
612
+ ─ ─────── ────────────────── ──── ─────── ----- ──────────────── ─────────────────
613
+ ◐ QUEUE gpu-a 0/1 44/44 44m confirmed A100x22, A40x22
614
+ ○ BLOCKED grp-b-gpu 2/1 6/30 · ACCOUNT_MISMATCH H200x4, A100x2
615
+ ○ BLOCKED hcn1-gpu 1/1 1/1 · INVALID_QOS L40Sx1
616
+ ▲ LIMIT grp-z-gpu 3/1 3/3 · confirmed A100x2, H100x1
617
+ ✗ WRONG HW grp-k-gpu 0/1 0/2 · confirmed RTX6000x2
618
+
619
+ ● runs now ○ not permitted ✗ no node of the right kind
620
+ ▲ over a declared ceiling ◐ would queue
621
+ ```
622
+
623
+ Note `grp-b-gpu`: two nodes free, six capable, and you still cannot use it. Without
624
+ `--check` the `ACCESS` column is absgrp-x grp-xirely and that row would read as a queue worth
625
+ waiting for.
626
+
627
+ Every label implies a differgrp-x next move, so collapsing two of them sends you somewhere
628
+ useless — and the labels are picked in order of what you *cannot* work around:
629
+
630
+ | label | what it means | what to do |
631
+ |---|---|---|
632
+ | `RUN NOW` | room right now | submit |
633
+ | `BLOCKED` | no job of any shape runs here | ask for access |
634
+ | `WRONG HW` | no node of the right kind | go elsewhere; waiting will not help |
635
+ | `TOO FEW` | right nodes, never enough of them | ask for fewer nodes |
636
+ | `LIMIT` | over a declared ceiling | resize or shorten |
637
+ | `QUEUE` | permitted, capable, just full | submit and wait |
638
+
639
+ The renderer used to test `Placemgrp-x.reachable`, which is deliberately *both* "permitted"
640
+ and "the shape is legal". So a queue whose only problem was a per-user accelerator ceiling
641
+ rendered as `BLOCKED` / "not permitted", telling you to go request access you already had.
642
+ On a live cluster a `-N 40` request made **all five** candidate partitions read "not
643
+ permitted"; the answer was `-N 8`.
644
+
645
+ `TOO FEW` exists for the same reason. "Could this queue ever host the shape" needs the
646
+ right *kind* of node **and enough of them**; checking only the kind made a one-node queue
647
+ asked for forty report possible, so it rendered `QUEUE` — a wait for capacity the queue
648
+ does not contain. A queue whose node list is incomplete is exempt: ruling it out on a
649
+ lookup failure would be the worse error.
650
+
651
+ `CAPABLE` carries a denominator because the reason histogram beneath it cannot be summed
652
+ back into one — a node can fail on several counts at once. `1/11` next to "5 nodes: V100
653
+ lacks bf16; 5 nodes: RTX6000 lacks bf16" closes; a bare `1` does not.
654
+
655
+ The legend lists only the states actually presgrp-x. It was a fixed list of four, which meant
656
+ `where` explained "wrong hardware" over tables containing no such row.
657
+
658
+ `nodetop accelerators` turns that into a cluster-wide answer, which is the
659
+ question you actually have before committing to a run:
660
+
661
+ ```
662
+ ⏺ by model
663
+ MODEL VENDOR ARCH MEM NODES FREE BF16 FP8
664
+ A100 NVIDIA sm_80 40G? 29 █········ 13/116 yes no
665
+ H100 NVIDIA sm_90 80G? 5 ███▌····· 7/18 yes yes
666
+ RTX6000 NVIDIA sm_75 24G 16 █████▍··· 38/64 no no
667
+
668
+ ⏺ capability reach share of the cluster that can do this at all
669
+ bf16 █████████████▌···· 268/358 installed, 47 free now
670
+ fp8 ███··············· 60/358 installed, 18 free now
671
+ ```
672
+
673
+ Free counts exclude unschedulable nodes on purpose: hardware behind a drained
674
+ node is installed, not reachable, and an unidgrp-xifiable accelerator is counted
675
+ in **no** capability row rather than being assumed capable.
676
+
677
+ On Kubernetes, occupancy follows the scheduler's real arithmetic rather than a
678
+ naive sum: a pod reserves `max(sum(containers), max(init containers)) +
679
+ sidecars + spec.overhead`, so a node held by a large init container is reported
680
+ full rather than free.
681
+
682
+ Four details make this trustworthy rather than merely clever:
683
+
684
+ - **Capability is stored per model, per vendor — never derived from one number.** Deriving
685
+ dtype support from a CUDA compute capability works until an AMD or Intel part appears
686
+ and then reports nonsense. NVIDIA, AMD CDNA and Intel Xe/Gaudi are all covered.
687
+ - **The model comes from the typed resource first, then labels.** On the reference cluster
688
+ 90 of 91 GPU nodes report a bare `Gres=gpu:4`; the model is only in the node features,
689
+ in whatever case the admin typed (`a100`, `A100`, `H100`, `L40S`). Kubernetes is the
690
+ same story with `nvidia.com/gpu.product=NVIDIA-A100-SXM4-40GB`.
691
+ - **An unidgrp-xifiable accelerator is `None`, never a guess** — and unknown is not treated
692
+ as incapable. Only a *known* negative excludes a node.
693
+ - **Memory is an inference and is labelled one.** `A100` alone does not say 40 GB or
694
+ 80 GB, and no scheduler records it. The conservative variant is assumed, so the failure
695
+ mode is a needless warning rather than an OOM ninety minutes into a run.
696
+
697
+ ---
698
+
699
+ ## Commands
700
+
701
+ ```bash
702
+ nodetop # cluster overview; unusable queues first
703
+ nodetop backends # which systems are here, and which can confirm access
704
+ nodetop queues # compact table (alias: partitions)
705
+ nodetop queues -q gpuq # every gate for one queue (--detail for all)
706
+ nodetop nodes --gpu # invgrp-xory with model, vendor, arch and memory
707
+ nodetop health # down, drained, and silgrp-xly degraded nodes
708
+ nodetop where -g 4 --gpu-mem 40 --needs bf16 -t 2-00:00:00
709
+ nodetop where -g 4 --declared # trust the allowlists, skip the dry-run
710
+ nodetop where -g 4 --all # include the ruled-out queues and why
711
+ nodetop where -c 8 --tolerates dedicated=inference:NoSchedule
712
+ nodetop check -q gpuq -g 1 # the dry-run, directly (alias: probe)
713
+ nodetop gpus # invgrp-xory + what each model can do (alias: accel)
714
+ nodetop snapshot -o snap.json # record this cluster's state
715
+ nodetop --replay snap.json status # ...and analyse it later
716
+ nodetop exclude --gpu-nodes # exclusion list for CPU-only work
717
+ nodetop --backend kubernetes status
718
+ ```
719
+
720
+ The `ACCESS` column appears only once a dry-run has answered — the same word on
721
+ every row is noise, so with no probe run it is dropped and stated once, along
722
+ with the flag that would confirm it. `check` likewise declares which of your
723
+ flags took no part: `--needs` and `--gpu-mem` cannot, because no scheduler can
724
+ express them, so the control plane was never asked.
725
+
726
+ `--check` narrows itself: a queue that publishes an account allowlist has already
727
+ answered most of the question, so only the intersection with your own accounts is
728
+ dry-run, capped per queue. On a cluster where one user holds 34 associations across 87
729
+ partitions that is the difference between a couple of seconds and several hundred
730
+ submissions.
731
+
732
+ Exit status is meaningful, so `nodetop check … && sbatch …` behaves: `where` and
733
+ `check` return 0 only when somewhere could actually take the job, 1 when
734
+ nothing can, and `check` returns 2 when the system has no dry-run to ask.
735
+
736
+ `--all` widens `status`, `queues` and `where` to include what they would otherwise
737
+ filter out — on `where` that means the ruled-out queues with their blockers attached,
738
+ which is what you want when the question is "why can nothing run anywhere?".
739
+ `--tolerates` declares the node restrictions a job accepts; on Kubernetes that is how a
740
+ tainted node becomes eligible, and nothing else can express it.
741
+
742
+ `--json` works on every command, on either side of the sub-command name, as do
743
+ `--no-color`, `--ascii`, `--backend` and `--replay`. The JSON carries everything the
744
+ text does, including the caveats — a note that only appears in prose is a note a script
745
+ never learns, and a script is the consumer most likely to act on the answer. `check`
746
+ therefore reports `not_covered` and `filter_scheduler_disagreemgrp-xs` alongside the
747
+ per-queue verdicts, and a test asserts the two renderers cannot diverge.
748
+
749
+ That property has been broken twice and both were the same shape: `status --json` returned
750
+ `Cluster.summary()` and returned it *early*, so it answered a differgrp-x question than the
751
+ panel — 358 accelerators and 126 free, cluster-wide, where the panel said "222 of 358
752
+ GPUs, 53 free" for the partitions this account can submit to — and it carried neither the
753
+ funnel nor a single partition row. `queues --json` carried no core figures at all while
754
+ its text form printed two. Both now emit from the same population the text does, which
755
+ means `status --json` pays for the same dry-runs the panel pays for; `--declared` skips
756
+ them for both forms alike. One name per quantity, too: `effective_free_cpus` means the
757
+ same thing in `status`, `queues`, `nodes` and `zoom`, and sums across them. The vocabulary follows the system — `partition` on Slurm, `queue` on PBS/LSF/SGE, `pool` with no
758
+ scheduler — and `-p/--partition` is accepted everywhere as an alias for `-q/--queue`.
759
+
760
+ Two worth knowing:
761
+
762
+ - **`health` finds the node the scheduler still hands out while it runs several times
763
+ slower than its siblings** — a power-capped or thermally throttled accelerator reports a
764
+ perfectly healthy state, and that shows up as a mysteriously slow job, not an error.
765
+ Nodes are called `degraded` only when they are *schedulable* and carry a suspicious
766
+ reason; an already-drained node is not impaired-but-usable, it is out.
767
+ - **The reason field is parsed before anything reads it.** Slurm stamps every drain
768
+ reason with `[who@when]`, and that suffix breaks both things built on top of it.
769
+ Grouping on the raw string splits one maintenance window into a row per second the
770
+ operator spgrp-x typing — on a 607-node cluster, 52 nodes out for the same cause rendered
771
+ as five findings differing only in a timestamp, and the actual answer ("52 nodes, out
772
+ for five weeks") was nowhere on the screen. And the keyword list that finds impairmgrp-x
773
+ holds short words like `fan`, `slow` and `clock`, which against the whole string also
774
+ match the *operator's username*: an admin called `fanl` marked every node they touched
775
+ as thermally throttled. `split_reason` separates the two, the text view groups by cause
776
+ and reports the age of the oldest stamp, and `--json` exposes the same parse so a script
777
+ cannot disagree with the terminal about what one cause is.
778
+ - **`exclude --gpu-nodes` decides accelerator-ness from the resource count, never the
779
+ hostname.** Clusters routinely have a `gn-bigmem1` with no GPU sitting among 44
780
+ nodes that have four each. Filtering on the name prefix is how CPU work ends up
781
+ squatting an accelerator.
782
+
783
+ ### 1d. `idle 0` does not mean there is nothing there for you
784
+
785
+ > idle is 0 doesn't mean there is nothing from there we can't use.
786
+
787
+ Right, and the column invites that reading. `idle` counts **wholly** idle nodes, and on a
788
+ busy cluster almost nothing is wholly idle. Measured here: `amd` reports `idle 0` while
789
+ carrying **2105 free cores spread over 24 of its 40 nodes**, every one of them running
790
+ something. Any job that does not need a whole node can start there immediately. The
791
+ overview cannot show that without turning into a node listing, so the number that fits in
792
+ the column is precisely the one most easily misread.
793
+
794
+ Two changes. The count that was misleading now states both figures wherever a single
795
+ partition is expanded — `idle 0 wholly free, 24 of 40 with something spare` — so the
796
+ smaller number can no longer be read as the whole answer. And `zoom` opens one partition
797
+ up: the same gate-by-gate block `queues -q NAME` prints, then the nodes inside it, roomiest
798
+ first, in the same table `nodes` prints.
799
+
800
+ ```
801
+ ● amd [UP]
802
+ nodes █████████████▋ 39/40 schedulable
803
+ idle 0 wholly free, 24 of 40 with something spare
804
+ accel none
805
+ maxtime 1-12:00:00 (from slurm QOS amd; the partition itself says unlimited)
806
+
807
+ ⏺ inside 40 nodes · 1 out · roomiest first
808
+ no node here is grp-xirely free, but 24 nodes have something spare -- 2105 cores. A job
809
+ that does not need a whole node can start now.
810
+ ────────────────────────────────────────────────────────
811
+ node state cpu free mem free gpu
812
+ ◐ cn-0507 MIXED ███████▏ 114/128 29/244G ·
813
+ ◐ cn-0519 MIXED ███████▏ 114/128 29/244G ·
814
+ ```
815
+
816
+ **The header and the table are the existing renderers, not lookalikes.** `_queues_detail`
817
+ draws the block and `_node_rows` builds the rows, both shared with the commands they came
818
+ from. Two renderers of the same thing drift — this file has the scars, which is why
819
+ `_grid` exists — and a zoom view whose columns disagree with the listing it zooms out to
820
+ is worse than no zoom view at all.
821
+
822
+ Building it surfaced a bug in the ordering added earlier. A drained node still reports its
823
+ full complemgrp-x free, so ranking nodes by free capacity put a `DOWN+DRAIN` node advertising
824
+ `32/32 cores, 4/4 GPUs` at the **top** of the answer to "where is there room" — phantom
825
+ capacity leading the list. `Queue.effective_free_*` had always excluded those; the sort had
826
+ not. Unschedulable nodes now sort last in both views regardless of what their counters say.
827
+
828
+ ### 1e. You cannot act on a printout
829
+
830
+ > you can't operate on the print out at all. i hope there is something like claude code
831
+ > where we you can move the cursor up and down to select things
832
+
833
+ **On a terminal this is the default.** A highlight sits on a row, the arrow keys move it,
834
+ grp-xer opens that partition, and you land back on the list when you are done. There is no
835
+ flag to turn it on and nothing on screen explaining it.
836
+
837
+ That last part is deliberate. A flag to switch it on meant advertising the flag, and a line
838
+ of the overview spgrp-x telling the reader that a key exists is a line nobody reads — the
839
+ overview has now lost a column glossary, a legend, a footer of suggestions, a DEAD block
840
+ and, finally, its own key hint. The highlight is the affordance: a row in inverse video is
841
+ something you try the arrow keys on. Every line of the default view is numbers.
842
+
843
+ `--static` prints the report and exits. It exists for a terminal that is not a person --
844
+ `watch nodetop` allocates a pty and would otherwise block on a keystroke forever -- and it
845
+ is documgrp-xed in `--help` and nowhere else, which is where a flag belongs.
846
+
847
+ Three constraints ruled out reaching for a TUI library, and between them they decided the
848
+ whole shape of it:
849
+
850
+ - **No dependencies.** This is a tool you run on a login node while the cluster is
851
+ misbehaving, so it has to work with nothing but the system Python. `termios` and `tty`
852
+ are standard library on every platform the package claims; a TUI library is not
853
+ installable at the momgrp-x it is needed.
854
+ - **The same output.** Nothing in the interactive path renders anything. It takes the
855
+ finished lines `status` already built and wraps one of them in inverse video. A second
856
+ renderer is how the interactive view would start disagreeing with the printed one — the
857
+ same reason `_grid` and `_node_rows` exist.
858
+ - **It degrades to the printout.** Redirected, piped, `TERM=dumb`, or a platform without
859
+ `termios`: you get the static report, not an error. Both streams are checked, and for
860
+ differgrp-x reasons — stdout must be a terminal for a highlight to mean anything, and
861
+ *stdin* must be one or a run with input redirected from a file would consume that file
862
+ as keystrokes.
863
+
864
+ Arrows or `j`/`k`, `g`/`G` or Home/End to jump, grp-xer or space to open, `q`/Escape/Ctrl-C
865
+ to leave. Movemgrp-x wraps at both ends, which is cheaper than a page-down binding.
866
+
867
+ **Driving it through a real pty is what made it work.** The first version decoded every
868
+ arrow key as a quit, and the reason is worth writing down because the code looked right:
869
+ `sys.stdin.read(1)` fills *Python's* buffer from the kernel, so after taking the `ESC` of
870
+ an arrow key the following `[B` sits in userspace where `select()` on the descriptor cannot
871
+ see it. The peek came back empty, the sequence read as a lone Escape, and a lone Escape
872
+ means quit. Reading the descriptor directly with `os.read` keeps the poll and the read
873
+ looking at the same buffer. A unit test with a fake character reader would have passed
874
+ either way.
875
+
876
+ A second bug the pty found, and the same shape as the first -- a state assumption that a
877
+ unit test cannot see. Raw mode was scoped to the list, so the keystroke that dismisses a
878
+ zoomed view was read in *canonical* mode, where nothing arrives until Enter. "Any key
879
+ returns" had quietly become "press grp-xer". Raw mode now spans the whole interaction.
880
+
881
+ **Two more that only a terminal could show, both found by making the default
882
+ interactive and then attacking what shipped.**
883
+
884
+ A frame taller than the window destroys the screen. The repaint moves the cursor up by the
885
+ height of the previous frame, and 84 partitions is a 93-line frame: on a 24-row terminal
886
+ the cursor clamps at the top, the clear-to-end lands in the wrong place, and every keypress
887
+ leaves another copy of the listing behind — 252 rows on screen for 84 partitions. There is
888
+ a viewport now, and only *rows* are dropped: headings, the funnel and the totals survive
889
+ whatever scrolls, because they are the frame of reference for the row you are looking at. A
890
+ short line says `3 above 67 below`, because silgrp-xly dropping rows reads as "this is all
891
+ of them", which is the lie the funnel exists to prevgrp-x.
892
+
893
+ `finally` does not restore a terminal. A default-handled `SIGTERM` or `SIGHUP` ends the
894
+ process without raising anything, so nothing runs and the terminal is left with echo and
895
+ canonical mode off — a shell that appears dead, with no echo to tell you that typing
896
+ `reset` is working. `SIGINT` was fine only because Python turns it into an exception.
897
+ Measured through a pty: after `SIGTERM`, `echo=False canonical=False`. The fatal signals
898
+ are now caught long enough to put the terminal back and then re-raised with the default
899
+ disposition, because a tool that swallows `SIGTERM` is worse than one that leaves a messy
900
+ terminal. `SIGTSTP` is the same problem wearing Ctrl-Z: suspending hands the terminal back
901
+ and resuming takes it again. **`SIGHUP` is the one that matters on a login node — it is
902
+ what a dropped ssh connection sends.**
903
+
904
+ The loop itself is injectable — `read_key` takes a character reader and `select` takes a
905
+ key source and a writer — so the move logic, the wrap-around, the repaint and the
906
+ KeyboardInterrupt path are all tested without a terminal. An interactive mode that is only
907
+ ever tested by hand is one that breaks silgrp-xly.
908
+
909
+ ### 1f. One screen, three levels, and a cursor you can see
910
+
911
+ > the ui should be just one where i can go in and go out rather than print every interface
912
+ > on the terminal and select the new interface after printing the new one
913
+ >
914
+ > the cursor isn't clear at all. the users don't know if they can move the cursor up or down
915
+
916
+ Partitions, then the nodes inside one, then the jobs on one node — each level **replaces**
917
+ the last in the same rows rather than printing beneath it. `select` erases its block on the
918
+ way out, so there is one screen instead of a transcript of screens, and the cursor position
919
+ is remembered per level: stepping out lands you on the row you came from, not at the top of
920
+ a list you have already read. Enter or Right goes in, Escape/Left/Backspace comes out, `q`
921
+ quits from any depth.
922
+
923
+ **The highlight was not a matter of taste — it was broken.** A rendered row is full of
924
+ coloured cells and each one ends in `ESC [ 0 m`, which clears reverse video along with the
925
+ colour. Wrapping such a row in `ESC [ 7 m` therefore highlighted it as far as the first
926
+ coloured cell and no further, so the selected row really was a smudge on the left. Inverse
927
+ is now re-armed after every embedded reset — 19 of 19 visible characters inside the
928
+ highlight, where it had been about four. And there is a `❯` in a one-character mark column,
929
+ because `Style.inverse` is a **no-op** under `NO_COLOR`: without the glyph the selection was
930
+ invisible in that mode grp-xirely, and a glyph also implies the axis you can move along.
931
+
932
+ Jobs come from a `squeue` query fetched **lazily and cached** — a deliberate exception to
933
+ the one-snapshot rule, because almost no invocation asks for jobs, and while browsing the
934
+ newest answer beats the consistgrp-x one.
935
+
936
+ **`squeue` reports a job's counts as totals across every node it holds**, which in a
937
+ per-node table is actively misleading: a nine-node job appeared as `431` cores on a
938
+ 48-core node, a number the reader knows to be impossible, and one impossible cell
939
+ discredits the whole column. The exact per-node share needs a `scontrol show job` per row,
940
+ and dividing would be a guess dressed as a fact — a job need not be allocated uniformly.
941
+ So both exact numbers are shown, `431 x9`, and single-node jobs (most of them) are unmarked
942
+ and read directly. Verified the other way too: on the 161 nodes where every job is
943
+ single-node, the job cores sum **exactly** to the node's allocation.
944
+
945
+ An empty job list distinguishes four cases, because "no jobs here" on a visibly busy node
946
+ would be phantom capacity in a new place: the query failed, this backend cannot list jobs,
947
+ busy but nothing claims the resources, or genuinely idle.
948
+
949
+ ### 1h. A per-node view has to show the per-node share
950
+
951
+ The job table under a node showed each job's totals across every node it holds, because
952
+ that is what a job list reports. On a 48-core machine:
953
+
954
+ ```
955
+ job user cpu gpu used left name
956
+ 4210001 rmartin 512 x42 · 1-06:13:04 5:46:56 _interactive
957
+ ```
958
+
959
+ 512 cores on a 48-core node, marked `x42` to mean "spread over 42 nodes" — a number the
960
+ reader knows to be impossible next to a marker nobody could decode: *"the cpu column
961
+ doesn't make any sense. what do the column grp-xries mean?"* And no memory column at all,
962
+ on the resource that most often decides whether a node is usable.
963
+
964
+ Only the scheduler knows the split, and it will say:
965
+
966
+ ```
967
+ $ scontrol show job -d 4210001
968
+ Nodes=cn-0114 CPU_IDs=41-47 Mem=7168 GRES=
969
+ ```
970
+
971
+ Seven cores and seven gigabytes, not 512. So `Allocation` is now fetched and the columns
972
+ are `cpu`, `mem`, `gpu` — this node's share — with the span in its own `nodes` column,
973
+ presgrp-x only when something actually spans. Three details made it work:
974
+
975
+ * **One call for the whole cluster.** 0.6s and 4.7 MB for 2928 jobs, against 0.13s for a
976
+ single job — so asking about five jobs already pays for asking about all of them, and a
977
+ node with 49 array tasks on it would otherwise stall an interactive repaint for six
978
+ seconds. Fetched lazily on the first per-node view and cached.
979
+ * **`squeue` and `scontrol` disagree on what a job is called.** `squeue` names a running
980
+ array task `4210001_132`; `scontrol` gives it a JobId of its own and records the array
981
+ separately. 1864 of 2928 jobs here are array tasks, so keying on `JobId` alone would
982
+ have found a share for none of them. Each allocation is registered under both spellings.
983
+ * **`Nodes=` is a nodelist.** Slurm collapses consecutive nodes that got the same shape of
984
+ allocation — `Nodes=cn-[0521-0522] CPU_IDs=78-94` — and the figures then apply to
985
+ each of them.
986
+
987
+ A single-node job needs no lookup at all: its totals *are* its share, which is most jobs
988
+ and the whole answer on a backend that models a job as living on one machine. A multi-node
989
+ job whose share cannot be established prints `?` rather than substituting a total.
990
+
991
+ ### 1i. Everything is a level, including the leaf
992
+
993
+ Two dead ends, both found by using the thing:
994
+
995
+ * **Enter on a job did nothing.** The stack popped instead of pushing, so the row was the
996
+ deepest the tool wgrp-x — with the job name truncated and its node list never shown.
997
+ *"when choosing any of the job here, it doesn't go into the job details but going back
998
+ to the original node".* A job now has its own view: the name in full, its share of this
999
+ node beside the job's totals, and the whole nodelist.
1000
+ * **Enter on a drained node showed four lines saying "nothing running here".** No state, no
1001
+ reason — and the reason is what the reader opened it for, truncated in the listing at
1002
+ `maintenance [root@…`. *"after hitting this one, nothing shows up, even people wanting
1003
+ to see the reason why this node is down."* The node's own view now leads with its state,
1004
+ prints the reason whole with the operator and timestamp separated out, says when the
1005
+ control plane has lost contact, and — for an unschedulable node — says "nothing running
1006
+ here, and nothing can start" rather than the phrasing that reads as *free*.
1007
+
1008
+ The funnel's own total became a target for the same reason: *"why can't we select the 87
1009
+ partitions?"* It opens every queue on the cluster with the word that put it there, which
1010
+ is the one view where the funnel's arithmetic can be checked rather than trusted. The `→`
1011
+ between the total and the first term wgrp-x with it — *"why there is a right arrow here? it
1012
+ makes no sense at all"* — because once every term is a peer they read as a list and are
1013
+ punctuated as one.
1014
+
1015
+ ### 1j. The redraw, twice broken
1016
+
1017
+ **A frame exactly as tall as the terminal does not fit.** The node listing reserved six
1018
+ rows for its own chrome and filled the rest, so its frame came out at exactly `LINES` —
1019
+ and the last line's newline scrolls the screen by one, which puts the repaint's cursor-up
1020
+ one line low. Every keypress then orphaned a top border:
1021
+
1022
+ ```
1023
+ ╭──────────────────────────────────────────────╮
1024
+ ╭──────────────────────────────────────────────╮
1025
+ ╭──────────────────────────────────────────────╮
1026
+ ... thirteen of them
1027
+ │ gpu-a · 44 nodes · 28 with room │
1028
+ ```
1029
+
1030
+ One spare line, subtracted inside the windowing helper so no caller has to remember it.
1031
+ Below ten rows the chrome alone exceeds the screen, and there `interactive.supported()`
1032
+ now returns False: the static print scrolls, which is merely inconvenigrp-x.
1033
+
1034
+ **And the repaint blanked the screen before drawing.** It moved to the top of the block,
1035
+ cleared everything downward with `ESC[J`, and only then wrote the new lines — two writes
1036
+ with the screen empty in between. Holding an arrow key turned that gap into a strobe:
1037
+ *"when pressing down arrow constantly, the app is flickering"*. Two fixes, measured
1038
+ through a pty against the real cluster with twelve Down presses:
1039
+
1040
+ | | before | after |
1041
+ |---|---|---|
1042
+ | frames drawn | 12 | **1** |
1043
+ | screen-clearing escapes | 12 | **0** |
1044
+
1045
+ Each line is now written over the old one and cleared only to end-of-line as it goes, in a
1046
+ single write per frame, so no cell is ever empty. And a burst of keypresses coalesces:
1047
+ a held key arrives as a stream of escape sequences and only the last position matters, so
1048
+ the repaint waits until the input queue is empty — capped, because a screen that never
1049
+ updates would be worse than one that updates too often.
1050
+
1051
+ ### 5. A device index read as a device count
1052
+
1053
+ The worst defect found so far, and it looked like a shuffled table rather than a parse
1054
+ error. Three jobs sharing one node's four accelerators:
1055
+
1056
+ | job | `GRES=` | reported | actual |
1057
+ |---|---|---|---|
1058
+ | 4210001 | `gpu:2(IDX:0,3)` | **0** | 2 |
1059
+ | 4210001_1 | `gpu:1(IDX:2)` | **2** | 1 |
1060
+ | 4210001 | `gpu:1(IDX:1)` | 1 | 1 |
1061
+
1062
+ Slurm appends *which* devices, not just how many, and the suffix holds both colons and
1063
+ commas — the two characters this field is split on. `gpu:2(IDX:0,3)` split on commas gives
1064
+ `gpu:2(IDX:0` and `3)`; the first, split on colons, ends in `0`. So the parser read the
1065
+ device index as the count, and the wrong number it produced was often another job's, which
1066
+ is why it read as rows out of order. The invariant that would have caught it in one line:
1067
+ 0 + 2 + 1 = 3 on a node reporting all four allocated.
1068
+
1069
+ The same field on a node — `Gres=gpu:v100:4(S:0-1)`, Slurm printing socket affinity —
1070
+ parsed as **zero accelerators**. This cluster does not print that suffix, so that half was
1071
+ latgrp-x, and it would have made every GPU node on a cluster that does print it look like a
1072
+ CPU node.
1073
+
1074
+ Fixed by removing every pargrp-xhesised group before splitting. Then verified rather than
1075
+ assumed, against the scheduler's own answers on a live cluster:
1076
+
1077
+ | checked against | count | mismatches |
1078
+ |---|---|---|
1079
+ | `scontrol show node` — cores, memory, accelerators, allocated and total | 607 nodes × 6 fields | 0 |
1080
+ | `squeue` — per-job totals | 1209 | 0 |
1081
+ | `scontrol show job -d` — per-node shares | 1717 | 0 |
1082
+ | `sinfo` — per-partition accelerator totals | 92 | 0 |
1083
+
1084
+ Two things that look like discrepancies and are not. Rerunning the sweep shifts a handful
1085
+ of `*_alloc` fields on two or three nodes: that is a job starting between the snapshot and
1086
+ the check, and the mismatches move rather than persisting. And a **cancelled** job's
1087
+ allocation block keeps its CPU and memory lines while `GRES=` empties out, because Slurm has
1088
+ already taken the accelerators back — so a node in teardown can show shares that sum to
1089
+ less than it holds. Both are the cluster changing, not the parser.
1090
+
1091
+ ### 5a. And a count that was not a number
1092
+
1093
+ Swept for the same defect elsewhere and found its opposite in PBS. Its node parser called
1094
+ `int()` directly on the field:
1095
+
1096
+ ```
1097
+ resources_available.ngpus = unlimited # PBS, for an uncapped resource
1098
+ resources_available.ngpus = 4x # a site script
1099
+ ```
1100
+
1101
+ `int()` raises on both, and the exception wgrp-x straight through the node parser — so one
1102
+ odd field on one node emptied the *grp-xire* node list, and an empty node list is reported as
1103
+ "no nodes -- wrong backend, or the control plane is down". A misdiagnosis rather than a gap,
1104
+ which is the worse of the two failures. The same code let a *negative* count through, which
1105
+ Slurm's own helper exists to prevgrp-x: `cpus_free` is `total - alloc`, so an allocation of -5
1106
+ against a total of 0 reports five free CPUs that do not exist.
1107
+
1108
+ Both adapters now share one `count()` in `backends/base.py` rather than each keeping its own
1109
+ — having two implemgrp-xations of the same job is how they came to disagree in the first
1110
+ place.
1111
+
1112
+ ### 6. `·` cannot mean two things -- or a number
1113
+
1114
+ `·` is this tool's empty cell. In a column headed `gpu free` it was appearing both on a node
1115
+ with no accelerator installed and on a job holding none of a node's four — *"putting a dot
1116
+ there means nothing. you put the same sign in the gpu partition but there is gpus in those
1117
+ nodes."* Then it turned up again in the `nodes` column, standing in for the number **1**:
1118
+ *"what does . mean in the node column? why can't you put 1 there?"* A single-node job spans
1119
+ one node, and a count column holds counts.
1120
+
1121
+ So the rule is written down on the glyph itself. `·` is a **separator between words** and
1122
+ never the contgrp-x of a cell. A measuremgrp-x goes in as a number, `0` included. `—` is what a
1123
+ question that does not arise looks like — a node with no accelerator under `gpu free`, a
1124
+ partition that can start nothing under `start`, a field the control plane never reported —
1125
+ and it reads as not-applicable with colour off and in ASCII. Six more cells were carrying a
1126
+ `·` for that meaning: the routing-queue rows in `queues`, the accelerator column in
1127
+ `health`, `start` and `gpus` in `where`, the unreported account/QOS/filter fields in
1128
+ `check`, and the vendor/arch/memory of an unidgrp-xifiable accelerator in `gpus`. A test now
1129
+ sweeps the grid rows of seven commands and fails on any cell that is a bare `·`.
1130
+
1131
+ Where a whole column would be dashes, it is dropped instead: `table` already omits a column
1132
+ no row fills, so a CPU-only partition spends no width on a question that does not arise.
1133
+
1134
+ The headers wgrp-x the same way. It was `cpu | free | mem free | gpu`, with `cpu` over the
1135
+ meter and a bare `free` over the fraction beside it — *"what does 'free' mean here? and then
1136
+ after that, you have 'mem free'. why so many frees?"* One word was doing the work of three
1137
+ labels and none of them said which resource it belonged to. Now every column names its own:
1138
+ `cpu free`, `mem free`, `gpu free`, with the meter unlabelled beside the number it draws, in
1139
+ the same order the overview's table uses.
1140
+
1141
+ ### 1k. One window, whatever is in it
1142
+
1143
+ The frame sized itself to its contgrp-x, which is right for a printout and wrong for a
1144
+ screen you move around in. Stepping from the overview into `3 down` shrank the box to a
1145
+ third of its width and four rows:
1146
+
1147
+ ```
1148
+ ╭─────────────────────────────────────────────────╮
1149
+ │ 3 down │
1150
+ │ partition why nodes cores models │
1151
+ │ ❯ eng down 48 4608 │
1152
+ ╰─────────────────────────────────────────────────╯
1153
+ ```
1154
+
1155
+ *"whatever we choose in the ui, the window should stay the same and the text and
1156
+ information getting displayed should dynamically get adjusted."* Every view now draws at
1157
+ `term_width()` × `term_height()` and pads up to it, so the box is where the eye left it and
1158
+ only the contgrp-xs change. `term_height()` is the window less one line — the spare line the
1159
+ repaint needs — capped at 30 for the same reason `MAX_WIDTH` exists, and floored where the
1160
+ chrome no longer fits.
1161
+
1162
+ Escape got the same treatmgrp-x as Left when Left had to stop leaving the program, and should
1163
+ not have: at the root it did nothing, which is indistinguishable from a hang. It is now its
1164
+ own key — out of a nested view, out of the program at the root — while Left stays a
1165
+ movemgrp-x with nowhere to go.
1166
+
1167
+ ### 1g. The submit line, checked against the scheduler that will read it
1168
+
1169
+ The `submit` line exists to be copied, so the question is not whether it looks
1170
+ right — it is whether `sbatch` accepts it. Fed back through `sbatch --test-only`,
1171
+ every shape nodetop places is accepted verbatim: GPU counts, core counts,
1172
+ walltimes in each accepted spelling, and `--mem` round-tripping as
1173
+ `--mem=65536M`. A 2 TiB request is refused by nodetop (`NOWHERE NOW`,
1174
+ `SHAPE_UNAVAILABLE` on every reachable partition) *and* by Slurm — the two agree
1175
+ on the negative case as well as the positive ones.
1176
+
1177
+ Doing that found a papercut in the input rather than the output. `--mem` was a
1178
+ bare float in GiB, so `--mem 64G` — which is exactly what `sbatch` takes, and
1179
+ therefore the first thing a Slurm user types — was an argparse error reading
1180
+ `invalid float value: '64G'`. A tool whose premise is scheduler fluency should
1181
+ not refuse the scheduler's own notation. Both memory flags now accept `64`,
1182
+ `64G`, `64GB`, `64Gi`, `64GiB`, `65536M`, `2T` and `0.5T`, case-insensitively;
1183
+ bare numbers still mean GiB so nothing that worked before changes meaning, and
1184
+ suffixes are binary multiples like `sbatch`'s, so there is no second convgrp-xion
1185
+ to guess between. `Gi` is in there because this tool speaks Kubernetes too, and
1186
+ that is how Kubernetes writes it.
1187
+
1188
+ ## The terminal UI
1189
+
1190
+ Everything is drawn against the standard library — no curses, no rich, no
1191
+ dependency at all — and three things it handles are correctness rather than
1192
+ decoration:
1193
+
1194
+ - **Display width is not string length.** Cells are padded by what the terminal
1195
+ will actually show, so ANSI colour, East-Asian wide characters and combining
1196
+ marks do not shift a column. `width("\033[31mabc\033[0m") == 3`,
1197
+ `width("日本語") == 6`, `width("é") == 1`.
1198
+ - **Not every terminal speaks UTF-8.** Every glyph has an ASCII twin, chosen
1199
+ automatically from the real stdout encoding and forceable with `--ascii` or
1200
+ `NODETOP_ASCII=1`. The test suite asserts the ASCII path emits **no non-ASCII
1201
+ bytes at all** — a `LANG=C` session gets `+-|`, `*`, `->` and `#`, not mojibake.
1202
+ - **Colour support is a spectrum.** Truecolor, 256-colour and 16-colour are
1203
+ detected from `COLORTERM`/`TERM`, and every semantic role survives all three
1204
+ depths. `NO_COLOR`, `FORCE_COLOR` and a non-TTY pipe are all honoured.
1205
+
1206
+ - **Scheduler text cannot repaint your terminal.** A node's `Reason`, a
1207
+ Kubernetes condition message and a dry-run's stderr are operator- or
1208
+ controller-authored free text, and they go straight into a table cell. Left
1209
+ alone they do damage `width()` cannot see, because it measures what text
1210
+ *occupies* while these characters act instead: `ESC [ 2 J` clears the caller's
1211
+ terminal mid-report, `\r` returns to column zero so the rest of the row
1212
+ overwrites what was drawn — silgrp-xly, hiding contgrp-x rather than mangling it —
1213
+ `\n` splits one row into two, and `\t` measures as one column then expands to a
1214
+ tab stop. Control characters are replaced with spaces at the point the data
1215
+ grp-xers the model, so all six backends are covered by one rule and a mangled
1216
+ field still reads as mangled instead of quietly closing up. Styling is applied
1217
+ afterwards, so the escapes nodetop emits deliberately are untouched. This is
1218
+ also the `--replay` boundary: a snapshot is a JSON file that may have come
1219
+ from someone else, and reading one must not repaint your terminal.
1220
+ - **Wrapping never invgrp-xs a differgrp-x flag.** `textwrap` splits on hyphens by
1221
+ default, which turns `--test-only` into `--test-` / `only` and `gpu-a-0001`
1222
+ into two node names. Disabled everywhere, and asserted across every width
1223
+ from 24 to 80 columns.
1224
+ - **Nothing may be wider than the window.** Tables shrink their widest columns
1225
+ to fit (headers included — truncating only the data leaves the header row as
1226
+ the one line guaranteed to overflow), panels clip their contgrp-x, and prose
1227
+ wraps. Truncation is ANSI-aware, so cutting a coloured cell preserves the
1228
+ escape sequence and appends a reset rather than silgrp-xly eating visible
1229
+ characters. Verified as a test across every command at 60/80/100/120 columns.
1230
+
1231
+ Meters use eighth-block sub-cell resolution, which is not cosmetic either: at
1232
+ 14 cells a naive bar rounds 2-of-176 free accelerators down to empty, and the
1233
+ difference between 0 and 2 free is the whole question.
1234
+
1235
+ Three more decisions in the same drawing code, each of which had a wrong version first:
1236
+
1237
+ - **A bar is a box with a level in it, not a stripe.** The unfilled remainder is drawn in
1238
+ a near-background grey rather than left blank, so the eye has a fixed reference to
1239
+ measure a short fill against — `14%` and `55%` are not comparable at a glance without
1240
+ one. That grey is a step *below* the grey used for de-emphasised prose: it is a
1241
+ reference mark, not contgrp-x.
1242
+ - **The fill is a darker twin of the tone its number wears.** A bar is a slab and text is
1243
+ a line; the tone that reads as bright in a four-digit number reads as shouting across
1244
+ sixteen filled cells, and ten shouting bars are a wall. Terminals have no alpha channel,
1245
+ so the wash is a genuinely darker colour of the same hue — what compositing that hue at
1246
+ ~60% over a dark background would have produced. At 16 colours there is no room for a
1247
+ second copy of every step, so the fill simply keeps the text tone.
1248
+ - **Secondary numbers get their own grey, not the prose grey.** A total beside the free
1249
+ count it divides is still *contgrp-x*; painting it the same grey as a hint or a caveat is
1250
+ what made whole numeric columns read as furniture.
1251
+
1252
+ Panel borders carry a diagonal colour sweep — hue advances with `x + y`, lightest at the
1253
+ top-left, the way a highlight falls across a glossy surface. Every anchor colour in it is
1254
+ a *light* one, on purpose: a frame that sweeps light-to-deep puts the darkest end at the
1255
+ bottom-right, where on a dark terminal it disappears, and a border that fades out halfway
1256
+ down reads as a rendering fault rather than as a gradigrp-x. The sweep moves in hue and
1257
+ stays put in brightness, and it is drawn in runs of equal tone — about ten escape
1258
+ sequences per border rather than one per column.
1259
+
1260
+ `--help` is coloured too, and it is coloured *after* argparse has formatted it. argparse
1261
+ lays its columns out with `len()`, so painting the strings it is handed throws every
1262
+ column off by the width of its own escape sequences. Four roles and no more — flags and
1263
+ sub-commands in blue (what you type), placeholders in amber (what you substitute), section
1264
+ headings bold, defaults and example notes dim — because a help screen wearing a dozen
1265
+ colours is harder to read than one wearing none.
1266
+
1267
+ The one deliberate exemption is the `to request exactly what was checked` line.
1268
+ It is neither wrapped nor truncated, because it exists to be copied and an
1269
+ ellipsis or hanging indgrp-x there would hand you a broken command.
1270
+
1271
+ ## Library
1272
+
1273
+ ```python
1274
+ from nodetop import Cluster, JobShape, rank
1275
+
1276
+ cluster = Cluster.load() # autodetects the batch system
1277
+ cluster.backend_name # 'slurm'
1278
+ cluster.can_probe # False on PBS and LSF -- check this
1279
+ cluster.queues["test"].usable # False
1280
+ [b.code for b in cluster.queues["test"].structural_blockers()]
1281
+ # ['QUEUE_DISABLED', 'NO_ACCOUNTS', 'NO_QOS']
1282
+
1283
+ shape = JobShape(nodes=1, gpus_per_node=4, gpu_memory_gb=40,
1284
+ requires=("bf16",), walltime="2-00:00:00")
1285
+
1286
+ for place in rank(cluster, shape, use_probe=True):
1287
+ print(place.queue, place.runnable_now, place.confirmed, place.earliest_start)
1288
+ ```
1289
+
1290
+ Every query runs against one snapshot, so all the numbers in a report describe the same
1291
+ instant instead of drifting across a dozen independgrp-x calls — enforced, not just
1292
+ intended: a test asserts no backend issues the same command twice, since fetching a
1293
+ source again for a second consumer is how two instants end up in one report. A query that fails is
1294
+ recorded in `Cluster.errors` rather than silgrp-xly becoming an empty result.
1295
+
1296
+ ### Post-mortem
1297
+
1298
+ When a partition goes down mid-run, the evidence is gone by the time anyone looks.
1299
+ `nodetop snapshot` records what the queries returned and **every command replays against
1300
+ it unchanged** — on a laptop, days later, with no cluster in sight:
1301
+
1302
+ ```bash
1303
+ nodetop snapshot -o outage.json # ~500 KiB for a 607-node cluster
1304
+ nodetop --replay outage.json status # or where / accelerators / health / queues
1305
+ ```
1306
+
1307
+ Replay needs no special code path in the analysis layer: the same backend runs against a
1308
+ recorded runner instead of a live one, so it works for every backend for free. And the
1309
+ recording is honest about what it is — `can_probe` is **False** on a replay even for
1310
+ Slurm, because a recording holds the answers to the queries that were made, not to a
1311
+ dry-run nobody ran.
1312
+
1313
+ **A replay carries the data's own clock.** The banner says how old the snapshot is, and
1314
+ every relative time below it — `START`, the age of a drain reason — is measured from the
1315
+ capture instant, not from when you opened the file:
1316
+
1317
+ ```
1318
+ replaying outage.json (slurm, captured 6d 4h ago)
1319
+ ```
1320
+
1321
+ This was wrong for a while, and the interesting part is which half mattered. `captured_at`
1322
+ was being written and never read, so a replay stamped itself with the momgrp-x it was
1323
+ opened. Misdating a post-mortem is a nuisance; the real damage is that node free times are
1324
+ *absolute instants*, so comparing them against `now()` inflates every wait by the
1325
+ snapshot's age. A node recorded as free in three hours reads `overdue` the momgrp-x the
1326
+ recording is older than three hours — an authoritative-looking number that is pure
1327
+ arithmetic error. Fifteen snapshot tests passed throughout; none of them looked at the
1328
+ time.
1329
+
1330
+ Elapsed time and future waits are also formatted by separate functions on purpose.
1331
+ `format_wait` says `now` under a minute and `overdue` for anything negative, which is
1332
+ right for a start estimate and wrong for an age: reusing it printed "captured now ago",
1333
+ and "captured overdue ago" for a recording made on a host whose clock ran fast.
1334
+ `format_age` returns `None` there instead, so the caller reports clock skew rather than
1335
+ rendering a duration for it.
1336
+
1337
+ Malformed output is exercised as well — truncated records, CRLF line endings, a JSON body
1338
+ cut mid-object, a node listed twice, garbage in a numeric field. A parser that *raises*
1339
+ is fine: the failure lands in `Cluster.errors` and the report says it is partial. The
1340
+ dangerous case is one that succeeds on garbage, and two of those were real: a truncated
1341
+ Slurm record carried no state at all, which made it read as schedulable *and* empty —
1342
+ the most attractive thing in the cluster to a placemgrp-x search — and `CPUAlloc=-5`
1343
+ against `CPUTot=0` reported five free CPUs that did not exist.
1344
+
1345
+ Degenerate shapes are exercised too — an empty cluster, a single node, every node down,
1346
+ a queue nothing belongs to, a 200-character queue name, ten thousand nodes — against
1347
+ every command in every output mode. An empty cluster is treated as a *finding* rather
1348
+ than reported blandly as "0 nodes", since in practice that reading means the wrong
1349
+ backend or an unreachable control plane far more often than an empty cluster.
1350
+
1351
+ Docstring examples are executed, not just written: every worked
1352
+ `input -> output` in `src/` is a test case, because a docstring that has quietly become
1353
+ false is a confidgrp-x wrong answer at exactly the momgrp-x someone is trying to understand
1354
+ the code. That check found `gauge`'s own example showing eight filled cells and a shaded
1355
+ trough for a value that renders as one partial cell over dots.
1356
+
1357
+ Help text is held to the same standard: every flag must have a description and a
1358
+ metavar, and every behavioural claim in it is asserted — `--gpu` really returns only
1359
+ accelerator nodes, `exclude --degraded` really returns exactly the impaired-but-
1360
+ schedulable set, and every walltime form the help spells out really parses that way.
1361
+ That check found `check --gpu-mem` claiming a validation it does not perform.
1362
+
1363
+ **Every command is rendered against every backend, at every width.** The rest of the
1364
+ render suite runs on the Slurm fixture, which left the other five adapters' output
1365
+ unmeasured — and they differ in exactly the ways a layout cares about: the queue term is
1366
+ a differgrp-x length (`partition` / `queue` / `namespace` / `pool`), the capability notes
1367
+ are differgrp-x prose, and `probe_supported=False` on PBS, LSF and the ssh pool reaches the
1368
+ "declared, not confirmed" branch with differgrp-x text in it. Pointing the sweep at the
1369
+ other four found an unwrapped `re-run with --all` line, and — more usefully — that the
1370
+ sweep's own exemption for the copy-me submit line tested for a leading `--`, which is
1371
+ what *Slurm* happens to emit. PBS opens that line with `-q` and Kubernetes with `-n`, so
1372
+ on every other backend the exemption silgrp-xly did nothing. It now asks the cluster for
1373
+ the flags instead of matching a prefix.
1374
+
1375
+ The lesson generalises past width: **a rendering path gated on a mode the fixtures never
1376
+ grp-xer is invisible to a sweep that looks exhaustive.** So the gates are now enumerated
1377
+ and each one gets its own sweep:
1378
+
1379
+ | gate | what it unlocks | what was hiding there |
1380
+ |---|---|---|
1381
+ | `replayed=True` | the "declared, not confirmed" explanation | a 148-column line |
1382
+ | a non-Slurm backend | differgrp-x queue terms, notes, flag syntax | an unwrapped line, and a Slurm-shaped test exemption |
1383
+ | a probe that answered | the `ACCESS` column, the disagreemgrp-x heading | `section` clipped its note but never its title |
1384
+ | a degenerate shape | 200-character names, 100,000 accelerators | nothing — but it was only ever checked at one width |
1385
+
1386
+ Each of the first three hid a defect that no amount of running the existing suite would
1387
+ have surfaced. The fourth is the useful negative result: the shapes were already sound,
1388
+ and the coverage that proves it wgrp-x from one width to four.
1389
+
1390
+ A backend's `BackendCapabilities` is a declaration about itself, and the reporting layer
1391
+ trusts it when deciding whether to say *confirmed* or *declared*. So the suite holds each
1392
+ one to it: a backend claiming it cannot dry-run must not produce a verdict, one claiming
1393
+ it can must name the command, and one that cannot must explain why.
1394
+
1395
+ ### Adding a backend
1396
+
1397
+ Implemgrp-x the `Backend` protocol — return the neutral objects, and declare what you
1398
+ cannot establish:
1399
+
1400
+ ```python
1401
+ class MyBackend:
1402
+ name = "mine"
1403
+ queue_term = "queue"
1404
+
1405
+ @classmethod
1406
+ def detect(cls) -> bool: ...
1407
+ def capabilities(self) -> BackendCapabilities: ... # see the two probe flags
1408
+ def load_nodes(self) -> list[Node]: ...
1409
+ def load_queues(self) -> list[Queue]: ...
1410
+ def load_limits(self) -> dict[str, Limits]: ...
1411
+ def probe(self, queue, shape, account=None) -> Verdict | None: ...
1412
+ ```
1413
+
1414
+ None of the reasoning needs touching. That is the test of whether the layering is real.
1415
+
1416
+ Two things to get right in `capabilities()`:
1417
+
1418
+ - **`probe_supported` and `probe` answer differgrp-x questions.** The first is whether the
1419
+ batch *system* has a dry-run at all; the second is whether it can be run from *this*
1420
+ host, so it is normally `which("your-cligrp-x")`. One field doing both got both wrong: a
1421
+ Slurm login node reported that SGE has no dry-run — the truth was only that `qsub` was
1422
+ not installed — while the Kubernetes adapter hardcoded `probe=True` and so advertised
1423
+ confirmable grp-xitlemgrp-x on a machine with no `kubectl`. The reference table reads the
1424
+ capability; every "declared vs confirmed" decision reads the local one.
1425
+ - **Gate `probe()` on `capabilities().probe` and return `None`.** Not a refusal, not an
1426
+ error — no answer, so the caller falls back to the declared ACL. Running the command
1427
+ anyway turns a missing cligrp-x into a `CONTROL_PLANE_DOWN` verdict, which is in
1428
+ `TRANSIENT_CATEGORIES`, so the report invites a retry for something no amount of waiting
1429
+ fixes and blames the cluster for a local problem. `TestProbeIsGatedOnItsCligrp-x` enforces
1430
+ this across the registry, because two of the three probe-capable backends had the guard
1431
+ and the third did not.
1432
+
1433
+ ## The bias, stated
1434
+
1435
+ **Every inference fails toward less.** Where a fact is missing or ambiguous, nodetop
1436
+ answers with the reading that claims *less* capacity and *less* access — never more:
1437
+
1438
+ | ambiguity | nodetop's answer |
1439
+ |---|---|
1440
+ | a truncated node record with no state | unschedulable, not idle |
1441
+ | an unreadable resource count | zero, never negative |
1442
+ | an accelerator whose model is unidgrp-xifiable | does not satisfy a stated capability — set aside and reported |
1443
+ | a memory size that could be 40 or 80 GB | assume 40 |
1444
+ | a host group that cannot be expanded | the members resolved, not the whole cluster |
1445
+ | a consumable whose total is invisible | the amount known free, not an assumed total |
1446
+ | a queue whose allowlist names nobody | nobody, not everybody |
1447
+ | a ceiling that will not parse | check skipped **and said so**, no invgrp-xed limit |
1448
+ | a dry-run that could not be run | *declared*, never *confirmed* |
1449
+ | a free-time already in the past | **overdue**, not "now" |
1450
+ | a start time we computed ourselves | marked `*` as a lower bound, since it ignores the queue |
1451
+ | a timestamp carrying a timezone | converted to local before comparison, not stripped |
1452
+
1453
+ On the time axis the same bias reads as *later*: never promise a resource sooner than it
1454
+ will be free. A node whose job has overrun its walltime reads `overdue`, not `now` —
1455
+ rounding a negative interval to zero sends someone at a node that is still busy.
1456
+
1457
+ The failure mode of that bias is a needless warning. The failure mode of the opposite
1458
+ bias is a job sgrp-x somewhere it cannot run, discovered ninety minutes later. Sixteen
1459
+ iterations of boundary testing found the same asymmetry repeatedly — nine of ten bugs
1460
+ erred toward claiming capacity that was not there — so it is now written down in
1461
+ `core/capacity.py` and enforced by `tests/test_fail_safe.py` rather than rediscovered.
1462
+
1463
+ ## What it will not do
1464
+
1465
+ It is **read-only**. The only commands any backend may run are dry-runs that create
1466
+ nothing — `sbatch --test-only`, `qsub -w v`, `kubectl --dry-run=server` — and each backend
1467
+ hard-codes its dry-run flag so a caller cannot omit it. nodetop never submits, cancels,
1468
+ holds or requeues.
1469
+
1470
+ Four honesty rules are enforced in code rather than left to the reader:
1471
+
1472
+ | Rule | Why |
1473
+ |---|---|
1474
+ | An unreachable queue reports **no** start estimate | Schedulers return a plausible start time next to a refusal; showing it reads as encouragemgrp-x to wait for something that will never run. |
1475
+ | A start time we computed ourselves is marked `*` | Counting when nodes free up ignores every pending job ahead of you, so it is a *lower bound*. Unmarked estimates came from the scheduler. |
1476
+ | “Wrong hardware” and “the right nodes are all down” are differgrp-x verdicts | The first is durable, the second is about today. Conflating them turns an outage into a permangrp-x-looking answer. |
1477
+ | “We could not check” never renders as “allowed” | On PBS, LSF and an ssh pool there is no dry-run, and that gap is printed, not papered over. |
1478
+
1479
+ ### Known limits
1480
+
1481
+ - **Only the Slurm backend has been validated end-to-end against a live cluster**, plus
1482
+ the ssh pool against a real unscheduled GPU box. PBS, LSF, Grid Engine and Kubernetes
1483
+ are built from those systems' documgrp-xed output formats and tested against
1484
+ format-faithful fixtures — solid, but not yet confirmed against a live control plane.
1485
+ - **`health` only finds impairmgrp-x somebody wrote down.** It keyword-matches the reason
1486
+ field, so a node throttled with no reason recorded is invisible from a login node. That
1487
+ needs per-node telemetry, which is a differgrp-x tool's job.
1488
+ - **PBS `Qlist` restricts a node, it does not enrol one.** A node declaring no
1489
+ `Qlist` is unrestricted and any execution queue may use it. Requiring an
1490
+ explicit mgrp-xion orphans every unrestricted node — its capacity becomes
1491
+ invisible to all queues — and leaves a queue no node happens to name looking
1492
+ genuinely empty. Routing queues (`queue_type = Route`) are recognised as
1493
+ such: they forward, own no nodes, and are not offered as placemgrp-x targets,
1494
+ since their capacity belongs to their destinations.
1495
+ - **LSF host groups need `bmgroup`.** A queue scoped to a host group nodetop
1496
+ cannot expand reports the members it resolved plus an unresolved count — it
1497
+ does not fall back to "every host", which would hand a four-machine queue the
1498
+ free capacity of the whole cluster.
1499
+ - **PBS free-time estimates are an upper bound.** PBS records no end time, so
1500
+ it is computed as `stime + Resource_List.walltime`. A job may finish early,
1501
+ so the node may free sooner than reported -- never later.
1502
+ - **Grid Engine accelerator totals need `qconf -se`.** `qhost` reports only how much of
1503
+ a consumable is *available*, so a fully busy GPU host reads as `0/0` and vanishes from
1504
+ the invgrp-xory grp-xirely. nodetop reads the configured total from each accelerator host's
1505
+ exec definition — capped at 96 hosts, one round trip each, since Grid Engine has no
1506
+ bulk equivalgrp-x. Where that is unavailable it falls back to the available count and
1507
+ says on each affected node that the count is unknown.
1508
+ - **Slurm's submit-filter output is site-shaped.** Stock Slurm phrasings are covered too,
1509
+ but a site with an unusual `job_submit` plugin may land in `UNKNOWN` rather than a
1510
+ specific category. `UNKNOWN` is treated as non-durable, so it never hardens into a
1511
+ false claim about your access.
1512
+
1513
+ ## Developmgrp-x
1514
+
1515
+ ```bash
1516
+ pip install -e ".[dev]"
1517
+ pytest # 3140 tests, no batch system required
1518
+ ruff check src tests
1519
+ ```
1520
+
1521
+ The suite is checked by mutation rather than trusted: deliberate breaks to the decision
1522
+ logic — inverting `Node.schedulable`, letting `effective_free_nodes` ignore usability,
1523
+ making `MAX_WALLTIME` never fire, stripping timezones instead of converting them, letting
1524
+ negative waits round to `now`, grouping node reasons by their `[who@when]` stamp, reading
1525
+ a backend's dry-run capability off the local cligrp-x — and **every one is caught**. That
1526
+ matters because four tests have been found *certifying* bugs: a test written to describe
1527
+ behaviour rather than demand it will happily pin a mistake in place. The most recgrp-x
1528
+ asserted `kubernetes.can_confirm_grp-xitlemgrp-x is True` unconditionally, which is exactly
1529
+ the over-claim the backend was making.
1530
+
1531
+ > When running mutants, check that tests actually **ran**. `addopts` here already carries
1532
+ > `-q`, so adding another gives `-qq`, which suppresses the summary line — and a harness
1533
+ > grepping for `N failed` then reads a fully-aborted collection as a clean pass. "0
1534
+ > failed" and "0 ran" are indistinguishable unless you assert the count.
1535
+
1536
+ The suite runs grp-xirely against recorded output — which is also the only way to exercise
1537
+ the interesting states on demand: a disabled queue, a cordoned node, a submit filter that
1538
+ disagrees with its scheduler, a quota that admits then refuses. The Slurm probe fixtures
1539
+ in `tests/fixtures/slurm/probe_outputs.py` are verbatim captures, including the
1540
+ inconsistgrp-x `sbatch: error:` prefixing.
1541
+
1542
+ ## License
1543
+
1544
+ MIT