headway 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
headway-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: headway
3
+ Version: 0.1.0
4
+ Summary: See how a method works and how much each change to it gains: a checked computation graph of the code, its versions and their benchmark results
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: numpy>=1.26
7
+ Provides-Extra: test
8
+ Requires-Dist: pytest>=8; extra == "test"
@@ -0,0 +1,211 @@
1
+ # headway
2
+
3
+ See how a method works, and how much headway each change to it makes. A spec module declares a method's indices,
4
+ symbols (with their dimensions), steps (with their equations) and groups, each pointing into the code. headway
5
+ checks it against the code, so the picture cannot quietly go stale, and draws it as an interactive computation
6
+ graph, with a legend of indices, symbols and equations and the code behind every step. Versions of the method
7
+ (commits, experiments, competing methods) open side by side, and their benchmark results show as a heatmap, so
8
+ you can see what each change gained.
9
+
10
+ **[GUIDE.md](src/headway/GUIDE.md)** (also `headway guide`) is the full description: how headway works and how
11
+ to write a headway for a codebase. It is written to be handed to a coding agent working in another repository.
12
+
13
+ ```
14
+ pip install -e /path/to/headway # into the environment that imports the method
15
+ headway check my_pkg.graphspec # exit 1 if the spec and the code disagree
16
+ headway build my_pkg.graphspec -o headway.html # one HTML file; open it in a browser
17
+ [--collapse "round 0"] [--hide setup] [--json headway.json] [--no-verify] [--offline]
18
+ [--name v3] [--compare v1.json --compare v2.html] [--results results.json]
19
+ headway serve my_pkg.graphspec [--open] # the viewer at http://127.0.0.1:8765/, rebuilt live
20
+ [--compare old.json]
21
+ headway diff v1.json v2.json my_pkg.graphspec # what changed from each version to the next
22
+ headway attach v3.json results.json # put benchmark results into a built headway (or build --results)
23
+ headway results v1.json v2.json v3.json # the versions' results on their common benchmark suite
24
+ headway guide # the guide
25
+ ```
26
+
27
+ From Python, e.g. at the end of a benchmark script in the method's own repository:
28
+
29
+ ```python
30
+ import headway
31
+ headway.build("my_pkg.graphspec", "headways/v5.html", json="headways/v5.json", name="v5",
32
+ results=[{"suite": "my_bench.suite", "values": values, "run": {"seeds": [0, 1, 2]}}],
33
+ compare=["headways/v4.json"])
34
+ ```
35
+
36
+ `serve` watches the spec's package and every file its code references point to. On a save it rebuilds in a fresh
37
+ Python process (about a second, including the equation checks) and the open page takes the new graph without
38
+ losing its view: the selection, the zoom, the open groups. Steps that changed pulse, and a note says how many
39
+ changed. A spec that fails to build leaves the last good graph up, with the error over it. Each step, value and
40
+ group in the side panel links to its declaration in the spec ("✎ declared at …").
41
+
42
+ ## Editing from the page
43
+
44
+ The page stays a static view; edits happen in the spec, and the page takes you there.
45
+
46
+ - **✎ links** open the spec in your editor at the exact argument: the equation, the description, the inputs, the
47
+ code references. They are in the side panel, and on the right of each legend row when you hover over it. For an
48
+ object declared inside a helper function of the spec, the panel says so: editing the helper changes everything
49
+ it declares.
50
+ - **`--editor`** (on `build` and `serve`, or `$HEADWAY_EDITOR`): `vscode` (the default), `cursor`, `pycharm`,
51
+ `file`, or a URL template with `{file}`, `{line}` and `{col}`.
52
+ - **Ask an AI**, in the side panel, writes a prompt to paste into any assistant that can read the repository. It
53
+ carries the object's declaration and location, its inputs and outputs, the code it points to and its equation
54
+ check; with nothing selected, it is about the spec as a whole. It has two modes:
55
+ - **Change the spec**: the request, with instructions to keep the references resolving, keep the `@G.verify`
56
+ function in line with any changed equation, flag needed code changes, and run `headway check`. Preset requests
57
+ fill in the box, worded for what is selected (a step, the steps around a symbol, a group, or the whole spec):
58
+ **define local symbols** (put the symbols an equation uses but the graph does not define in `where=`), **add
59
+ explanations** (a sentence or two in the spec on what each computation and output is, and what it is for),
60
+ **check descriptions** (against the code), **reconcile with the code** (after the code changed), **standardize
61
+ notation** (the same operation written the same way; fixed numbers as constants) and **disambiguate symbols**
62
+ (one meaning per symbol across the spec).
63
+ - **Explain**: a question, answered as a separate product; the assistant is told to change nothing, to take the
64
+ code as the ground truth and to say which reasons are documented and which are its own reading. Presets: what is
65
+ it for, read the equation, why this way, how it fits in, code vs. math, check my understanding. **Answer as**
66
+ sets how elaborate the answer is: a quick answer, a full explanation, a tutoring session, a worked example,
67
+ study notes to keep, or an interactive page (shown the way the assistant's environment shows pages, or as one
68
+ HTML file).
69
+
70
+ The mode and the answer format are remembered per browser; what is typed is kept per object and per mode.
71
+
72
+ The built page is a static file: open it from disk, send it, or put it on any web server. It loads KaTeX, elkjs and
73
+ highlight.js from CDNs; with `--offline` they are written into the file instead (about 3 MB), and the page needs no
74
+ network at all. `--offline` downloads them once, into `~/.cache/headway`. The ✎ links point to the spec on the
75
+ machine that built the page.
76
+
77
+ The viewer fills the window: the graph, the side panel on the right and
78
+ the legend below. Drag a divider to resize; drag it nearly shut, double-click it or use its arrow to collapse a
79
+ pane. The sizes are remembered per browser. Click a step or a value for its equation, inputs, outputs and
80
+ code (with a link that opens your editor at the line); double-click a group to collapse or expand it; show only what is
81
+ upstream or downstream of the selection; hide groups; show the settings as nodes. Steps done once per fold or per
82
+ cell are drawn as stacked cards; ✓ marks an equation checked against the code.
83
+
84
+ ## A spec
85
+
86
+ ```python
87
+ from headway import Headway
88
+ G = Headway("my method")
89
+ j = G.index("j", "j", "bin")
90
+ g = G.index("g", "g", "group")
91
+ m = G.symbol("m", "m_{jg}", "calls", dims=(j, g), role="input")
92
+ R = G.symbol("R", "R_{jg}", "residuals", dims=(j, g))
93
+ kr = G.setting("kr", r"\kappa_r", "pseudocounts", value=5, code="my_pkg.settings:Settings.pseudo")
94
+ G.op("residuals", "residuals", r"R_{jg} = \frac{m_{jg} + \kappa_r q}{n_{jg} + \kappa_r} - q",
95
+ inputs=[m, kr], outputs=[R], group="model",
96
+ code="my_pkg.model:residuals", # or "my_pkg.model:fit#step 5" / "#from text..to text"
97
+ bind={"methylated": m, "return": R}, # for the shape trace
98
+ where={"q": "the genome-wide fraction"}) # symbols used only in this step's equation
99
+
100
+ @G.verify("residuals") # the equation, written plainly, against the code
101
+ def residuals_as_written(): ...
102
+ ```
103
+
104
+ `where` is for symbols an equation uses that are not values of the graph: a step's own intermediates, functions
105
+ like the logistic σ. The viewer shows them under the equation, as a paper would ("where q is …"). A value another
106
+ step also uses should be a symbol, listed in both steps' inputs or outputs, so it shows as a node and an edge.
107
+ headway does not read the TeX, so it cannot tell you which symbols lack a definition; it only checks that a
108
+ where-entry is not written exactly as a declared symbol the step should list instead.
109
+
110
+ ## Comparing versions
111
+
112
+ A version is a built headway: the JSON from `build --json`, or a page from `build`. Keep one wherever it is useful
113
+ (at a tag, before an experiment, next to a set of results) and compare any of them, with each other or with the
114
+ spec as it is now. headway does not manage the versions or need git; it records the spec's file and, if it is in
115
+ a git repository, the commit, branch and whether the work tree had changes, and shows them with the version.
116
+
117
+ - **In the viewer**, `--compare` (on `build` and `serve`) or the **Compare…** button opens other versions, each in a
118
+ lane of its own. The lanes share one layout, so a step sits in the same place in every lane: stacked one above
119
+ another when the graph flows right, side by side when it flows down. A marker on each node says what changed
120
+ against the lane before it (or against a lane pinned as the baseline): **+** added, **Δ** changed, **−** gone
121
+ (drawn dashed where it was), **⋔** split or merged. The lanes start in the order the versions were built (a
122
+ version opened later takes its place in that order); drag them to reorder, × to close one, any number of them.
123
+ Opening a version that is open already (the same build, or one given the same `--name`) reloads it. **One at a
124
+ time** draws only the version you are reading, with its markers, and keeps the others open: click another to
125
+ switch. Click a lane to read that version in the side panel and the legend. Selecting an object selects it in every
126
+ lane; the panel lists it version by version and shows what changed, with the equations one above the other
127
+ and a diff of the code. Ask-AI prompts say which version they are about and what changed in it; Explain has
128
+ **What changed?**. **Collapse unchanged** leaves open only the groups where something changed.
129
+ - **`headway diff`** prints the same comparison as text, version after version (`--wording` for labels and
130
+ descriptions too). A spec module is built as it is now.
131
+
132
+ Objects correspond by key. When a key changes, say so with `was=`, on symbols, settings, constants, steps and
133
+ groups:
134
+
135
+ ```python
136
+ G.op("weighted_score", "module score", ..., was="score") # renamed
137
+ G.op("sizefactor", "size factors", ..., was="normalize") # split: both halves name
138
+ G.op("scale_counts", "scale counts", ..., was="normalize") # the step they came from
139
+ G.symbol("S", "S_i", "module score", ..., was=["score", "cell_score"]) # renamed twice
140
+ ```
141
+
142
+ A merged object lists every key it replaces. `was` is matched across all the open versions at once, so a rename two
143
+ versions back still lines up; `headway check` rejects a `was` naming a key the spec still declares. What counts
144
+ as a change is read from the built versions, never from the TeX: inputs and outputs, group, dimensions (structure),
145
+ the equation's text, the code the references resolve to (so a code change under an unchanged equation shows), a
146
+ setting's value, the equation check, and the wording (labels, descriptions; hidden unless asked for). Code no
147
+ reference points to is not seen.
148
+
149
+ `examples/cellscore` is a small method developed over four versions (gene centering; size factors, splitting a
150
+ step; gene weights, renaming the score), with `build.sh` building each version and the comparison page.
151
+
152
+ ## Benchmarks
153
+
154
+ A benchmark suite is declared like a method, as a `headway.Suite` (a Headway): each metric is a step, with its
155
+ notation, equation, description, code reference and `@verify` check, so it is annotated and checked the same way.
156
+ A suite adds its datasets (some can be held out), each metric's direction and range, and objectives, which combine
157
+ metrics into one number to rank by and are declared apart from them.
158
+
159
+ ```python
160
+ B = Suite("my benchmark", version="1")
161
+ S = B.symbol("S", "S_i", "score", dims=(i,), role="input")
162
+ B.dataset("deep", "deep sequencing", "...", values={"depth_sd": 0.3}, code="my_bench.data:simulate")
163
+ B.dataset("atlas", "cell atlas", "...", source="GSE123456", facts={"cells": "12,000", "platform": "10x v3"},
164
+ heldout=True, code="my_bench.data:load_atlas")
165
+ B.metric("auroc", r"\mathrm{AUROC}", "separation", "How often ...", equation=r"...", inputs=[S, y],
166
+ better="higher", range=(0, 1), code="my_bench.metrics:auroc", source="Mann-Whitney U, scaled")
167
+ B.objective("overall", "overall", metrics={"auroc": 2, "depth_dep": 1}) # how="mean" (absolute) or "rank"
168
+ ```
169
+
170
+ Datasets are annotated too. When the suite makes its data (a simulation, a preprocessing), declare those steps in
171
+ the suite like any other, with their equations, code and checks, and give each dataset its `values` of their
172
+ settings: the steps are written once, and the datasets differ in their values (`headway check` rejects a value
173
+ for anything but a declared setting). A dataset can also have a `source`, `facts` (anything else worth knowing)
174
+ and `code` (where it is made or loaded).
175
+
176
+ headway computes no metric. Your benchmark writes a results file, `{"suite": "my_bench.suite", "values":
177
+ {dataset: {metric: [one value per replicate]}}, "run": {...}}`, and `build --results` or `attach` puts it into a
178
+ version's headway with the suite, built and checked. Versions with results on the same suite (title and version) are
179
+ compared, wherever their files are.
180
+
181
+ In the viewer, **Graph | Results** switches between the computation graph and the results of the same open
182
+ versions: a heatmap of versions × metrics × datasets (grouped by metric or by dataset), each value the mean over
183
+ replicates ± their s.d., shaded from worst to best among the versions (or across the metric's range), with its
184
+ difference from the version it is compared with. Missing results are hatched, never filled in; an objective missing
185
+ any value it needs is "incomplete". Click a column for what the metric or objective is (its equation, code, check
186
+ and source) and the replicates of every version, plotted; click it again to sort by it. Click a dataset (under
187
+ the table, or its column when grouped by dataset) for what it is: its settings, how it is made, its facts and code,
188
+ and every version's results on it. In the suite's own page, the legend lists the datasets, and a setting shows
189
+ its value in each. Click a version for what
190
+ changed in it, and to go to it in the graph. The Ask-AI prompts there carry the results, the metrics' definitions
191
+ and what changed between the versions: **Why do they differ?** and **What to try next?**. `headway results`
192
+ prints the same table as text. The objectives are the only computation headway does on results (bench.py; the
193
+ viewer computes them the same way).
194
+
195
+ ## What is checked
196
+
197
+ | check | catches | where |
198
+ |---|---|---|
199
+ | references | a renamed, moved or deleted function; an anchor comment that is gone | `headway check` |
200
+ | structure | undeclared symbols or indices, two producers of one value, cycles, unused values | `headway check` |
201
+ | equations (`@G.verify`) | code that no longer computes the equation as written | `headway check`, marked ✓/✗ in the viewer |
202
+ | shapes (`headway.trace`) | arrays whose axes do not match the declared indices, an index with two sizes | your test suite |
203
+
204
+ ```python
205
+ with headway.trace(G) as t:
206
+ run_the_method_on_small_data()
207
+ t.assert_ok()
208
+ ```
209
+
210
+ Not checked: that the graph's edges are all the data flow there is (a step could use a value the spec does not
211
+ list), or that an equation without a `@verify` matches its code.
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "headway"
7
+ version = "0.1.0"
8
+ description = "See how a method works and how much each change to it gains: a checked computation graph of the code, its versions and their benchmark results"
9
+ requires-python = ">=3.12"
10
+ dependencies = ["numpy>=1.26"]
11
+
12
+ [project.optional-dependencies]
13
+ test = ["pytest>=8"]
14
+
15
+ [project.scripts]
16
+ headway = "headway.cli:main"
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
20
+
21
+ [tool.setuptools.package-data]
22
+ headway = ["viewer.html", "GUIDE.md"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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.
@@ -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"]