Design decisions¶
This page records the design decisions behind CatSmoothing, the mistakes that shaped them, and the questions still open. Contributors and coding agents check it before proposing a design change and update it in the same change that makes or reverses a decision.
Maintaining this page¶
- Search here before deciding. If a decision (
D-) or lesson (L-) covers the case, follow it, or argue against it with evidence it does not already account for. - A new decision gets a
D-entry with its date. A mistake worth not repeating gets anL-entry that names the test or CI job catching it, since a comment or a manual step fires on nothing. - To reverse a decision, set the old entry's status to
superseded by D-NNand keep its text. The record of what was tried is the point of this page. - An answered question (
Q-) becomes aD-entry, and the question's status becomesanswered by D-NN. - Some decisions have a one-line summary under "Design rules" in
AGENTS.md. Update that line in the same change. - Never renumber. New entries take the next free number.
- D-01 and D-02 predate this page and carry no dates.
Each decision lists its status, the decision, why, and what was rejected or its limits.
Scope¶
CatSmoothing smooths Shapely LineString, LinearRing, Polygon, and MultiPolygon
geometries with Catmull-Rom splines and computes tangent angles at vertices. A Rust
crate in src/ holds the spline math, the PyO3 bindings in src/python.rs expose it as
catsmoothing._catsmoothing, and catsmoothing/api.py wraps the bindings with
Shapely-aware functions. NumPy and Shapely are the only runtime dependencies.
Decisions¶
D-01: Rust core behind PyO3 bindings¶
- Status: accepted.
- Decision: The spline math is a Rust crate that maturin builds into
catsmoothing._catsmoothing. The crate is not on crates.io; only the bindings call it. - Why: Speed, and Rayon runs batches of lines in parallel.
- Limits: The Rust API has one caller, so Google's library API rules apply to it as recommendations (D-16).
D-02: Centripetal parameterization by default¶
- Status: accepted.
- Decision:
smooth_linestrings,smooth_polygon, andlinestrings_tangent_anglesfit withalpha=0.5.CatmullRomtakes anyalphaor an explicitgrid. - Why: Centripetal Catmull-Rom curves have no cusps or self-intersections within a segment (Yuksel, Schaefer, and Keyser, 2011).
D-03: Inline segment coefficients, serial evaluation for small requests¶
- Status: accepted (2026-10-22).
- Decision: Each segment stores its cubic as power-basis coefficients and evaluates
with Horner's rule. A request below 4096 points (
PARALLEL_MIN_POINTSinsrc/splines.rs) evaluates serially. - Why: Building a 2,000-vertex spline got about 6x faster, and smoothing 2,000 lines
of 100 vertices about 2x faster (
CHANGELOG.md, 0.5.0).
D-04: Resample at equal arc length¶
- Status: accepted (2026-10-22).
- Decision:
uniform_distances,smooth_linestrings, andsmooth_polygonplace points at equal arc length. Arc length comes from 16-point Gauss-Legendre quadrature, bisected until each piece agrees with its halves to 1e-6 relative; the inverse takes Newton steps inside a shrinking bisection bracket. - Why: 0.4.1 iterated toward equal chords and, within 100 iterations, converged on none of 2,000 noisy test lines.
- Rejected: The fixed-point chord iteration, and with it the
toleranceandmax_iterationsarguments (D-06). - Limits: Spacing is equal along the curve; chords are shorter wherever the curve bends between two points.
D-05: distances caps the gap in coordinate units¶
- Status: accepted (2026-10-22).
- Decision:
distances(anddistanceinsmooth_polygon) is the largest allowed arc length between output points, in the coordinates' units. A line of lengthLgetsceil(L / d) + 1evenly spaced points. - Rejected: 0.4.1's comparison against the centripetal parameter, which counted
intervals as points and returned 2 points for
distances=10on a 100-unit line.
D-06: Deprecated arguments stay accepted and warn¶
- Status: accepted (2026-10-22; keyword-only since 2026-10-23).
- Decision:
toleranceandmax_iterationsare keyword-only, ignored, and raise aDeprecationWarningwhen passed. They go in a later release. - Why: D-04 removed what they controlled, and the warning gives callers a release to drop them.
D-07: Caller input errors raise ValueError, never a panic¶
- Status: accepted (2026-10-22).
- Decision: Rust validates every input a caller controls and returns
Err(SplineError), which the bindings map toValueError.panic!andexpectguard internal invariants only. - Why: A Rust panic reaches Python as
PanicException, which callers cannot catch as a normal error, and 0.4.1 panicked or returned NaN on several bad inputs.
D-08: Repeated consecutive vertices are dropped¶
- Status: accepted (2026-10-22).
- Decision: When the grid is generated (no explicit
grid, oralphaoverriding it), consecutive duplicate vertices are dropped before fitting, without a warning. - Rejected: Raising
ValueError, and keeping them, which produced all-NaN output in 0.4.1.
D-09: Vertices are 2D¶
- Status: accepted (2026-10-22).
- Decision: Vertex arrays must have exactly two columns; anything else raises
ValueError. - Rejected: Dropping extra columns, as 0.4.1 did without a word.
D-10: Gaussian pre-smoothing by point reflection and ring wrapping¶
- Status: accepted (2026-10-22).
- Decision: An open line is extended past each end by point reflection through its
end vertex; a closed ring wraps around. A sigma of at least one period of the
extended line returns the kernel's limit (the chord for open lines, the centroid for
rings).
sigma_units="length"measures sigma along the input line and weights each vertex by the length it stands for. - Why: Point reflection keeps evenly spaced straight runs in place, and wrapping smooths the seam vertex like any other.
- Rejected: 0.4.1's mirror reflection, which moved vertices near the ends along the line, and its pinned seam vertex.
D-11: Taubin smoothing with lambda 0.5 and mu -0.53¶
- Status: accepted (2026-10-22).
- Decision:
taubin_iterationsruns that many lambda/mu step pairs on the vertices before fitting. It cannot be combined with a Gaussian sigma. - Why: Gaussian smoothing shrinks a curve; Taubin's inflating step keeps the size of bends drawn with enough vertices.
- Limits: A shape drawn with about a dozen or fewer vertices per turn still shrinks, and shapes grow slowly as iterations add up. The what's new notebook measures both.
D-12: G2 and C2 continuity through continuity-enhancing splits¶
- Status: accepted (2026-10-22).
- Decision:
continuity="g2"or"c2"splits each piece into three cubics at a quarter of its span (Yuksel, "Continuity-Enhancing Degree Elevation and Splits", SIGGRAPH 2026). Every vertex and its tangent stay. The default is"c1". - Why: C1 Catmull-Rom curves can jump in curvature at every vertex.
D-13: The last point is the last vertex¶
- Status: accepted (2026-10-22).
- Decision: Evaluation at the last knot returns the last vertex exactly. A closed curve treats its last vertex as a repeat of the first when the two lie within 1e-9 of the ring's bounding-box diagonal.
- Why: Rounding at projected-CRS magnitudes left smoothed rings open, and Shapely then appended an extra closing vertex.
- Rejected: 0.4.1's per-coordinate test with a tolerance of 1e-5 of the coordinate value. At UTM magnitudes it takes points up to 5 m apart in easting and 40 m in northing as one, so it dropped a real vertex that close to the start.
D-14: Batches cross the boundary once, without the GIL¶
- Status: accepted (2026-10-22).
- Decision: Batch functions pass every line's coordinates as one stacked array plus
offsets, release the GIL while Rust works, and run on Rayon's global pool, so
RAYON_NUM_THREADSapplies. With one thread in the pool, they andevaluaterun serially on the calling thread, and the CodSpeed job setsRAYON_NUM_THREADS=1. - Why (one thread): Handing work to Rayon blocks the caller in a futex, which CPU simulation excludes and flags as dominated by system calls. Serial runs give CodSpeed the same work without the futex waits. CodSpeed's walltime instrument needs macro runners, which need an organization-owned repository. Local walltime is the same either way (11.3 ms for 500 lines of 100 vertices).
- Rejected: One array per line, and a custom thread pool built on first use, which
ignored
RAYON_NUM_THREADS.
D-15: Python 3.13 to 3.15¶
- Status: accepted (2026-10-23).
- Decision:
requires-python = ">=3.13", tests run on 3.13 and 3.15, and wheels build for 3.13 to 3.15 with PyO3 0.30.
D-16: Style comes from Google's Rust guide, enforced by tools first¶
- Status: accepted (2026-10-23).
- Decision: The
[lints.clippy]table inCargo.tomlandclippy.tomlenforce the rules of the Google Rust style guide that clippy can check;.agents/skills/rust-style/SKILL.mdholds the judgment rules that apply to this crate. Python uses ruff withselect = ["ALL"]and NumPy docstrings. - Why: A lint costs no agent context and fails CI. The skill loads only when an
agent works on Rust: Claude Code through the path-scoped rule in
.claude/rules/rust-style.md, Codex and Gemini through the skill listing. - Rejected: Google's Python guide, which would replace every NumPy docstring, and copying the full Rust guide (about 18,000 tokens) into agent context.
D-17: AGENTS.md is the single instruction file¶
- Status: accepted (2026-10-23).
- Decision:
AGENTS.mdis the contributing guide for people and agents, and the docs render it. Skills live in.agents/skills/, with.claude/skillsa symlink to it.CLAUDE.mdis git-ignored. - Why: Codex and Gemini read
AGENTS.md, and Claude Code reads it natively when noCLAUDE.mdexists. A probe on 2026-10-23 showed Claude Code and Codex both listing therust-styleskill.
D-18: Development tools live in dependency groups¶
- Status: accepted (2026-10-23).
- Decision: Test, lint, docs, typecheck, benchmark, and dev tools are PEP 735 dependency groups, which pixi maps to features.
- Rejected: Optional dependencies, which published them as installable extras.
D-19: Releases tag a matching version; git-cliff writes the notes¶
- Status: accepted (2026-10-23).
- Decision: The version is set in both
pyproject.tomlandCargo.toml, andpixi r release vX.Y.Zrefuses a tag that does not match both or a commit not onorigin/main. The release workflow publishes a release'sCHANGELOG.mdsection when one exists and git-cliff's notes, with the tag summary, otherwise.CHANGELOG.mdis hand-written through 0.5.0; after each later release,pixi r changelog-updateprepends that release's git-cliff entries below its preamble. - Rejected: Rewriting
CHANGELOG.mdfrom commit history, which would replace the hand-written entries.
D-20: Example notebooks are paired with scripts¶
- Status: accepted (2026-10-23).
- Decision: Each notebook in
docs/examples/is paired with a jupytextpy:percentscript and committed with its outputs.pixi r nb-runsyncs the pair and executes the notebooks against a release build. ssort skips the folder, and pretty-format-json skips every notebook. - Why: Agents and reviewers edit and diff the script. The docs show stored outputs and never execute a notebook.
D-21: The what's new notebook compares against recorded 0.4.1 outputs¶
- Status: accepted (2026-10-23).
- Decision:
docs/examples/data/make_v041_data.pyruns the released 0.4.1 wheel and saves its outputs tov041.npz. It refuses to run on a newer build, and the notebook refuses to run on 0.4.1. - Rejected: Importing both versions in one process, and prose-only comparisons.
D-22: Pull requests are the only way into main¶
- Status: accepted (2026-10-23).
- Decision: A GitHub ruleset blocks direct pushes, force pushes, and deletion of
main, and requires the CI jobs named inAGENTS.mdto pass before a merge.
D-23: Actions are pinned to commit SHAs¶
- Status: accepted (2026-10-23).
- Decision: Every
uses:in.github/workflows/names a full commit SHA with its version in a comment, which Dependabot updates, and every checkout setspersist-credentials: false. - Why: A moved tag cannot change the code that runs with release permissions, and no later step can push with the checkout's token.
D-24: Documentation site on zensical and GitHub Pages¶
- Status: accepted (2026-10-23).
- Decision: The site builds with zensical from
zensical.tomlin the pixidocsenvironment, which installs no CatSmoothing build: mkdocstrings reads the sources.pixi r docsconverts the example notebooks to Markdown pages without running them and runs a strict build..github/workflows/docs.ymlruns it on every PR and deploysmainto GitHub Pages at https://cheginit.github.io/catsmoothing/. The home, changelog, contributing, and license pages includeREADME.md,CHANGELOG.md,AGENTS.md, andLICENSE, andzensical.tomlhas nonavkey, so the nav mirrorsdocs/. - Why: It matches water-ogcapi (Q-01). Each page has one source, and the strict build fails on a missing snippet or a broken internal link. A page missing from an explicit nav still builds without a warning, so an explicit nav would hide a new notebook.
- Rejected: mkdocs with mkdocs-jupyter on Read the Docs, which compiled the Rust extension for every docs build.
- Limits: zensical was at 0.0.72 when adopted, so its config keys may change. The strict build does not catch a relative link in an included file. Pages deploys only once the repository's Pages source is GitHub Actions, and the Read the Docs project has to be retired by hand.
D-25: Release builds strip through the Cargo profile¶
- Status: accepted (2026-10-30).
- Decision:
[profile.release] strip = trueinCargo.tomlstrips the extension, and[tool.maturin]sets nostrip. The CodSpeed job setsCARGO_PROFILE_RELEASE_STRIP=false. - Why: A stripped extension shows every Rust frame in CodSpeed's flamegraphs as
Unknown symbol. maturin'sstrippasses-C strip=symbolsto rustc, which overrides the Cargo profile, so no job could keep symbols. The x86_64 CPython 3.13 wheel's.sostays 780,912 bytes either way. - Rejected:
CARGO_PROFILE_RELEASE_DEBUG=trueand-Clinker-features=-lldin the CodSpeed job (PR #59): maturin stripped both builds, so the profiled.sohad no.symtab.
Lessons¶
L-01: A probe of the released wheel can import the checkout¶
python -c puts the current directory on sys.path, so a probe of the 0.4.1 wheel run
from the repository root imported the checkout's catsmoothing/ and reported the new
behavior. It made the changelog's 0.4.1 ring-vertex bug look unreproducible until a
build of the v0.4.1 tag, run from outside the repository, showed it. Check: run such
probes from outside the repository and print catsmoothing.__file__.
L-02: mdformat without its frontmatter plugin breaks skills¶
mdformat turned the YAML frontmatter of a skill and a Claude rule into a heading and a
list, and both silently stop loading that way. Check: mdformat-frontmatter is in
.pre-commit-config.yaml; re-parse frontmatter after any automated rewrite.
L-03: maturin develop builds the profile [tool.maturin] names¶
preinstall runs maturin develop --uv, and profile = "release" under
[tool.maturin] makes that an optimized build. This entry first called it a debug
build, which no commit since April 2025 has produced. Check: the build log's
Finished line names the profile.
L-04: ssort can move code between notebook cells¶
ssort sorts a jupytext script as one module, so a function in one cell that reads a
variable from a later cell pulls that statement up. Check: ssort excludes
docs/examples/.
Open questions¶
Q-01: Move the docs to zensical and GitHub Pages¶
- Status: answered by D-24.
- water-ogcapi builds with zensical and deploys to GitHub Pages. CatSmoothing built with mkdocs and mkdocs-jupyter on Read the Docs.