Skip to contents

Contributions to autograph, whether in the form of issue identification, bug fixes, new code or documentation are encouraged and welcome.

Please note that the autograph project is released with a Contributor Code of Conduct. By contributing to this project, you agree to abide by its terms.

Git

stocnet projects are maintained using the git version control system. A plain-English introduction to git can be found here. I recommend you read this before continuing. A more recent motivation can be found here. It will explain the basics of git version control, committing and repos, pulling and pushing, branching and merging.

Fork

Using git from the command line on your lap- or desktop can be intimidating, but I recommend Fork software for Mac and Windows. This allows mostly visual management of commits, diffs, branches, etc. There are various other git software packages available, but this one is fairly fully featured.

The Github page allows to access the issues assigned to you and check the commits. You can also access the documents in the repository, although this won’t be necessary after you have cloned it on your computer via Fork.

Cloning

Once you have downloaded Fork, the first thing you have to do is to clone the remote repository on your computer. Before cloning, you will be able to choose on which branch you want to work: develop or main.

Pull

This command allows you to pull changes from the remote repository to your local repository on Sourcetree. Make sure you do that before starting working on your files so you have the newest versions. When pulling, make sure you choose master or develop, depending on the branch you decided to work with. Once you pulled, you have now all the new commits and files and you can start working on your assigned tasks. Note that you can access and open the files either from the Finder or from Fork. Some documents might be stored using Large File Storage (LFS) to save space on the repository.

Commit and Push

Once you have made modifications on a file and saved them, it will appear in your commit window. Here you can control one last time your file, write the commit message with the issue reference (see below) and commit. Once your commit is ready, you can push them to the origin/main repository. Note that you can click the “push immediately” box in the commit window if you don’t want to do it in two steps. If you are working on a separate branch, it is important to select this branch when pushing to origin/main.

Branching and CI

  • main is the release branch; develop is the working branch (clone/work on develop).
  • PRs into main trigger prchecks.yml: R CMD check (macOS/Windows/Linux), binary build, codecov, lintr, spell check, a check that vignette articles and tutorials stay in sync, and PR metadata checks (DESCRIPTION version bump, PR title/description conventions).
  • Merges/pushes to main trigger pushrelease.yml: check, auto-bump version tag, GitHub release with binaries, then pkgdown site deploy.

Package architecture

Project overview

autograph is an R package (part of the stocnet ecosystem) providing the visual layer for network analysis: automatic ggplot2/ggraph-based plotting and consistent theming for network data and network-analytic results. It offers three graph-drawing entry points (graphr(), graphs(), grapht()) plus a large family of plot() S3 methods dispatched on result objects from other packages (migraph, netrics, RSiena, ergm, MoNAn, goldfish). Division of labour to keep in mind when adding functions:

  • manynet: network classes/coercion (as_*()) and network-level logical tests (e.g. is_directed(), is_twomode()).
  • netrics: everything analytic — marks, measures, memberships, motifs — at the node, tie, and network level.
  • autograph (this package): drawing graphs and plotting analytic, modelling, or diagnostic results, along with deep (often institutional) theming. All plot methods should live here.
  • migraph: testing and modelling, e.g. QAP/MRQAP and diffusion models.

In terms of style, we are aiming for sensible defaults in terms of user experience. As a ggplot2 object, most everything can be tweaked before or afterwards, but the default presentation should already be aesthetically pleasing, informative, and consistent.

Common commands

This is a standard R package developed with devtools/roxygen2. Run these from an R console with the working directory set to the package root (or via Rscript -e).

There is no non-R build system — no package.json/Makefile. Roxygen is configured with markdown = TRUE; NAMESPACE and all man/*.Rd files are generated — never hand-edit them.

Graph drawing pipeline (graphr()/graphs()/grapht())

graphr() (R/graphr.R) is the single-network entry point and the core of the package. It accepts any manynet-compatible network object and builds a ggplot2/ggraph plot through an internal pipeline of helper functions, each in its own file:

  • graph_layout() (R/graph_layout.R) — resolves the layout algorithm (igraph/ggraph/graphlayouts, or autograph’s own layouts), builds the ggraph layout object, and optionally snaps coordinates to a grid.
  • graph_nodes(), graph_edges(), graph_labels.R, graph_legends.R, graph_aes.R, graph_checks.R — each layer adds/styles one visual component (node aes, edge aes, text labels, legends) onto the ggraph plot, resolving arguments that may be literal values (e.g. node_size = 6) or references to a node/tie attribute name (e.g. node_size = "wealth").
  • Note that users are not expected to call any of these graph_*() functions themselves; exported modularity makes development, debugging, and testing easier.

graphs() (R/graphs.R) calls graphr() per network in a list and arranges the results with patchwork (deliberately chosen over gridExtra/cowplot/ggpubr because it doesn’t interfere with ggplot2 themes — see the comment in R/autograph_utilities.R).

grapht() (R/grapht.R) animates a longitudinal/dynamic network over time using gganimate/gifski.

Layout names and files

Custom layout algorithms not provided by igraph/ggraph/graphlayouts live in their own layout_*.R files and follow the layout_tbl_graph_*() naming convention. There is one file for each family: layout_layered.R (layered, lineage, railway, ladder), layout_concentric.R, layout_levels.R, layout_configurational.R, layout_valence.R and layout_matching.R.

One family, one file, one man page, one @name. A file named layout_*.R registers at least one layout_tbl_graph_*. A file that registers none is not a layout file: put the helper beside its only caller. This rule exists because two files broke it — layout_grid.R held the grid-snapping engine (now R/graph_snap.R), and layout_engine.R held only the layered engine (now the second half of R/layout_layered.R).

Each layout file is self-contained — its exports, its layout_tbl_graph_* aliases, and the private engine those exports share. A private helper used by exactly one family lives in that family’s file; one used by two or more goes to R/autograph_utilities.R. .rescale() was copied into two files instead, and drifted out of sight of both.

Choosing a name

Reserved names, never usable. ggraph:::layout_to_table.character() tests is.igraphlayout() before it looks for a layout_tbl_graph_* function, so a layout named with any of these is unreachable through layout =, and nothing warns:

bipartite, star, tree, circle, nicely, dh, drl, gem, graphopt, grid, mds, sugiyama, sphere, randomly, fr, kk, lgl

Taken namesggraph’s own layout_tbl_graph_*:

auto, backbone, cactustree, centrality, circlepack, dendrogram, eigen, fabric, focus, hive, htree, igraph, linear, manual, matrix, metro, partition, pmds, sf, sparse_stress, stress, treemap, unrooted

Reserved in waiting — in graphlayouts but not yet wrapped by ggraph. A name here resolves to autograph today and would be silently shadowed the day ggraph wraps it, because ggraph’s namespace is searched first. Treat them as taken:

multilevel, umap, dynamic, stress3D, constrained_stress, fixed_coords, centrality_group, focus_group, metromap, tree_unrooted

This is what retired multilevel in favour of levels.

Reserved by usalluvial is held for a plot of changing membership composition over time, not for a layout.

Check a candidate against all three namespaces before committing to it:

Rscript -e 'nm <- "yourname"; print(nm %in% c(sub("^layout_tbl_graph_", "", grep("^layout_tbl_graph_", ls(asNamespace("ggraph")), value = TRUE)), sub("^layout_(as|with|in|on)_", "", grep("^layout_", getNamespaceExports("graphlayouts"), value = TRUE)), sub("^layout_(as|with|in|on)_", "", grep("^layout_", getNamespaceExports("igraph"), value = TRUE))))'

Name the shape, not the meaning or the algorithm. A layout name is a single lowercase English noun for what the drawing looks like: layered, lineage, railway, ladder, concentric, valence. Never the algorithm or its author (sugiyama), and not a claim the data may not support — hierarchy was retired because a two-mode network has two layers and no hierarchy between them. Where a layout takes the attribute it is named for, keep the two in agreement (layout = "levels" takes level = "lvl").

A name is for the drawing; an argument is for the variation — but a common drawing earns a name. railway and ladder are alignment = "rungs" applied to layered and lineage, and they keep their names: making a user learn an argument to reach a common figure defeats the point of an auto-graph. Add a name when a user would look for that word; do not add one for every argument value. The counter-example is dyadhexad, which were retired because configuration already picks the one matching the number of nodes, so no user had a reason to type them.

Prefer overloading an argument to adding one. An argument that takes a keyword should also take a node attribute name or vector where that makes sense, as ranks= and node_size= do. That is how lineage absorbed a layout of its own that only differed in where the layers came from.

Adding a layout

  1. Export layout_<name>() and alias layout_tbl_graph_<name> <- layout_<name>.
  2. Tag it @family mapping and @template param_ggraphlayouts (man-roxygen/param_ggraphlayouts.R holds .data, circular, times and the return value, which every layout shares).
  3. Declare what it needs in .layout_requirements() (R/graph_checks.R), so graphr() can substitute and say why instead of failing downstream. Declare nothing where an argument could rescue the layout — .abort_layout_arg() asks for it.
  4. Add it to .layered_layouts() if its coordinates carry meaning along an axis that grid snapping would collapse.
  5. Add a mirroring test-layout_<name>.R. The functional audit in test-functional_layouts.R enumerates from the namespace, so it picks the layout up with no further change.

Retiring a layout name

  1. Add a shim to R/autograph-defunct.R under @rdname layout_deprecated, forwarding to the replacement after manynet::snet_warn().
  2. Add the name to .deprecated_layouts() and its replacement to .rename_layout(). .rename_layout() swaps the name once, where the layout is checked, so that every step after it — applicability, node sizes, tie alpha, labels — knows only the current name.
  3. Keep its .layout_requirements() entry, so the string is still validated before the swap.
  4. Nothing else needs updating: .deprecated_layouts() is what keeps the name out of the RStudio completions and out of the functional audit.

Plot-method dispatch (plot_*.R)

The rest of the package implements plot.<class> S3 methods so that plot(result) “just works” for objects returned by other stocnet/network packages, without the user needing to know which autograph function to call. Methods are grouped by the kind of result object, not by source package:

File Covers
plot_analysis.R node/tie/network measures, motifs, memberships (node_measure, tie_measure, network_measures, node_member, node_motif, network_motif, matrix)
plot_summaries.R diffusion/learning model summaries (diff_model, diffs_model, learn_model, mnet)
plot_gof.R goodness-of-fit objects (gof.ergm, sienaGOF, gof.stats.monan, autograph’s own ag_gof)
plot_diagnostics.R adequacy diagnostics and model fits, currently goldfish’s (goldfishOutliers, goldfishChangepoints, goldfishOnset, goldfishMargins, goldfishGOF, goldfishTimeTest, goldfishFit)
plot_convergence.R, plot_tests.R, plot_interp.R convergence traces, statistical tests, and interpretation plots for netlm/netlogit/ergm etc.
plot_manydata.R ‘many’ data plots; the whole file is commented out at present

New plot.* methods must be registered in NAMESPACE via roxygen @method/@export tags — run devtools::document() after adding one. Suggestions for new plot methods are welcome.

Class names across the stocnet ecosystem

S3 dispatch matches exact class strings, so two packages that emit the same class string collide: autograph cannot tell the objects apart, and neither can a user’s inherits() check. A name such as test_gof or margin_table is the name any sibling package would pick for the same idea, so it is not safe.

The rule for every stocnet package is: name a class after the package plus a noun, in camelCase (<pkg><Thing>), following RSiena’s sienaFit, sienaGOF and sienaAlgorithm. camelCase keeps a class visually distinct from the snake_case user-facing functions.

Two things the convention does not use:

  • No dot suffix. A dot in a class string creates no inheritance. R dispatches on exact class strings, and all S3 inheritance comes from the class vector, so foo.goldfish does not match an object of class "diagnose_outliers.goldfish". A suffix such as .goldfish is convention only.
  • No shared parent class. autograph draws a different figure for each diagnostic, so a fallback method would have nothing to do. autograph standardises by coercion instead, as it already does for other objects.

The goldfish diagnostic classes follow this rule. Five older class names remain in R/autograph-defunct.R as aliases forwarding to the renamed methods, so that an object classed the way an earlier autograph expected still plots: diagnose_outliers and diagnose_changepoints (the names goldfish 1.9.21 stamps), outliers.goldfish and changepoints.goldfish (the two the draft methods were written against), and result.goldfish (the fit class every goldfish stamps, back to the version on CRAN). An alias restores dispatch, not the old column contract: each forwards to a method that reads the current columns. Delete each alias once the oldest supported goldfish is past the rename.

Function names

Two naming families, and they do not mix:

  • User-facing functions are snake_case, and usually verb_noun: graphr(), match_color(), is_dark(), simulate_colorblind(), check_separation(), list_fonts(), stocnet_theme(). This is the convention across the stocnet suite, so a user meets one style everywhere.
  • The ag_ prefix is for the theme accessors only: ag_base(), ag_ink(), ag_highlight(), ag_positive(), ag_negative(), ag_qualitative(n), ag_sequential(n), ag_divergent(n), ag_font(). Each returns the autograph-specific value the current theme holds for one role, and each reads an snet_* option. Do not give ag_ to a function that does something else, even a small one: a new verb belongs in the snake_case family. Internal helpers may take ag_ where they build such a value (ag_ground(), ag_theme_*()).
  • check_* scores, .check_* guards: the exported check_span(), check_offset(), check_contrast() and check_separation() each measure a drawing and return the score, so that a user can compare one layout or palette with another. The private .check_layout(), .check_layout_applies(), .layout_applies() and .check_dup() validate an argument and abort or substitute, reading the tables .layout_requirements() and .deprecated_layouts(). The two do different jobs, so keep the dot: it is what tells them apart.

Theming

R/theme_palette_set.R implements stocnet_theme() (alias set_stocnet_theme()), which sets an R option (stocnet_theme, default "default") read by every plotting function in the package. Institutional and stylistic palettes (default, bw, crisp, neon, clay, iheid, ethz, uzh, rug, unibe, oxf, unige, cmu, iast, hwu, rainbow) are defined in R/theme_palette_set.R and exposed via the ag_ accessors listed above, documented together under ag_call. Users can override individual palette colours via options() (e.g. options(snet_highlight = ...)) rather than editing theme code. R/theme_match.R maps a plot/result object to its appropriate theme treatment.

Three roles, kept separate, because they pull in different directions: the base is an unhighlighted mark, and may be light where that is what separates it from a dark brand highlight; the ink (ag_ink()) is what a plot writes with, and must stay legible; the highlight is the brand colour. Reference lines, axis text, and other chrome take ag_ink(), never ag_base().

Every plot is drawn on the theme’s ground. Build plot themes with the ag_theme_*() wrappers in R/theme_palette_set.R (ag_theme_minimal(), ag_theme_void(), and so on) rather than calling ggplot2::theme_minimal() directly, so that a theme with a background other than white reaches every plot and not only the graphs.

R/theme_colorblind.R holds the colour-checking tools: simulate_colorblind(), check_separation(), check_contrast(), and the internal colorblind_sort() that each theme’s categorical palette passes through when the theme is set. The three answer three different questions and none substitutes for another: check_separation() asks whether two marks can be told apart (CIELAB distance, worst case across normal and colour-blind vision), check_contrast() asks whether text can be read on what it sits on (WCAG 2.1 relative luminance), and simulate_colorblind(type = "grey") asks whether either survives a photocopier. Greyscale is reported beside check_separation()’s score rather than folded into it: two colours that differ only in hue collapse in greyscale however well they serve a colour-blind reader, so a worst case that included it would condemn nearly every institutional palette. A palette added to a theme therefore does not need hand-ordering, but it does need to survive the audit in tests/testthat/test-functional_themes.R, which requires the first few colours to stay apart under each type of colour blindness. A palette whose own order carries meaning is exempted by adding it to colorblind_unsorted; "rainbow" is the only member, and is sampled across its length instead of taken from the front.

The medium is separate from the theme, and lives in R/theme_medium.R: stocnet_medium() says where a plot will be seen ("screen", "presentation", "mobile", "print"), not how it should look. It scales text through ag_size() and ag_text_size(), and "print" overrides the ground to white. Text set on a geom or on a theme element directly does not pass through base_size, so wrap it in ag_text_size(); marks are deliberately left unscaled, since a node’s size is relative to its layout.

Adding a theme or palette

  1. Add the name to theme_opts in R/theme_palette_set.R.
  2. Give it a branch in each set_*_theme() it needs: background, ink, highlight, divergent, categorical, font. Omitting one leaves the theme on that setter’s default, which is usually right. set_missing_theme() needs nothing: it derives ag_missing() from the ground and the palette.
  3. Store the categorical palette in the order colorblind_sort() gives it, not the order the brand guide lists it in. The test suite asserts that the stored palette is already a fixed point of the sort, so a hand-ordered palette will fail.
  4. Run tests/testthat/test-functional_themes.R. It holds every theme to: the first few categorical colours staying apart under each type of colour blindness, divergent poles that are not a red-green pair, a highlight pair that every viewer can separate, and ink that clears WCAG’s 4.5:1 on the theme’s own ground.

Reorder a palette; do not repaint it. An institution’s colours are that institution’s, and the point of colorblind_sort() is that the order is ours to choose while the colours are not. Where a brand colour genuinely cannot meet a floor — the "clay" and "oxf" highlights fall just under WCAG’s 3:1 — name the exception in the test rather than adjusting the colour or dropping the assertion.

Tools worth checking a candidate palette with before it is added:

  • ColorBrewer for whether a scheme should be qualitative, sequential or diverging, and for its colour-blind safe, print-friendly and photocopy-safe filters.
  • Viz Palette for seeing a set of hexcodes at once under each type of colour vision deficiency.
  • Datawrapper’s notes on colour in a data-vis style guide for why a palette needs to vary in lightness and not only in hue, and for the case for a de-emphasis colour (ag_missing() here).
  • The {GGenemy} and {colorify} packages, for auditing a finished ggplot2 figure and for generating candidate palettes respectively. Neither is a dependency.

Because autograph re-exports several ggplot2 symbols (see R/reexports_ggplot2.R), loading autograph last in a session is recommended so its plot() methods take precedence over other packages’.

Precooked/vignette data

data/*.rda holds pre-computed example results (ERGM/SAOM GOF, goldfish changepoints/outliers, MoNAn convergence/GOF, migraph diffs/regressions/tests) used in examples, vignettes, and tests, documented in R/data_precooked.R. inst/extdata holds a serialized ergm_res object loaded via load_ergm_res(), deliberately kept out of data/ and serialized rather than stored as a live object because namespace references inside ergm model objects don’t survive a plain save()/reload across package versions.

Dependencies

autograph Depends on manynet (network data structures and coercion) and Imports ggplot2 (>= 4.0.0), ggraph, graphlayouts, igraph, dplyr, and patchwork. ergm and RSiena are listed under Enhances (their plot.* methods are only invoked if those packages are installed and such results are passed in), and gganimate, gifski, ggforce, migraph, and netrics are Suggests-only, so code paths depending on them should guard with requireNamespace() (see the thisRequires() helper in R/autograph_utilities.R) or be skipped gracefully when unavailable.

The declared minimum of each stocnet dependency is the version on CRAN, so that CI can install it. Where autograph needs something that only a newer, unreleased version has, reach it through a shim in R/autograph_utilities.R rather than by raising the minimum. Test for the function with .ag_has_manynet() rather than for the version string, because a pre-release development build can carry the version without yet exporting the function. Call the function with getExportedValue() and not ::, because R CMD check resolves a :: call against the installed package and reports the newer name as missing even where the call is never reached. Delete each shim once the minimum is raised past the version that added the function.

Tests

tests/testthat/ uses testthat edition 3 with parallel execution (Config/testthat/parallel: true in DESCRIPTION). tests/testthat.R sets stocnet_theme("default") before running the suite so theme state doesn’t leak between runs. Test files are organised by the same grouping as the R/ source files (e.g. test-graphr.R, test-layout_layered.R, test-theme_match.R).

In addition, the test-functional_*.R files implement functional (family-enumerating) testing, mirroring the approach in manynet: layout algorithms, plot.<class> methods, palette accessors, and graphr()’s aesthetic arguments are enumerated automatically from the namespace and run over a fixture grid, so new layouts/methods/palettes are audited without writing new tests. Helpers live in tests/testthat/helper-functional.R. Non-conformant combinations are skipped with a greppable AUDIT [...] message rather than failed, so the audits double as a to-do list locally; CI sets AUTOGRAPH_STRICT_AUDIT: true so the same cases fail there instead. tests/testthat/helper-tutorials.R extracts and evaluates the code chunks of the learnr tutorials in inst/tutorials/, so tutorial code that errors or raises a deprecation warning fails the suite (rendering the tutorials themselves is deliberately not tested).

Tutorials and articles

The learnr tutorials in inst/tutorials/ are the source. vignettes/articles/*.Rmd are their static pkgdown twins, and are generated from them by data-raw/build_tutorial_articles.R. Never edit an article by hand: the next regeneration discards the edit, and prchecks.yml fails the PR for drift meanwhile.

After adding or changing functionality, ask whether a reader learning the package would meet it, and if so:

  1. Edit the tutorial in inst/tutorials/<tute>/*.Rmd. Add the new function to the topic it belongs to, in an exercise=TRUE chunk, with a sentence saying what it is for. New sections need an entry in that topic’s page-toc, and are worth a line in its closing “In brief” callout.
  2. Re-render the tutorial HTML in place (rmarkdown::render() on the tutorial .Rmd), and commit it.
  3. Re-run Rscript data-raw/build_tutorial_articles.R, and commit the regenerated article.
  4. Run testthat::test_file("tests/testthat/test-tutorials_autograph.R"), which purls and evaluates every chunk, so new tutorial code is tested.

Where the change is worth showing off rather than only teaching, it also belongs in README.Rmd — which is knit to README.md with devtools::build_readme(), never edited directly — and its figures land in man/figures/, from where the website serves them.

Website

The site is built by pkgdown from pkgdown/_pkgdown.yml and deployed from pushrelease.yml on a merge to main.

Every exported function must appear in the reference: index. A topic left out of it fails the build, so the site stops updating. Add a new function to the section it belongs to, or add a new section where it starts a family, and prefer naming the topic (theme_colorblind) over widening a starts_with() pattern. A helper that users are not meant to call takes @keywords internal instead. The reference: titles are also the headings used in NEWS.md (see below), so keep the two in step.

Check before opening a PR:

pkgdown::check_pkgdown()          # every topic is in the index
pkgdown::build_site(preview = FALSE)  # everything else

prchecks.yml runs both in the website-builds job, so a PR reports whether the site can be built without deploying it.

README figures

README.md is knitted from README.Rmd, and its figures are hosted on jameshollway.com rather than committed to man/figures. R installs man/figures into the installed package’s help directory, so a README figure left there is shipped to every user and counted by R CMD check’s installed size note. The figures once held 3.3Mb of a 5.2Mb installed package.

The published figures live at https://www.jameshollway.com/post/autograph/, which is served from content/post/autograph in that site’s own repository. Point AUTOGRAPH_SITE_DIR at your checkout of that directory:

export AUTOGRAPH_SITE_DIR=~/path/to/jameshollway.com/content/post/autograph

The setup chunk reads that variable, and sets have_site from whether the directory exists. Where it does not, as on CI and on CRAN, the figure chunks do not run, and README.md keeps pointing at the published copies. The knit is still correct; the figures are simply not refreshed.

A new figure needs three things:

  1. Write the figure into the site directory rather than into man/figures. A chunk whose code is hidden calls ggsave(site_figure("README-<name>-1.png"), ...) and takes echo = FALSE, eval = have_site. A chunk whose code is shown instead takes fig.path = site_prefix, fig.show = "hide", eval = have_site, which lets knitr write the file but suppresses the local link.
  2. Write the <img> tag into the prose by hand, with the published URL and an alt text. fig.alt cannot do this, since the chunk emits no link.
  3. Copy the figure into the site repository, then commit and deploy it there. The image 404s on GitHub until that deploy lands.

Knit with devtools::build_readme(), and check that grep 'man/figures/README' README.md finds nothing. Only man/figures/logo.png belongs in man/figures.

NEWS.md conventions

NEWS.md groups each version’s changes under ## headings that mirror the website function overview (pkgdown/_pkgdown.yml reference: titles). Lead with ## Package (package-wide/website/infrastructure changes), then the function families in overview order: ## Graphing (graphr/graphs/grapht and internal functions, ## Plotting (all plot.* methods), ## Layouts, ## Theming. Put ## Tutorials and ## Data near the end. Each heading appears at most once per version.

Start each bullet with a verb matching the change type:

  • Added ... — new functionality
  • Fixed ... — bug fixes; if it relates to a GitHub issue, suffix with (closing #123)
  • Renamed ... to ... — function or data name migrations
  • Improved ... — functional updates to existing behaviour
  • Updated ... — documentation changes

Any of these verbs can also lead a sub-bullet.

Keep every bullet to one line of fewer than 81 characters ideally (a few more or less is fine). If a bullet wraps, it holds too much: shorten it, or split it into a lead bullet and sub-bullets. Each bullet stands on its own, and states what changed, not why or how unless there is space for context. Explanation belongs in the function documentation or the vignettes.

Where several bullets describe parallel changes, reuse the sentence structure, so that a reader sees the parallelism at a glance. Use one word for one thing throughout a version’s entries, rather than varying the wording for effect.

If a cited GitHub issue was not authored by @jhollway, thank the author with an @-tag in the bullet. Cluster related changes (e.g. several fixes to the same function, or sub-points of one feature) as indented sub-bullets under a lead bullet, to improve readability. Where several changes concern one function, lead with an Improved ... bullet that names the function, and put the individual Fixed .../Added ... points beneath it, so the cluster groups by function rather than by change type. Under an Improved ... lead bullet, do not name the function again in the sub-bullets, since the lead bullet already carries it. Sub-bullets indent by two spaces, and nest at most one level further (four spaces). A sub-bullet does not need a verb: it can state the consequence, the previous behaviour, or an example call. The more entries a version holds, the more this structure matters, so group first and only then write the bullets.