headway 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
headway/GUIDE.md ADDED
@@ -0,0 +1,375 @@
1
+ # The headway guide
2
+
3
+ This guide is for people and for coding agents. It says what headway is, how to write a headway for a codebase,
4
+ how to keep it true as the code changes, and how to put benchmark results into it. It assumes nothing beyond this
5
+ file. `headway guide` prints it.
6
+
7
+ ## What headway is
8
+
9
+ A **headway** describes a method (a computational or statistical procedure in some codebase) as a computation
10
+ graph: the values it works with, the steps that turn some values into others, each step's equation, and a
11
+ reference into the code that computes it. You write it as a Python module, the **spec**, kept next to the code.
12
+ headway then:
13
+
14
+ - **checks the spec against the code**: every code reference resolves; the graph is well formed; each equation you
15
+ attach a check to reproduces what the code computes; on a real run, arrays have the dimensions the spec says.
16
+ So the description cannot quietly go stale.
17
+ - **draws it** as one interactive HTML page: the graph (groups collapse and expand), a legend of indices, symbols
18
+ and equations, and for each step its equation, description and the code itself, with links that open your
19
+ editor at the line. It also writes prompts for an AI assistant about whatever is selected.
20
+ - **compares versions**: built versions of a headway (one per commit, experiment or competing method) open side
21
+ by side in lanes, registered on one layout, with markers for what was added, changed or removed.
22
+ - **shows benchmark results**: a benchmark suite is declared the same way (its metrics are steps with equations
23
+ and checks; its datasets are annotated), and each version's results are stored in its headway. The viewer shows
24
+ a heatmap of versions × metrics × datasets, so you can see what each change gained or lost.
25
+
26
+ What headway does **not** do: it does not run your method, compute metrics, manage git or experiments, or read
27
+ equations. Equations are TeX strings shown as written; headway never parses them. Whether an equation matches the
28
+ code is established only by a check function you write (`@G.verify`).
29
+
30
+ ## Installing it in another codebase
31
+
32
+ headway is a Python package (Python 3.12+, numpy). Install it into the environment that can import the method:
33
+
34
+ ```
35
+ pip install -e /path/to/headway # or: uv pip install -e /path/to/headway
36
+ headway guide # this file
37
+ ```
38
+
39
+ The spec imports headway; the method's own code never imports the spec or headway. A suggested layout:
40
+
41
+ ```
42
+ mypkg/ the method
43
+ mypkg/graphspec.py the spec: one Headway object, conventionally named G
44
+ bench/mybench/suite.py a benchmark suite (optional): one headway.Suite, conventionally named B
45
+ bench/run.py runs the method on the benchmark and builds a headway with the results
46
+ (run with bench/ on PYTHONPATH, so that `mybench.suite` imports)
47
+ headways/ built versions: v1.json, v1.html, ...
48
+ tests/test_headway.py the check and the shape trace, in the test suite
49
+ ```
50
+
51
+ ## The pieces of a spec
52
+
53
+ ```python
54
+ from headway import Headway
55
+ G = Headway("my method", "One sentence on what it computes, from what.")
56
+ ```
57
+
58
+ **Indices** name the axes of arrays: `G.index(key, tex, name, desc="", *, varies=False)`.
59
+ `i = G.index("i", "i", "cell")`. `varies=True` if the axis may have different sizes in different calls (a subset).
60
+
61
+ **Symbols** are the values: data, intermediate results, outputs, settings and constants.
62
+ `G.symbol(key, tex, name, desc="", *, dims=(), per=None, role="value", value=None, code=(), was=())`
63
+
64
+ - `dims`: the indices, in order (`dims=(i, g)` is an i × g array; `()` a scalar).
65
+ - `role`: `"input"` (data given to the method), `"value"` (computed inside), `"output"` (a result of the method),
66
+ `"setting"` (configurable), `"constant"` (a number fixed in the code).
67
+ - `per`: an index the symbol has one copy of per value of. The copies themselves have `dims` without it: one
68
+ model per fold is `dims=(g,), per=fold`; one error per candidate penalty, computed by one call each, is
69
+ `dims=(), per=a`, not `dims=(a,)` (the shape trace checks each call's value against `dims`).
70
+ - `code`: optional. For an input, where it is read, loaded or defined, if there is such a place; for a setting,
71
+ its config field; for a constant, the line that sets it (a constant without one is a warning).
72
+ - Shortcuts: `G.setting(key, tex, name, desc="", *, value=None, code=(), was=())` (point `code` at the config
73
+ field, e.g. `"mypkg.config:Settings.alpha"`) and `G.constant(key, tex, name, desc="", *, value, code=(), was=())`.
74
+
75
+ **Steps** (ops) turn input symbols into output symbols:
76
+
77
+ ```python
78
+ G.op(key, label, tex="", desc="", *, inputs=(), outputs=(), code=(), group="", per=None, bind=None, where=None,
79
+ was=())
80
+ ```
81
+
82
+ - `tex`: the equation, as the code computes it (not as a paper states it, if they differ). Display TeX, KaTeX
83
+ dialect (`\begin{aligned}` works).
84
+ - `desc`: one or two sentences: what the step does and why it is there. `$...$` in any description is TeX.
85
+ - `inputs`, `outputs`: symbols (objects or keys). Each symbol is produced by at most one step.
86
+ - `code`: one reference or a list (see "Code references").
87
+ - `group`: a path, `"round 1/model"`; groups nest and collapse in the viewer. `G.group(path, label=None, desc="",
88
+ *, collapsed=False, was=())` gives one a label, a description or a collapsed start; it is optional.
89
+ - `per`: the step is done once per value of this index (drawn as stacked cards); its per-value outputs are
90
+ symbols with the same `per`.
91
+ - `where`: `{TeX: definition}` for symbols the equation uses that are not values of the graph (a step's own
92
+ intermediates, functions like σ). Shown under the equation, as a paper would. A value two steps share should be
93
+ a symbol listed in both instead.
94
+ - `bind`: for the shape trace, `{code name: symbol}`, naming the parameters of the function in the step's first
95
+ code reference, and `"return"`, `"return.0"` (a tuple's first item) or `"return.attr"` (an attribute of the
96
+ result). Optional; see "Shape trace" for which steps can be bound.
97
+
98
+ **Equation checks**: a function that computes the equation as written, plainly (numpy on small made-up data),
99
+ and asserts the code gives the same. Registered for one or more steps:
100
+
101
+ ```python
102
+ @G.verify("center", "score")
103
+ def as_written() -> None:
104
+ X = np.random.default_rng(0).normal(size=(6, 4))
105
+ expected = (X - X.mean(0)).sum(1) # the equations, written out
106
+ assert np.allclose(mypkg.core.score(X), expected) # the code
107
+ ```
108
+
109
+ A step whose check passes is marked ✓ in the viewer; one that fails, ✗ (and `headway check` fails).
110
+
111
+ **Shape trace**, in your tests: run the method on small data with the bound functions wrapped; every call's
112
+ bound values must have as many axes as their symbols have `dims`, and each index one size (within a call, and
113
+ across calls unless the index `varies`). How it works, and so what to bind:
114
+
115
+ - The trace wraps the function named by a bound step's **first** code reference (an anchor is ignored: the whole
116
+ function is wrapped). So bind a step only when its first reference is a function that computes that step. A
117
+ step whose code is a block inside a larger function cannot be traced; leave it unbound. Unbound steps are not
118
+ traced and nothing warns.
119
+ - A function several steps bind (the same helper used in two places): each call is given to a step whose bind
120
+ fits it, in order. A function called on subsets of the rows (inside cross-validation folds, say) sees an index
121
+ at several sizes: declare that index `varies=True`, or leave that step unbound if the looser check is not worth it.
122
+ - Every bound step must be called at least once by the traced run.
123
+
124
+ The test file, in full:
125
+
126
+ ```python
127
+ import headway
128
+ import mypkg
129
+ from mypkg import graphspec
130
+
131
+ def test_spec_matches_code():
132
+ report = headway.check(graphspec.G)
133
+ assert report.ok, report.text()
134
+
135
+ def test_shapes():
136
+ with headway.trace(graphspec.G) as t:
137
+ mypkg.run(small_data()) # the method's entry point, on a few rows
138
+ t.assert_ok()
139
+ ```
140
+
141
+ ## Code references
142
+
143
+ ```
144
+ "pkg.mod:func" the whole function (or class, method or property: "pkg.mod:Class.method")
145
+ "pkg.mod:Class.field" one line of a class body (a dataclass field)
146
+ "pkg.mod:CONSTANT" a module-level assignment, the whole statement
147
+ "pkg.mod:func#text" from the line holding `text` to the line before the next comment line at the
148
+ same or a smaller indentation, or to the end of the enclosing block if there
149
+ is none (so "#step 2" takes the block under "# step 2: ...")
150
+ "pkg.mod:func#from text..to text" from the line holding `from text` to the next line holding `to text`, inclusive
151
+ "pkg.mod:CONSTANT#text..text" the same, within a module-level statement (one entry of a dict)
152
+ ```
153
+
154
+ The anchor text must occur on exactly one line. Anchor on something that would change if the step changed: for a
155
+ constant, the literal value (`"pkg.mod:fit#np.clip(p, 1e-3..np.clip(p, 1e-3"`), so changing it in the code
156
+ fails the check until the spec is updated.
157
+
158
+ ## What `headway check` reports
159
+
160
+ Errors (exit 1): a reference that does not resolve; an undeclared symbol or index; a symbol produced by two steps;
161
+ a step producing an input, setting or constant; a value or output no step produces; a cycle; a `where` entry
162
+ whose TeX is exactly a declared symbol's TeX (list the symbol instead; the comparison is of whole strings, so an
163
+ entry defining several symbols at once, `"n_1, n_0"`, is fine); a failed `@verify`; a `was` naming a key the spec
164
+ still declares; and, in a suite, the errors listed under "Benchmarks". Warnings: a symbol no step uses (outputs
165
+ excepted), a step with no code reference, a constant with no code reference, a group with no steps.
166
+
167
+ Not checked: that the graph lists all the data flow there is, and that a step without a `@verify` matches its
168
+ code. Say in a step's description when its equation is a simplification of the code.
169
+
170
+ ## Writing a headway for a codebase: the procedure
171
+
172
+ 1. **Read the method end to end.** Find the entry point and follow the data from the inputs to the outputs. Read
173
+ the docs and the code; where they disagree, the code is the ground truth, and the spec describes the code.
174
+ 2. **Choose the steps.** One step per meaningful mathematical operation: usually a function, or a commented block
175
+ inside a long one. Glue (I/O, logging, reshaping) is not a step unless it changes the meaning of the data. A
176
+ method of moderate size has 8–40 steps; group them by stage (`"preprocessing"`, `"model"`, `"round 1/fit"`).
177
+ 3. **Declare the indices and the symbols**, with notation that follows the project's paper or docs if there is
178
+ one. One meaning per symbol across the spec; the same operation written the same way everywhere. Every setting
179
+ points to its config field; every number fixed in the code that matters is a constant pointing to its line.
180
+ 4. **Declare each step**: its equation as the code computes it, a description of what it does and why, its inputs
181
+ and outputs, its code references, `where` for its local symbols, and `bind` if you will trace shapes.
182
+ 5. **Write `@verify` checks** for the steps that carry the math (at least the ones whose equations someone will
183
+ rely on). Small random data; the equation written out plainly; compared with the code's function.
184
+ 6. **Run `headway check pkg.graphspec`** until there are no errors, and fix the warnings or know why they stay.
185
+ 7. **Add the test** (`headway.check(G).ok` and a shape trace) so the spec breaks loudly when the code changes.
186
+ 8. **Build and look**: `headway build pkg.graphspec -o headway.html`, or `headway serve pkg.graphspec --open` to
187
+ rebuild live while editing. Read the graph as a newcomer would: does every step say what it is for?
188
+
189
+ When the code changes later, rerun `headway check`; it names what went stale. Update the spec in the same change
190
+ as the code.
191
+
192
+ ## Versions: seeing what changed
193
+
194
+ A **version** is a built headway: the JSON from `headway build --json`, or the HTML page itself. Keep one wherever
195
+ it is useful (each commit you care about, each experiment, each competing method), with `--name` to label it.
196
+ headway records the spec's file, the build time, and, in a git repository, the commit, branch and whether the work
197
+ tree had changes.
198
+
199
+ ```
200
+ headway build pkg.graphspec --name v5 -o headways/v5.html --json headways/v5.json \
201
+ --compare headways/v3.json --compare headways/v4.json # v3 and v4 in lanes beside v5
202
+ headway diff headways/v4.json headways/v5.json # the same comparison, as text
203
+ ```
204
+
205
+ In the viewer, **Compare…** opens more versions. A version built without `--name` is called by its file name; lanes are ordered by build time and can be dragged, closed or
206
+ pinned as the baseline. Markers: **+** added, **Δ** changed, **−** gone, **⋔** split or merged. **One at a time**
207
+ shows one version, keeping the others open.
208
+
209
+ Objects correspond across versions by key. When you rename a key, say so in the new version with `was=`:
210
+
211
+ ```python
212
+ G.op("weighted_score", "module score", ..., was="score") # renamed
213
+ G.op("sizefactor", ..., was="normalize") # split: each part names
214
+ G.op("scale_counts", ..., was="normalize") # the step it came from
215
+ G.symbol("S", ..., was=["score", "cell_score"]) # renamed twice, or two merged into one
216
+ ```
217
+
218
+ What counts as a change: inputs, outputs, group, dimensions or role (structure); the equation text; the code the
219
+ references resolve to (so a code change under an unchanged equation shows); a setting's value; the check result;
220
+ and labels and descriptions (shown only with "wording" / `--wording`).
221
+
222
+ ## Benchmarks
223
+
224
+ A benchmark suite is a `headway.Suite`, which is a Headway: you declare it the same way, and it adds datasets,
225
+ metrics and objectives.
226
+
227
+ ```python
228
+ from headway import Suite
229
+ B = Suite("my benchmark", "What it measures.", version="1")
230
+ i = B.index("i", "i", "cell")
231
+ S = B.symbol("S", "S_i", "score", "The method's output.", dims=(i,), role="input")
232
+ y = B.symbol("y", "y_i", "truth", dims=(i,), role="input")
233
+
234
+ B.metric("auroc", r"\mathrm{AUROC}", "separation", "How often a positive scores above a negative.",
235
+ equation=r"\mathrm{AUROC} = \frac{1}{n_1 n_0} \sum_{i: y_i=1} \sum_{j: y_j=0} [S_i > S_j]",
236
+ inputs=[S, y], better="higher", range=(0, 1), code="mybench.metrics:auroc",
237
+ where={"n_1, n_0": "the numbers of positives and negatives"}, source="Mann-Whitney U, scaled")
238
+
239
+ B.dataset("pbmc", "PBMC", "Blood cells with sorted labels.", source="GEO GSE123456",
240
+ facts={"cells": "12,000", "platform": "10x v3"}, code="mybench.data:load_pbmc")
241
+ B.dataset("check", "held-out mix", "Not looked at during development.", heldout=True,
242
+ code="mybench.data:load_check")
243
+
244
+ B.objective("overall", "overall", "Separation counts twice.", metrics={"auroc": 2, "stability": 1})
245
+ B.objective("rank", "mean rank", metrics=["auroc", "stability"], how="rank")
246
+
247
+ @B.verify("auroc")
248
+ def auroc_as_written(): ...
249
+ ```
250
+
251
+ **How a suite meets the method.** A suite describes the benchmark, not the method. Its `role="input"` symbols are
252
+ what the method hands to the metrics (its outputs: scores, predictions, coefficients); write them with the same
253
+ notation as the method's spec, since a reader goes between the two (nothing links them automatically). If the
254
+ suite also makes the data the method runs on, declare a step for the method itself in the suite: inputs the data,
255
+ outputs the method's results, `code` pointing to the method's entry function, with a description saying it is
256
+ the method being benchmarked. The graph then reads data → method → metrics, and no data symbol is left unused.
257
+
258
+ - **`B.metric(key, tex, name, desc="", *, equation="", inputs=(), better, range=None, unit="", source="",
259
+ code=(), where=None, group="metrics", was=())`** declares the output symbol `key` and the step `key` that
260
+ computes it, so a metric has a notation, an equation, code and a check like any step. `better` is `"higher"` or
261
+ `"lower"`; `range` is its possible extent (needed by `how="mean"` objectives). For a metric with no natural
262
+ bound (a mean squared error), either use a bounded version of it (R², or the error relative to a baseline's) or
263
+ leave out `range` and combine it only in `how="rank"` objectives; a guessed range warns when values fall outside.
264
+ - **`B.dataset(key, name, desc="", *, source="", heldout=False, values=None, facts=None, code=())`**. If the suite
265
+ makes its data (a simulation, a preprocessing), declare those steps in the suite like any other (with their
266
+ settings, equations and checks) and give each dataset its `values` of those settings: the steps are written
267
+ once and the datasets differ in their values. `values` may only name declared settings. Keep one copy of the
268
+ values: either the benchmark code holds them and the suite reads them (`values=data.DATASETS["pbmc"]`), or the
269
+ suite holds them and the benchmark code reads them back (`B.datasets["pbmc"].values`, a dict). Replicates (seeds,
270
+ folds) are not dataset values; record them in the results' `run`. `facts` is anything
271
+ else worth knowing; `source`, where the data come from; `code`, where they are made or loaded. A held-out
272
+ dataset is hidden in the viewer unless asked for.
273
+ - **`B.objective(key, name, desc="", *, metrics, how="mean", datasets=None, tex="")`** combines metrics into one
274
+ number, kept apart from the metrics. `metrics` is `{metric: weight}` or a list (weight 1). `how="mean"`: each
275
+ metric's mean scaled to 0..1 by its range, turned so 1 is best, weighted and averaged over the datasets
276
+ (absolute). `how="rank"`: the weighted mean rank among the versions shown, 1 best (relative). `datasets`:
277
+ default every dataset not held out. A version missing any value an objective needs gets no score ("incomplete").
278
+
279
+ Suite errors: an empty range, an objective naming an undeclared metric or dataset, a `how="mean"` objective over a
280
+ metric without a range, a weight that is not positive, a dataset value for anything but a declared setting.
281
+
282
+ ### Results
283
+
284
+ headway computes no metrics. Your benchmark runs the method, computes the metrics, and hands headway the values:
285
+ one per replicate (seed, fold), for each dataset and metric it ran. Missing combinations are fine (shown hatched).
286
+
287
+ ```json
288
+ {"suite": "mybench.suite",
289
+ "values": {"pbmc": {"auroc": [0.91, 0.90, 0.93], "stability": [0.8, 0.82, 0.79]}},
290
+ "run": {"seeds": [0, 1, 2], "date": "2026-10-27"}}
291
+ ```
292
+
293
+ `suite` names the module holding the Suite (or `"module:attribute"`); in Python it can be the Suite itself. `run`
294
+ is anything worth keeping. Results are stored in the version's headway together with the built suite; versions
295
+ are compared on results with the same suite title and version, so bump `version` when a change to the suite makes
296
+ old results incomparable. Values for a dataset or metric the suite does not declare are left out with a warning.
297
+
298
+ From the command line:
299
+
300
+ ```
301
+ headway build pkg.graphspec --name v5 --results results/v5.json -o headways/v5.html --json headways/v5.json
302
+ headway attach headways/v4.json results/v4.json # add results to a built version (JSON or page)
303
+ headway results headways/v*.json # the table, as text
304
+ ```
305
+
306
+ From Python, in the codebase's own benchmark script (the usual way: run, then build, in one go):
307
+
308
+ ```python
309
+ import headway
310
+ from mybench import suite, metrics, data
311
+
312
+ values = {}
313
+ for name in ("pbmc", "check"):
314
+ for seed in range(3):
315
+ d = data.load(name, seed)
316
+ s = mypkg.score(d.counts)
317
+ values.setdefault(name, {}).setdefault("auroc", []).append(metrics.auroc(s, d.truth))
318
+
319
+ headway.build("mypkg.graphspec", "headways/v5.html", json="headways/v5.json", name="v5",
320
+ results=[{"suite": suite.B, "values": values, "run": {"seeds": [0, 1, 2]}}],
321
+ compare=["headways/v4.json"])
322
+ ```
323
+
324
+ In the viewer, **Graph | Results** switches to the heatmap of the open versions: objectives first, then metric ×
325
+ dataset columns (group by metric or by dataset), each cell the mean ± s.d. of the replicates and its difference
326
+ from the version it is compared with. Click a column for the metric's definition and every version's replicates;
327
+ click a dataset for what it is; click a version for what changed in it.
328
+
329
+ ## Command line
330
+
331
+ ```
332
+ headway check SPEC [--no-verify] exit 1 if the spec and the code disagree
333
+ headway build SPEC [-o headway.html] [--json FILE] [--name NAME] [--results FILE ...] [--compare [NAME=]FILE ...]
334
+ [--collapse GROUP ...] [--hide GROUP ...] [--no-verify] [--offline] [--editor EDITOR]
335
+ headway serve SPEC [--port 8765] [--open] [--compare ...] [--collapse ...] [--hide ...] [--editor EDITOR]
336
+ headway diff VERSION VERSION ... [--wording] a VERSION is a built file, or a spec module (built as it is now)
337
+ headway attach VERSION RESULTS.json [-o OUT]
338
+ headway results VERSION VERSION ... [--heldout]
339
+ headway guide
340
+ ```
341
+
342
+ SPEC is an importable module holding one Headway (or `module:attribute`); run from a directory where it imports.
343
+ `--offline` writes the page's libraries into it (about 3 MB) so it opens without internet. `--editor` (or
344
+ `$HEADWAY_EDITOR`): `vscode` (default), `cursor`, `pycharm`, `file`, or a URL template with `{file}`, `{line}`,
345
+ `{col}`. `serve` rebuilds when the spec or any file its references point to changes, and the open page keeps its
346
+ view.
347
+
348
+ ## Python API
349
+
350
+ ```python
351
+ import headway
352
+ headway.Headway, headway.Suite # declaring
353
+ headway.check(G) -> Report # .ok, .errors, .warnings, .text()
354
+ headway.trace(G) # context manager; .assert_ok()
355
+ headway.build(spec, out="headway.html", *, json=None, name=None, results=(), compare=(), collapse=(), hide=(),
356
+ verify=True, offline=False, editor=None, strict=False, log=None) -> dict
357
+ headway.attach(path, results, out=None, log=None) -> dict
358
+ headway.read_results(results, log=None) -> dict # a results dict or file -> the entry stored in a headway
359
+ headway.load("pkg.mod[:attr]") -> Headway
360
+ ```
361
+
362
+ `spec` is a module name or a Headway object; `out` or `json` may be None to skip that file; `results` items are
363
+ results dicts or paths to results files; `strict=True` raises if the check finds errors (otherwise they are in
364
+ the page); `log` receives progress messages (e.g. `log=print`).
365
+
366
+ ## Rules of thumb
367
+
368
+ - The spec describes the code as it is. If the code and a paper differ, write the code's equation and say so in
369
+ the description.
370
+ - Keep the spec and the code in the same change; run `headway check` in the tests.
371
+ - Prefer a symbol over a `where` entry when a value is used by more than one step.
372
+ - Name things for a reader new to the method: labels are short and plain ("size factors", not "sf"); descriptions
373
+ say what and why, not how the code is organized.
374
+ - Do not compute metrics, or anything else, in headway; it shows precomputed results against the methods.
375
+ - Do not make headway parse TeX; equations are checked only by `@verify` functions.
headway/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """headway: see how a method works, and how much each change to it gains. A spec module declares the method's
2
+ steps (equations, inputs, outputs, references into the code); headway checks it against the code and draws it as
3
+ an interactive computation graph, compares versions of it, and shows their benchmark results.
4
+ `headway guide` (or GUIDE.md in this package) describes it all."""
5
+ from headway.api import attach, build, load, read_results
6
+ from headway.bench import Suite
7
+ from headway.check import Report, check
8
+ from headway.spec import Group, Headway, Index, Op, Symbol
9
+ from headway.trace import Trace, trace
10
+
11
+ __all__ = ["Group", "Headway", "Index", "Op", "Report", "Suite", "Symbol", "Trace", "attach", "build", "check", "load",
12
+ "read_results", "trace"]
headway/api.py ADDED
@@ -0,0 +1,135 @@
1
+ """headway from Python: build a method's headway, with its benchmark results, from a script in any codebase.
2
+
3
+ import headway
4
+ values = run_my_benchmark() # {dataset: {metric: [one value per replicate]}}
5
+ headway.build("mypkg.graphspec", "headways/v5.html", json="headways/v5.json", name="v5",
6
+ results=[{"suite": "mybench.suite", "values": values, "run": {"seeds": [0, 1, 2]}}],
7
+ compare=["headways/v4.json"])
8
+
9
+ A spec or a suite can be given as a module name ("pkg.mod", or "pkg.mod:attribute" when the module holds more
10
+ than one) or as the object itself. The command line (cli.py) does its work through these functions.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ import json as jsonlib
16
+ import re
17
+ import sys
18
+ from collections.abc import Callable, Iterable, Mapping
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from headway import bench, diff, page
23
+ from headway.check import check
24
+ from headway.spec import Headway
25
+
26
+ Log = Callable[[str], None] | None
27
+
28
+
29
+ def load(target: str | Headway) -> Headway:
30
+ """The Headway (or Suite) a module holds: "pkg.mod", or "pkg.mod:attribute"."""
31
+ if isinstance(target, Headway):
32
+ return target
33
+ module_name, _, attr = target.partition(":")
34
+ if str(Path.cwd()) not in sys.path:
35
+ sys.path.insert(0, str(Path.cwd()))
36
+ module = importlib.import_module(module_name)
37
+ if attr:
38
+ return getattr(module, attr)
39
+ found = [v for v in vars(module).values() if isinstance(v, Headway)]
40
+ assert len(found) == 1, f"{module_name}: {len(found)} Headway objects; name one as {module_name}:<attribute>"
41
+ return found[0]
42
+
43
+
44
+ def _name(target: str | Headway, g: Headway) -> str:
45
+ """The spec's module name, as the viewer shows it in commands (`headway check <it>`)."""
46
+ if isinstance(target, str):
47
+ return target
48
+ return next((d.module for d in g.declared.values()), "")
49
+
50
+
51
+ def read_results(r: Mapping[str, Any] | str | Path, log: Log = None) -> dict[str, Any]:
52
+ """A results file (or the same as a dict) -> the entry kept in a headway: the values, checked against the
53
+ suite, and the suite, checked and built. {"suite": module name or Suite, "values": {dataset: {metric:
54
+ [values]}}, "run": {...}}."""
55
+ where = str(r) if isinstance(r, (str, Path)) else "results"
56
+ if isinstance(r, (str, Path)):
57
+ r = jsonlib.loads(Path(r).read_text())
58
+ assert isinstance(r, Mapping) and "suite" in r and "values" in r, f"{where}: not results (needs suite, values)"
59
+ suite = load(r["suite"])
60
+ assert isinstance(suite, bench.Suite), f"{r['suite']}: not a headway.Suite"
61
+ report = check(suite)
62
+ if not report.ok and log:
63
+ log(f"suite {suite.title}:\n{report.text()}")
64
+ e = bench.entry({**r, "suite": _name(r["suite"], suite)},
65
+ page.data(suite, report, spec=_name(r["suite"], suite)))
66
+ for w in e["warnings"]:
67
+ if log:
68
+ log(f"warning: {where}: {w}")
69
+ return e
70
+
71
+
72
+ def lanes(versions: Iterable[str | Path]) -> list[dict[str, Any]]:
73
+ """Other versions to show beside the spec, each "[NAME=]FILE" (a --json file or a built page)."""
74
+ out = []
75
+ for item in map(str, versions):
76
+ name, _, path = item.rpartition("=") if "=" in item and not Path(item).exists() else ("", "", item)
77
+ d = diff.load(path)
78
+ out.append({"name": name or d.get("name") or Path(path).stem, "from": str(Path(path).resolve()), "data": d})
79
+ return out
80
+
81
+
82
+ def build(spec: str | Headway, out: str | Path | None = "headway.html", *, json: str | Path | None = None,
83
+ name: str | None = None, results: Iterable[Mapping[str, Any] | str | Path] = (),
84
+ compare: Iterable[str | Path] = (), collapse: Iterable[str] = (), hide: Iterable[str] = (),
85
+ verify: bool = True, offline: bool = False, editor: str | None = None, strict: bool = False,
86
+ log: Log = None) -> dict[str, Any]:
87
+ """Check the spec, build it with any benchmark results, and write the page (`out`) and the JSON (`json`);
88
+ either can be None. Returns the built data. strict: raise if the check finds errors (else they are in the
89
+ data, under "check", and shown in the page)."""
90
+ g = load(spec)
91
+ report = check(g, run_verifiers=verify)
92
+ if log:
93
+ log(report.text())
94
+ if strict and not report.ok:
95
+ raise ValueError(f"{_name(spec, g)}: the check found errors:\n{report.text()}")
96
+ d = page.data(g, report, list(collapse), list(hide), spec=_name(spec, g), editor_=page.editor(editor))
97
+ if name:
98
+ d["name"] = name
99
+ for r in results:
100
+ bench.attach(d, read_results(r, log))
101
+ compared = lanes(compare)
102
+ if out is not None:
103
+ Path(out).write_text(page.html(d | ({"lanes": compared} if compared else {}), offline=offline))
104
+ if log:
105
+ log(f"wrote {out}")
106
+ if json is not None:
107
+ Path(json).write_text(jsonlib.dumps(d, indent=1))
108
+ if log:
109
+ log(f"wrote {json}")
110
+ return d
111
+
112
+
113
+ def attach(path: str | Path, results: Mapping[str, Any] | str | Path, out: str | Path | None = None,
114
+ log: Log = None) -> dict[str, Any]:
115
+ """Put benchmark results into a built headway: the JSON from --json, or a built page (rewritten with the
116
+ current viewer, keeping its other versions and whether it works offline). Results on the same suite are
117
+ replaced. Writes `out` (default: the file itself) and returns the data."""
118
+ path = Path(path)
119
+ text = path.read_text()
120
+ is_page = not text.lstrip().startswith("{")
121
+ if is_page:
122
+ m = re.search(r"^let D = (\{.*\});$", text, re.M)
123
+ assert m, f"{path}: neither a headway JSON file nor a page built by headway"
124
+ d = jsonlib.loads(m[1])
125
+ else:
126
+ d = diff.load(path)
127
+ bench.attach(d, read_results(results, log))
128
+ target = Path(out or path)
129
+ if is_page:
130
+ target.write_text(page.html(d, offline="data:text/javascript;base64" in text))
131
+ else:
132
+ target.write_text(jsonlib.dumps(d, indent=1))
133
+ if log:
134
+ log(f"wrote {target}")
135
+ return d