This document describes the coding standards of the binary-ensemble workspace as they are actually
practiced in the code today. It is descriptive first (what the code does) and prescriptive second
(what new code should do to fit in). When in doubt, imitate the surrounding module.
The workspace is a Rust library + four CLI tools for compressing ensembles of districting plans (the
BEN / XBEN / BENDL formats), plus a PyO3 binding crate that ships the binary_ensemble Python
package.
A companion document, docs/glossary.md, is the source of truth for terminology.
This document covers mechanics. The two are meant to be read together: the glossary tells you
what to call a thing, this tells you how to write the code around it.
The repository is a Cargo workspace (resolver = "2") with two members:
ben/— packagebinary-ensemble, library namebinary_ensemble. Contains the codec, I/O, ops, JSON-graph, format, and CLI logic, plus two thin binaries (ben,bendl) underben/src/bin/.benis a subcommand tree (encode,decode,relabel,canonicalize,reencode,sort-graph,pcompress, ...);bendldrives the.bendlcontainer.ben-py/— packageben-py, cdylibben_py_core. PyO3 bindings that depend onbinary-ensembleby path and are published as thebinary_ensemblePython package.
Conventions:
- The version is shared. Both crates use
version.workspace = truefrom[workspace.package]; never set a per-crate version. Don't hard-code the version string anywhere in source or comments either — keep code self-contained and free of version pins. - Binaries stay thin, with a uniform
main. Eachben/src/bin/*.rsjust callscli::<tool>::run(), and onErrprintsError: {err}to stderr and exits non-zero. All real behavior lives in the library so it is testable without spawning a process. Each CLI module exposespub fn run() -> CliResult, parses aclap#[derive(Parser)]Args, and dispatches to per-mode handlers; CLI failures use the crate's ownCliError/CliResult, not bareio::Error. - The Python crate owns all PyO3. Nothing in
ben/depends onpyo3. Python concerns live entirely inben-py/.
- Edition 2021, MIT licensed. No pinned
rust-toolchain.toml; build on ambient stable. rustfmtwith an explicit config (rustfmt.toml):max_width = 100,comment_width = 100,wrap_comments = true. Comments are auto-wrapped to 100 columns — write naturally and letrustfmtreflow. Always runcargo fmt --all(ortask format-rust) before committing.- Quality gates are run locally via
Taskfile.yml(thetask/go-taskrunner), not in CI. The GitHub workflow (ci_cd.yml) only builds and publishes wheels withmaturin. Before pushing, run the same checks the maintainer does:task test— Rust fast suite +#[ignore]-gated slow suite + Pythonpytest.task format—cargo fmt --all+ruff formatfor Python.task lint—ruff checkfor Python.task coverage-*—cargo llvm-cov(thebin/wrappers are excluded from coverage; they're meant to be trivial).
- Python tooling is
uv+maturin+ruff+pytest. Develop the extension withtask ben-py-develop(runsmaturin developinside theuvenv). Format/lint Python withruff.
- Directory modules use
mod.rs. A module folder is fronted bymod.rs(e.g.codec/mod.rs,format/mod.rs,io/mod.rs), which declares the submodules and re-exports the module's common surface. - Re-export the public surface with
pub use. Parent modules flatten the names callers need (e.g.codec/mod.rsdoespub use frames::{BenDecodeFrame, BenEncodeFrame};;format/mod.rsdoespub use errors::FormatError;). Add new public items to the appropriate re-export rather than forcing callers down deep paths. - Every module opens with a
//!doc comment that says what the module is for and links siblings with intra-doc links (e.g.[`encode`],[`decode`],[`translate`]). Thepub moddeclarations inlib.rseach carry a///one-liner. - Errors live in
errors.rs. A module that defines its own error type puts it in a siblingerrors.rs(e.g.format/errors.rs,codec/translate/errors.rs,json/graph/errors.rs) and re-exports it frommod.rs. - Tests live next to what they test under
#[cfg(test)] mod tests. Small modules use a siblingtests.rs; larger ones use atests/subdirectory split by topic (e.g.codec/decode/tests/{standard,mkvchain,twodelta}.rs,io/bundle/tests/{reader,writer,format}.rs). Cross-cutting, process-level, and stability tests go inben/tests/integration files. - Shared test helpers go in
test_utils, declared#[doc(hidden)] pub mod test_utils;so they're reusable across the crate's test trees without polluting the public docs. - Guard platform assumptions explicitly.
lib.rsrejects non-64-bit targets withcompile_error!. Encode invariants you rely on rather than letting them fail silently.
- Model domain concepts as enums/structs with doc-commented variants. E.g.
BenVariant { Standard, MkvChain, TwoDelta }, each variant documented with what it stores and when it applies. - Push invariants into the type system when you can.
XBenVariantis a deliberately restricted subset (Standard,MkvChain) that cannot representTwoDelta, so functions parameterised byXBenVariantare uncallable for TwoDelta at compile time. Prefer this kind of make-illegal-states-unrepresentable design over runtimeassert!s. - Provide
From/TryFrombetween related types, and when a conversion can fail, return a dedicated, named error type (e.g.TwoDeltaNotXBenError) rather than a bare()or a string — even a tiny marker struct getsDisplay+std::error::Errorimpls. - Derive the obvious traits. Small value types carry
#[derive(Debug, Clone, Copy, PartialEq, Eq)](andSerialize/Deserializewhere they cross the JSON boundary). Give public types aDebugimpl by default. - Mark extensible public enums/structs
#[non_exhaustive]. Options and transform types that may grow new variants/fields (e.g.RelabelTransform,RunPolicy,RelabelOptions) are#[non_exhaustive]so adding to them isn't a breaking change. - Build complex options with a constructor-plus-
with_*builder.RelabelOptionsis created by an intent-named constructor (first_seen,node_permutation,convert_to) and then refined withwith_*methods, rather than exposing a wide public constructor or public fields. - Reserve unused bits/fields explicitly for forward compatibility (e.g. named
RESERVED_BIT_*constants) rather than leaving holes undocumented.
- Library errors are
thiserrorenums, one per module, inerrors.rs. Each variant has a descriptive#[error("...")]message that includes the relevant values (e.g.UnknownBannerprints the actual bytes seen and the expected set). Wrap source errors with#[from](e.g.Io(#[from] io::Error)). - Bridge domain errors to
io::Errorat streaming boundaries. The pattern is an explicitimpl From<DomainError> for io::Errorthat forwards a real IO error unchanged and maps everything else toio::ErrorKind::InvalidData. This lets streaming readers/writers keepio::Resultsignatures while still carrying precise error context. expect()only for genuinely infallible cases, with a message that states the invariant (e.g..expect("valid fallback log filter")). Avoidunwrap()/expect()on real IO, parsing, or caller-supplied data in library paths — return aResultand propagate with?.?is the default control flow for fallible calls. Reservepanic!/unreachable!for true logic invariants, not expected failures.
- Use
tracing, notlogand notprintln!. Emit diagnostics with thetracingmacros. In practice the codebase logs almost entirely attrace!(fine-grained internal flow) with the occasionalwarn!; reach for higher levels only when a message genuinely belongs there. - Subscriber init is centralized and idempotent.
logging::init_logging()sets the global subscriber exactly once viastd::sync::Once, readsRUST_LOG(defaulting tooff), writes to stderr, and uses a compact format with time/target/level/ANSI disabled. Don't stand up ad-hoc subscribers elsewhere. stdoutis for program output only (decoded data, version banners, inspect listings) — never for logging.stderrcarries logs and progress.- Long streaming operations report progress with
indicatif.
- Stream; don't slurp. The crate processes ensembles too large to hold in memory. Functions take buffered readers/writers and work frame-by-frame / line-by-line.
- Be generic over IO with
R: Read/R: BufReadandW: Writebounds so the same code serves files, pipes, in-memory buffers, and test fixtures. - Binary fields use
byteorderwith an explicit endianness — never rely on native byte order for on-disk data. - Integrity is checked with CRC32C (
crc32ccrate). That crate was chosen deliberately overcrc32fast(it can't be misconfigured into IEEE CRC-32); the rationale is recorded inben/Cargo.toml. Keep integrity checks on payloads and the assignment stream. - Preserve the lazy-decode property of BEN frames —
Standard/MkvChainframes keep raw bytes without eagerly unpacking runs, which is what makes frame-skip subsampling and lookup fast.TwoDeltareplay is snapshot-bounded and maintains its own incremental state. Don't introduce a unified frame representation that forces eager bit-unpacking on read.
Documentation is treated as part of the code, not an afterthought.
//!on every module,///on public items. Item docs use the conventional rustdoc sections already prevalent here:# Arguments,# Returns,# Examples,# Errors,# Panics. Format illustrations in docs use fenced```textblocks.- Comments explain intent and stay self-contained and timeless. Don't reference planning-doc
filenames, plan section numbers, or version numbers from source/inline comments. Pointing a reader
at the stable
docs/glossary.mdfor terminology is fine and done in the code; pointing at a transient plan is not. - Substantive design goes in
docs/. Architecture/context inCONTEXT.md; vocabulary indocs/glossary.md; the on-disk contract in the format spec; plans indocs/<topic>-plan.mdbefore implementation.
- Standard Rust casing:
snake_caseitems,UpperCamelCasetypes,SCREAMING_SNAKE_CASEconsts. - Names follow the glossary's lexicon exactly. This is an explicit, enforced standard: use
plan/assignment/sample/ensemble/variant/banner/frame/streamwith their glossary meanings, and the verbsencode/decode(nevercompress/decompress). Spell format names out in identifiers (ben,ben32,xben,bendl,jsonl). If prose and an identifier disagree, the glossary wins and the identifier is what changes. - Functions are named descriptively after their transform, encoding direction and operands. Two
suffix/affix conventions are consistent and worth following:
- Direction is spelled out with
_to_/_from_(decode_xben_to_jsonl,encode_jsonl_to_ben,ben_to_ben32_lines). - A
_pathsuffix marks the convenience wrapper that takes a file path over the streaming core (decode_ben_to_jsonlvsdecode_ben_to_jsonl_path). - An
_unverifiedsuffix marks the variant that skips integrity checks (asset_bytesvsasset_bytes_unverified). The default name verifies; the escape hatch is explicitly labeled. Prefer clarity over brevity.
- Direction is spelled out with
- Name magic values once as consts (banners, magic bytes, header sizes, asset-type/flag values); never inline a protocol literal at a use site.
- Property-based testing with
proptestfor invariants — above all the round-trip property (encode → decode reproduces the original assignments)..proptest-regressionsfiles are committed so found counterexamples stay covered. - Determinism in randomized tests: seed with
rand_chacha/ explicit seeds; uselipsumfor synthetic text. Tests must be reproducible. - Slow / stress tests are gated with
#[ignore]and run separately (cargo test -- --ignored, i.e.task test-rust-slow). Keep the defaultcargo testfast. - Filesystem tests must be hermetic — use temp files, never repo-relative scratch paths.
- Test the behavior, at the right layer. Unit tests live beside their module; format-stability,
CLI, and full-pipeline behavior live in
ben/tests/. The CLI is exercised end-to-end (test_cli.rs), and format stability is pinned by golden tests — treat an on-disk format change that breaks them as a deliberate, documented decision.
- All PyO3 code is isolated in the
ben-pycrate and built against the stable ABI (abi3-py311,extension-module). The core library has no Python dependency. - Match the prevailing PyO3 version's idioms (the bound API:
Bound<'_, _>,wrap_pyfunction!). Don't mix older and newer PyO3 styles within the crate. - Names align across the language boundary. Rust structs carry a
Pyprefix internally (e.g.PyBenEncoder) but are exposed to Python with the prefix stripped via#[pyclass(name = "BenEncoder")]; Python methods use the sameencode_*/decode_*verbs as Rust. - Spell out Python-visible signatures with
#[pyo3(signature = ...)]for defaults and a matching#[pyo3(text_signature = "...")]so the signature shows up in Python help/IDE tooling. - Map Rust errors to specific Python exceptions at the boundary via small private
map_*_errhelpers that match on the source error and pick the right exception (PyIOErrorfor IO,PyValueErrorfor bad input,PyKeyErrorfor missing keys,PyExceptionas fallback). A panic must never cross the FFI line. - Ship typing metadata: the package includes
py.typedand a_core.pyistub; keep the stub in sync with the exported surface. Python users import re-exported names from thebinary_ensemblepackage, not from_coredirectly. - Type the surface precisely, with shared aliases. Public payload shapes live in
binary_ensemble.types(GraphInput,StrPath,Variant,SortMethod, the asset-payload unions, and theNodePermutationMap/AssetEntryTypedDicts) and are used by the facades and every.pyistub — noAnywhere the accepted shapes are known. Use modern hints (X | None, builtin generics,collections.abc); the floor is Python 3.11. Type checking is two-stage —tythenpyright(task typecheck-python, part oftask lint) — andtests/typing_assertions.pypins the surface from the consumer side:assert_typefor positives, bare# type: ignorefor calls that must NOT type-check (kept honest by pyright'sreportUnnecessaryTypeIgnoreComment). - Python-visible docstrings document every argument (facade
.pyfiles and the Rust///docs alike, Google style): eachArgs:entry carries its type in parentheses — the shared alias name where one exists, e.g.graph (GraphInput):— with custom-type shapes spelled out in the description. Defaulted parameters are marked(<type>, optional)and state the default as "Default isX." — or, whenNoneis meaningful, "Default isNonewhich ⟨meaning⟩."
- Conservative, single-purpose crates, each justified. Current set includes
byteorder(explicit-endian binary IO),crc32c(integrity),xz2(LZMA2 for XBEN),serde/serde_json,clap(derive CLI),indicatif(progress),petgraph+rustworkx-core(graph ordering for relabeling),pipe(in-memory streaming),pcompress(foreign-format bridge),thiserror, andtracing/tracing-subscriber. Dev-only:proptest,lipsum,rand+rand_chacha+rand_distr. - Record non-obvious choices in
Cargo.toml. Thecrc32c-vs-crc32fastdecision is documented inline; do the same for any future "why this crate" decision. - Prefer reusing a present dependency over adding a new one.
-
cargo fmt --allclean;task testgreen (fast +--ignored+ Python);ruff check/ruff formatclean for any Python. - New public items have
//!////docs with the standard sections; comments are self-contained (no plan/section/version references). - Errors are
thiserrorenums inerrors.rswith informative messages; boundaries bridge toio::Error; no strayunwrap()/expect()on real IO or input. - Diagnostics via
tracing(stderr); program output only on stdout; progress viaindicatif. - Streaming over buffered, generic IO; explicit endianness; BEN frame lazy-decode preserved; integrity (CRC32C) intact.
- Identifiers match
docs/glossary.md; magic values named as consts. - Round-trip / invariant covered by
proptest; randomness seeded; slow tests#[ignore]d; temp files for FS tests. - PyO3 changes stay in
ben-py, keepabi3, map errors to typed Python exceptions, and update the_core.pyistub.