Skip to main content

sva_cli/
help.rs

1// Concern: the `--help` page, each flag's default printed from its own constant | Non-concern: parsing those flags (args/), the JSON a subcommand answers (output.rs) | IO: () -> the page
2
3use sva_core::DEFAULT_STORE_BYTES;
4use sva_engine::{
5    DEFAULT_BUFFER_SECS, DEFAULT_CELL_SECS, DEFAULT_FLOOR_DB, DEFAULT_FRAME_SECS,
6    DEFAULT_LEDGER_DEPTH, DEFAULT_MAX_PAIRS, DEFAULT_MAX_PEAKS, DEFAULT_MODULATION_WINDOW_SECS,
7    DEFAULT_NOVELTY_WINDOW_SECS, DEFAULT_OVERSAMPLE, DEFAULT_PERIODICITY_WINDOW_SECS,
8    DEFAULT_SAMPLE_RATE, DEFAULT_SMOOTH_SECS, PSYCHOACOUSTIC_V1, READINGS, Reads,
9    self_similarity::MAX_CELLS,
10};
11
12/// Read off the constants the parser itself defaults to, so a printed default cannot drift
13/// from the one a render actually uses.
14pub fn help_text() -> String {
15    let bits = PSYCHOACOUSTIC_V1.precision_bits;
16    let store_gb = DEFAULT_STORE_BYTES >> 30;
17    let representations = representations();
18    format!(
19        r#"USAGE:
20  sva-cli (render | analyze | lint | trace | builtins | outline | new) [arguments]
21
22DESCRIPTION:
23  A composition is a directory of node files, each one closed-form expression in
24  `t` or `f` on one line, or a score placing notes in time. sva-cli reads that
25  composition and prints what it is and what it sounds like, as JSON on stdout.
26  `render`, `lint` and `trace` read the current directory; `new` writes beside it
27  and `analyze` reads a file.
28
29SCORES:
30  A score is data: JSON Lines, one note per line, sorted by `at`. A file whose
31  first non-blank line starts with `{{` is a score; its name has no extension, and
32  `@score/bass` reads it like any node. Each line plays the sound it names,
33  shifted to its time, and the score is their sum, one closed form:
34    {{"at": "0b", "sound": "@../sounds/bass", "deg": 1, "len": "0.25b", "vel": 0.9}}
35    {{"at": "0.25b", "sound": "@../sounds/bass", "deg": 5, "cutoff": "900hz"}}
36  is @../sounds/bass(t, f0=<degree 1>, len=0.25b, vel=0.9) + @../sounds/bass(t -
37  0.25b, f0=<degree 5>, cutoff=900hz).
38    at     required: a time literal string, "0.25b" or "0.5s"; a bare number is
39           refused. One score counts in bars or in seconds, not both.
40    sound  required: an `@ref` string, resolved from the score's own directory.
41    len    a time literal string, bound as the sound's `len`.
42    deg    a scale degree, an integer counted from 1, the tonic. In a seven-row
43           scale 8 is the tonic an octave up, 0 the seventh degree below the
44           tonic and -6 the tonic an octave down. It binds `f0` to the tonic in
45           `variables/key` times the degree's ratio in the scale its `scale =
46           @../scales/<name>` line names; a line writing `f0` and `deg` refuses.
47    other  any other field binds the sound's parameter of that name: a number, or
48           one literal in a string ("0.03s", "-6db", "900hz", "A1").
49  A field the sound does not read, a parameter it reads that nothing binds, and a
50  `deg` with no scale refuse before any audio, each naming the score's line and
51  column. A scale file holds one row per degree in cents above the tonic,
52  ascending, the last the period it repeats at: `scales/major` is 200 400 500
53  700 900 1100 1200. Editing `variables/key`'s note transposes every score and
54  editing its `scale` line changes mode. Keep a score phrase-sized and place it
55  from an equation: `@score/verse(t - 8b)`, `repeat(@score/riff-1b, 4)`. A score
56  carries no automation: a moving argument is written in the sound's equation.
57
58RENDER:
59  sva-cli render '<expression>' --representation <list> [--until '<condition>']
60                 [--bits <n>] [--rate <hz>] [--threads <n>] [--cache <path|none>]
61                 [--confirm]
62
63  Renders one expression, in the grammar a node file's body uses, and prints one
64  reading per representation under `data.representations`. `@path` reads a node
65  from the current directory and `@/abs/path` one anywhere; there is no default
66  target. Every reading states its `source` (exact or measured), the
67  conformance profile it ran under, and its rate.
68
69  The target's own ref may read an interval: `@piano4([0, 2b], f0=C4,
70  vel=0.5, release=1s)`, where `release` is the instrument's own parameter. Its ends are the language's literals (`s`, `ms`, `b`,
71  `sp`, or a bare `0`); `[a, inf)` and `[a,)` leave the end open; the other
72  arguments are the node's own named ones. The start trims the output alone:
73  history before it is still computed, so a loop or a filter carries the state
74  it had there. With no interval the render starts at 0, earlier only where a
75  crop reaches before it, and its end is open. Inside an expression a window is
76  a `crop`. A target that samples its one ref read, `sample(@piano4([0, 2b],
77  f0=C4))`, reads that ref's interval and binds and the same node values.
78
79  Every node is computed over its support met with what reads it, and nowhere
80  else: outside its support a node is exactly zero. A closed interval renders
81  exactly its length, nothing cut. An open one ends where the root's support
82  does: a crop's end, the exact underflow of an `exp`, a clamp holding a factor
83  at zero; or earlier, where a proven upper bound on the root's magnitude stays
84  under the profile's silence threshold (-120 dBFS) from a sample on. A root
85  with no such bound (a held sine, a loop) is never cut. The label's `pruned`
86  states the threshold as `db` and the root's cut, `from` the sample the render
87  is treated as silent from. A stream's term leaves `@notes` where its support
88  ends. An open interval over a root whose support never ends and is never cut
89  (a held sine, a physical solver) refuses as `render.no_end`. A short-time
90  transform reads its input whole, and refuses one with no end as
91  `engine.unbounded_extent`. A form in `f` with no dual in `t` has no samples
92  and refuses as its dual does, `cast.left_algebra`.
93
94  A render is a stream pulled to its end: a closed form, a short-time
95  transform and what either reads are computed over their whole extent first,
96  and every other node block by block. `--until '<condition>'` stops the render
97  at the first sample the condition holds at, or at the interval's end,
98  whichever is first. It is checked as each block is pulled, so no node driven
99  block by block runs past the block it holds in. A condition compares (`<`,
100  `<=`, `>`, `>=`) `t`, `envelope(t)` (the RMS over every channel of the
101  `envelope` representation's frame holding `t`, framed by its own `frame`
102  where one is asked) and literals, joined by `and`/`or`.
103
104  `--representation <list>` takes a comma list of readings and may repeat. Each
105  is a call in the language's own syntax, its options its named arguments:
106  `spectrum(peaks=8, frame=50ms)`, `ledger(depth=3, brief=1)`. An entry
107  followed by `=<path>` writes a file instead, listed under `written`:
108  `samples=<path>.wav` writes audio, encoded as `--bits` says; any other path
109  takes the reading's JSON, uncapped. A path that already holds a file refuses
110  unless `--confirm` is written. `samples=alsa:<device>` plays the samples on
111  that ALSA device (`alsa:default`, `alsa:hw:1`, `alsa:plughw:1,0`) as each
112  block is pulled, in real time, through `aplay` from alsa-utils; Linux only,
113  anywhere else it refuses as `destination.unsupported_platform`. It sends
114  32-bit floats, or integer PCM where `--bits` is 16 or under. A device that
115  cannot take the format refuses with aplay's reason, and one aplay says plays
116  another rate than the render's stops there as `destination.rate_inexact`.
117  `samples(buffer=<t>)` is how much the device holds ahead of what it plays.
118  Once it has played out, `data.played` states the device, its seconds, rate,
119  channels, format, the buffer asked and the one granted, and every underrun
120  aplay reported, with what it cannot see.
121  `lines`, `atoms`, `derivative`, `bindings`, `arguments`, and a closed form's
122  `spectrum`, `envelope` and `pitch`, read the expression and ignore the range.
123
124  A closed form's `spectrum` and `pitch` are its exact lines, so a term under a
125  crop or an envelope, which has a width and is no line, refuses them; so do
126  `spectrum(frame=)` and `envelope(frame=)`, as a closed form has no frames. To
127  measure any of them frame by frame, a reader samples the target's ref,
128  `sample(@x([0, 1s]))`, and an author writes `sample(...)` inside the node.
129  A closed form with no exact envelope, such as a crop of a decay, refuses
130  `envelope` with the reason rather than measuring it unasked; `sample(...)`
131  asks for the measured one. A measured `spectrum` is one spectrum: every
132  `frame`-long window across the range, averaged. `pitch` is framed, one entry
133  per `frame`; for the spectrum at one instant, read a range one frame long.
134  A measured `envelope` is framed too, and reads every channel: a frame's
135  `rms` is the root mean square of all its channels' samples together, its
136  `peak` the largest magnitude in any channel.
137
138  `--bits <n>` is the precision every sample is written to, from 2 to 52: the
139  point where a series is truncated, and the encoding of a `.wav`, integer PCM
140  at n bits up to 16 and 32-bit float above. `--rate <hz>` (default
141  {DEFAULT_SAMPLE_RATE}) is the one rate a render samples at: the target is read
142  at its instants, and every node at the instants its reader asks for. A
143  closed form is exact at any instant, and so is a continuous loop, one
144  constant delay at a gain under 1 over a closed form such as
145  `x + 0.5*self(t - 17ms)`, which is its series. A filter, a discrete loop, a
146  solver and `rand` step at the rate asked for, where `1sp` is one step, so an
147  `sp` count or `self[idx(t) - 1]` means one sample at whatever rate is asked.
148  `rand` draws once per step, keyed by that step's index; a key between steps
149  reads the step nearest it, ties to even.
150
151  `--threads <n>` (default: every core the machine reports) is the most
152  threads a render computes on; `1` computes every value in turn on one.
153  Values none of the others reads, and the blocks of a value that holds no
154  state, compute apart, to the same bits on any number of threads.
155
156  A discrete loop reads its own past only by index; `self(t - d)` in one
157  refuses as `type.discrete_self_at_time`, naming what made it discrete. A
158  read `@x(k*t - d)` steps `x` at `k` times the step, every input it reads,
159  `sp` and `idx` with it, and rounds `d` to the nearest sample of that step,
160  ties to even: shifts are rounded to the nearest sample so placements share
161  one cached value; timing is exact to half a sample, and the label's
162  `moved_s` states the most any read moved. A node is computed once
163  per step however many reads ask for it. A node that holds state read at a
164  time that moves refuses as `type.stateful_warp`, naming what
165  holds its state; `@x[idx(...)]` reads its nearest step instead, as `p[idx(...)]`
166  does a signal passed in as parameter `p`, and any `idx`, as
167  `idx(t - 5ms - 2ms*sin(2*pi*t))`, is read sample by sample. An edit to a
168  stream plays from the next sample on.
169
170  A render keeps in a store on disk its target and, under it, each node of
171  samples that holds state (a filter, a discrete loop or a solver in it) or
172  that two or more nodes read. Nothing else is kept. A closed form, `sample`
173  of one and `rand` have a value at any instant and are computed where read.
174  A node without state that one node alone reads, however many times, is
175  computed again by that reader from what the node itself reads: no other
176  reader could ask for it, and without state it restarts at any sample, where
177  a filter or a loop must run from its start. So a stage worth keeping is a
178  node of its own that ends in a filter or a loop, or one two nodes read. A
179  kept value is named by what it computes: never its file's name, directory,
180  comments, spacing, or the order its named arguments are written in, so a
181  renamed or moved copy, a constant lifted into a default, an expression split
182  into a file of its own or written back inline, and two addends swapped all
183  read one entry. The next render of anything that reads the same value reads
184  it back, bit for bit, and computes nothing under it. The store is on by
185  default, at `$XDG_CACHE_HOME/sva`, else `~/.cache/sva`; `--cache <path>`
186  moves it (a `/dev/shm` path keeps it in memory) and `--cache none` turns it
187  off. It holds at most {store_gb} GB, the least recently used values going
188  first, and a store another build of sva-cli wrote is emptied when opened. A
189  render stages what it computes beside the store as it goes, and the store
190  itself is written once, when the render ends, fails, or is stopped by SIGINT
191  or SIGTERM.
192
193  `ledger` prints one row per node under the target. A row's `share` is the
194  part of its reader's own energy that row accounts for, so one reader's refs
195  sum to 1; a ref no addend isolates, such as one factor of a product, prints
196  `null`. Each ref carrying a share is collapsed once on its own, so a ledger
197  costs one collapse per attributed ref beyond the render, and `depth` bounds
198  how many, counted in refs from one file to another. Every row is computed
199  whole over the range, however late a reader reads it. `brief=1` keeps only
200  the rows that clipped, `skim=1` drops the wider fields.
201
202  `arguments` renders nothing: for every instance under the target it prints
203  each builtin call's named arguments as the numbers the call was lowered
204  with, a solver's whole parameter set with `written: false` on each default
205  it filled in, and the operand each `min`/`max` `chosen` where a call folds
206  one to a number: in named arguments and a solver's, modal bank's or
207  `noise`'s positionals. One inside a filter's or cast's positional or
208  `rand`'s key or seed is not listed. `at` spans are bytes of the instance's
209  own body, as `sva-cli outline` counts them.
210
211REPRESENTATIONS:
212  Each one a `--representation` call, its arguments in parentheses, every
213  default under DEFAULTS. `exact` is read off the written math, `measured` off
214  samples.
215
216{representations}
217
218ANALYZE:
219  sva-cli analyze <file.wav> --representation <list> [--confirm]
220
221  Runs the same readings over a whole external `.wav` at its own rate, never
222  resampled. Only the readings a buffer answers alone apply, each listed under
223  REPRESENTATIONS as `measured` or `exact off a closed form, measured off
224  samples`; the rest need the graph behind it.
225
226LINT:
227  sva-cli lint ['<expression>'] [--format <json|text>]
228
229  Checks the current directory without rendering a sample. With no target it
230  checks every file under its own rules, and types every entry point, which
231  types every file it reaches. With a target, in render's grammar, it checks
232  the files that target reaches, and prints the interval a render of it reads.
233  Either refuses a type error as `render` does.
234
235  A score holds no `;` comment and no body, so the comment and length checks
236  pass it by; a file of two or more lines that is neither a score nor a scale
237  refuses as `multiline-body` before any check runs.
238
239  Every check prints one `data.diagnostics` item. `warning` exits 0, `error`
240  exits non-zero, so branch on the verdict and never on whether the array is
241  empty. No flag downgrades an error.
242
243  error    missing-comment       no `;` comment line
244           multiline-comment     more than one
245           malformed-comment     not four ` | ` fields, `Models:` `Neglects:`
246                                 `IO: <in> -> <out>` `Tags: <tag>[, <tag>...]`
247           long-comment-block    a `;` block over 1000 characters, the line-1
248                                 doc comment's own run exempted
249           long-expression-body  a body over 10000 characters, a backstop rather
250                                 than a complexity budget
251           literal-sample-rate   a written rate where `sp` belongs
252           arity                 a builtin called without an argument it reads,
253                                 or with one it does not, as `rand(seed=k)`
254  warning  key-is-not-a-pitch    `variables/key` holds neither a note name nor
255                                 a number of hertz
256           parameter-has-no-default
257                                 a node reads a parameter no `name = value` line
258                                 binds, so a bare `@node` cannot play
259           support-never-ends    a node's support, cut where its bound falls
260                                 under the silence threshold, has no end for a
261                                 bare render to stop at
262
263TRACE:
264  sva-cli trace <node|expression>
265
266  Prints one node's position without rendering audio: what it reads (`down`, one
267  hop), everything that reads it (`up`, transitively to an entry point), each
268  beside the expression doing the reading, the node that made it discrete, and
269  the feedback loop it sits in, if any. A score is shown as its line count, its
270  first and last `at` and the sounds it plays, never the sum it lowers to. An
271  interval the target reads is ignored.
272
273BUILTINS:
274  sva-cli builtins
275
276  Prints the whole callable and syntactic vocabulary: every builtin with its
277  arity and named arguments, each argument's `meaning`, `unit`, the model
278  `part` it sets and whether it `moves` with `t` (a filter's cutoff, q and gain;
279  every other named argument is one number, refused when it names none), each
280  positional's meaning where the model states one, unit suffixes, the note-name
281  grammar, reserved identifiers, special call shapes, and what has no operator
282  at all.
283
284OUTLINE:
285  sva-cli outline <expression>
286
287  Prints the parse tree the engine builds from one expression, each node with
288  the byte `span` it was written in: calls by `name` with positional and named
289  `args`, operators by `op`, refs by `path` with their `binds`, refs and `self`
290  by how they `read`, `time` or `index`, literals by `value` and `unit`, names
291  by `name`. A node the parser supplies itself, the `0` of a prefix minus or the
292  `t` of a bare `@ref`, has `written: false`. A score's text prints `score`
293  instead of `outline`: one item per line, with its `line`, `span`, `at`,
294  `sound`, `deg` and `args`. Reads no composition.
295
296NEW:
297  sva-cli new <name> [--idempotency-key <key>]
298
299  Writes a starter composition at ./<name>, and refuses if that directory
300  exists. `--idempotency-key <key>` records the key beside the composition, so a
301  retry under the same key succeeds identically while the tree still holds what
302  was written. Any other key, or an edited tree, refuses.
303
304EXAMPLES:
305  sva-cli new song1 && cd song1
306  sva-cli render '@master' --representation samples=/tmp/song1.wav
307  sva-cli render '@master([0, 8b])' --representation 'ledger(depth=2),loudness'
308  sva-cli render '@voice/note([0, inf), f0=C4, len=2s)' \
309    --until 'envelope(t) < -60db and t > 1s' --representation samples=/tmp/note.wav
310  sva-cli render 'sample(@chord/home)' --representation 'spectrum(peaks=8)' --rate 48000
311  sva-cli render 'sample(@voice/note([0, 2s], f0=C4))' --representation pitch
312  sva-cli render '@master' \
313    --representation 'samples(buffer=100ms)=alsa:default,loudness'
314  sva-cli lint
315  sva-cli lint '@master'
316  sva-cli trace score/phrase
317  sva-cli builtins
318
319OUTPUT:
320  {{"status": "success", "data": {{"target": "@master([0, 8b])", "sample_rate":
321  {DEFAULT_SAMPLE_RATE}, "bits": {bits}, "profile": "psychoacoustic-v1",
322  "interval": {{"start_secs":
323  0, "end_secs": 16}}, "label": {{...}}, "written": {{"items": [...]}},
324  "representations": {{"ledger": {{...}}}}, "diagnostics": {{"items": []}}}},
325  "meta": {{"request_id": "req_...", "timestamp": 1700000000}}}}
326
327  `interval` is null where no reading read samples. An error adds "error":
328  {{"code", "message", "details": {{"count", "codes"}}}}. Success or error,
329  every response carries every finding in full at "data": {{"diagnostics":
330  {{"items": [{{"code", "severity", "message", "location", "help"}}],
331  "pagination": {{"count", "has_more", "next_cursor"}}}}}}, empty where it found
332  none.
333
334  Every collection carries that same {{items, pagination}} pair. `count` is the
335  whole reading's, `has_more` says `items` holds less than that, and
336  `next_cursor` is the interval start, as `0.5s`, the rest is read from. A
337  reading written to a file is written whole, uncapped. This page is an
338  envelope of its own, at "data": {{"help"}}.
339
340DEFAULTS:
341  --rate <hz>          the one rate a render samples at, and `1sp`.
342                       Default {DEFAULT_SAMPLE_RATE}.
343  --bits <n>           the precision every sample is written to. Default {bits},
344                       the `psychoacoustic-v1` profile's own.
345  ledger(depth=<n>)    how deep below its target a `ledger` walks.
346                       Default {DEFAULT_LEDGER_DEPTH}.
347  (peaks=<n>)          peaks a `spectrum` keeps, notes a `pitch`, formants a
348                       `formants`, stripes and novelty peaks a `self_similarity`
349                       lists per view. Default {DEFAULT_MAX_PEAKS}.
350  alias(oversample=<n>) the multiple `alias` re-renders at to hear what folded.
351                       Default {DEFAULT_OVERSAMPLE}.
352  (frame=<t>)          the step a framed reading advances by, in seconds.
353                       Default {DEFAULT_FRAME_SECS}, except `spectrum`, which sizes
354                       its own transform to the range unless it is set.
355  (floor=<db>)         the amplitude, as `-40db`, under which a harmonic reading
356                       or `self_similarity` drops a partial, and under which
357                       `self_similarity` counts a band level as zero.
358                       Default {DEFAULT_FLOOR_DB}db.
359  voice_leading(norm=<l1|l2>) how a voice leading's moves add up. Default l1.
360  dissonance(pairs=<n>) the most partial pairs one frame sums before it refuses.
361                       Default {DEFAULT_MAX_PAIRS}.
362  periodicity(window=<t>) the span of onset strength one window autocorrelates,
363                       in seconds, at least twice the longest period it reads.
364                       Default {DEFAULT_PERIODICITY_WINDOW_SECS}.
365  periodicity(curve=<0|1>) `1` adds every window's height at every lag.
366                       Default 0.
367  modulation(window=<t>) the span of band envelope one window transforms, in
368                       seconds, at least twice the slowest period it reads.
369                       Default {DEFAULT_MODULATION_WINDOW_SECS}.
370  modulation(bpm=<n>)  a tempo, as `140`, adding each rate's period in beats.
371                       Default none.
372  self_similarity(cell=<t>) the side of one matrix cell, in seconds, a whole
373                       number of frames; the target may span at most
374                       {MAX_CELLS} cells. Default {DEFAULT_CELL_SECS}.
375  self_similarity(smooth=<t>) the length each diagonal is averaged over
376                       before stripes are picked, in seconds.
377                       Default {DEFAULT_SMOOTH_SECS}.
378  self_similarity(window=<t>) the whole width of the novelty kernel, in
379                       seconds. Default {DEFAULT_NOVELTY_WINDOW_SECS}.
380  self_similarity(curve=<0|1>) `1` adds every cell's chroma and band levels and
381                       each view's matrix. Default 0.
382  bindings(node=@<path>) the instance whose bindings are read; required.
383  samples(buffer=<t>)  how much an `alsa:` device holds ahead of what it plays,
384                       in seconds. Default {DEFAULT_BUFFER_SECS}; only a device
385                       has one.
386  ledger(brief, skim)  `0` unless set `1`.
387  --format <json|text> how `lint` prints its findings: the envelope, or one
388                       terminal line each, colored where stdout is a terminal.
389                       The same objects either way. Default json.
390  --cache <path|none>  where the store lives. Default `$XDG_CACHE_HOME/sva`, else
391                       `~/.cache/sva`, holding at most {store_gb} GB; `none` keeps
392                       no store. A store that fails fails no render: it answers
393                       as without one, warned why in `diagnostics`.
394  --confirm            replaces a destination that already holds a file. Without
395                       it a path already taken refuses as `conflict` and nothing
396                       is written.
397
398ENVIRONMENT:
399  SVA_LOG=debug        `render` logs, once it ends, what it asked of each value
400                       to stderr; stdout and the samples are unchanged. Any
401                       other value, or none, logs nothing. One line per second
402                       of output, then one per node and a total:
403                         sva-cache pass hit=0 miss=190 ... reused=0 cum-hit=0.0%
404                         sva-cache t=1.022s hit=1 miss=2 ... cum-hit=0.5%
405                         sva-cache node hit=1 miss=0 ... reused=3 <node>
406                         sva-cache total hit=1 miss=198 ... hit-rate=0.5% ...
407                       `pass` is what was looked up before the first block.
408                       Each value is looked up once: a `hit` was answered by
409                       the store, `prefix` found a stored run up to a switch,
410                       `miss` computed it, `new` held it in memory, where the
411                       store takes what it keeps from. Each read of a value
412                       past its first is `reused`.
413
414EXIT CODES:
415  0  success (error.code absent)
416  1  internal_error (a destination could not be written)
417  3  validation_error (bad arguments, a composition that failed to parse, or a
418     render the engine refused)
419  4  conflict (a name `new` would overwrite, or a destination already holding a
420     file, without `--confirm`)
421  24 not_found (a `.wav` file, or a node this composition does not define)
422
423VERB ALIASES:
424  validate = lint, list = builtins, create = new, show = trace, from the `cli`
425  standard's own verb list. `render` and `analyze` take a reading, which that
426  list has no word for, so they keep their own names.
427
428SEE ALSO:
429  sva-cli --version    Show version information"#
430    )
431}
432
433/// One entry per reading, off the one table: its call, what it answers off, and its line.
434fn representations() -> String {
435    READINGS
436        .iter()
437        .map(|row| {
438            let keys: Vec<&str> = row.takes.iter().map(|arg| arg.key()).collect();
439            let call = match keys[..] {
440                [] => row.name.to_string(),
441                _ => format!("{}({})", row.name, keys.join(", ")),
442            };
443            let source = match row.reads {
444                Reads::ClosedForm | Reads::Structure => "exact",
445                Reads::Either => "exact off a closed form, measured off samples",
446                Reads::Samples => "measured",
447                Reads::Graph => "measured, and the graph behind the samples",
448            };
449            format!("  {call:<27}{source}\n      {}", row.help)
450        })
451        .collect::<Vec<_>>()
452        .join("\n")
453}