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 +375 -0
- headway/__init__.py +12 -0
- headway/api.py +135 -0
- headway/bench.py +264 -0
- headway/check.py +146 -0
- headway/cli.py +140 -0
- headway/diff.py +265 -0
- headway/page.py +208 -0
- headway/resolve.py +131 -0
- headway/serve.py +221 -0
- headway/spec.py +187 -0
- headway/trace.py +147 -0
- headway/viewer.html +1998 -0
- headway-0.1.0.dist-info/METADATA +8 -0
- headway-0.1.0.dist-info/RECORD +18 -0
- headway-0.1.0.dist-info/WHEEL +5 -0
- headway-0.1.0.dist-info/entry_points.txt +2 -0
- headway-0.1.0.dist-info/top_level.txt +1 -0
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
|