- Write clean, readable code. Prioritize clarity over cleverness.
- Functions should be short (aim for <20 lines in the body). If a function does two things, split it into two functions.
- Names must be descriptive and intention-revealing. No abbreviations unless they are universally understood in the domain (see Files and Naming for how a domain abbreviation is adopted).
- Names must be readable at the CALL SITE, not only at the declaration.
self.__iteration_factory.build(...)narrates itself;self.__factory.build(...)forces the reader to click through to learn "factory of what?". Qualify a member's role whenever the bare noun leaves that question open. - One narrated step per line: when a chained call's intermediate value has a
meaningful name, split the chain and name it
(
iteration = factory.build(...)thenreport = iteration.run()), so a traceback points at the step that failed. Chains stay fine when the intermediate is nameless (text.strip().lower()). - No magic numbers or string literals. Extract them into named constants or config fields.
- Prefer explicit over implicit. Never rely on side effects for control flow.
- No commented-out code. Remove dead code; git history preserves it.
- Type-annotate every function signature, every return type, and every class attribute. No exceptions.
- Use modern syntax:
list[X],dict[K, V],tuple[X, ...],X | None(notList[X],Optional[X]). Writetorch.Tensor, not bareTensor. - Use
X | Noneonly whenNoneis a semantically meaningful value, not as a default-avoidance pattern. - Use
ProtocolorABCfor structural/nominal typing of interfaces. - Generic classes use
Generic[T]with meaningful type variables.
- Class members are private (
__prefix) by default, protected (_prefix) when a subclass needs them, and exposed only through@propertygetters. Provide setters only when mutation is explicitly part of the design. Frozen dataclass fields are the exception: immutability already protects them, so they stay plain public fields. - Use
@property(not methods) for structural attributes that describe what an object IS (e.g.,n_features,input_dim). The()on a method call implies an action or non-trivial computation. - Use
@dataclass(frozen=True)for all value objects and configuration. Mutable dataclasses need strong justification. - Use
ABC+@abstractmethodto define interfaces. Concrete implementations inherit from these. - Use
Generic[T]on ABCs when subclasses are parameterized by a type (e.g., a factory generic over its params dataclass). Expose the concrete type via a@propertyreturningtype[T]so callers can construct instances without knowing the concrete subclass. - Use inheritance when two classes share behavior that varies in one dimension: a base class holds the shared logic and an abstract method the varying part. Use composition when the relationship is "has-a".
- Use
enum.Enum(orenum.StrEnum) for categorical choices, never raw strings. - Each class has a single responsibility. If it does two unrelated things, split it.
- Implement
__repr__on all public-facing classes (automatic for dataclasses).
- Functions do one thing. If the name contains "and", it probably does two.
- Separate commands from queries, and let the VERB disclose the effect. A
computational verb (
estimate_,compare_,assemble_,derive_) means no effect beyond the return value; a method that persists or mutates says so (record_,write_,save_,submit_,add_,narrow_to). Never use the functionalwith_*prefix (returns a modified COPY) for an in-place mutation; a caller who kept the original gets silent corruption. - Prefer pure functions (no side effects, deterministic) wherever possible. "Pure" is about effects, not placement: see No free-floating functions for where logic lives.
- Limit function arguments to 5 or fewer. If more are needed, group them into a config or dataclass.
- Use keyword-only arguments (after
*) for any argument that is not self-evident from position.
- Raise specific exceptions with informative messages. Never use bare
except:orexcept Exception:without re-raising. - Validate inputs at public API boundaries. Internal functions can trust their callers.
- Use custom exception classes for domain-specific errors (inherit from the appropriate built-in).
- Every validation message tells the caller how to fix the problem; see Validation errors must contain the remediation.
- Organize code into clearly separated packages and sub-packages. Each package represents a coherent domain concept.
- Every package has an
__init__.pythat explicitly exports its public API via__all__. - Avoid circular imports. If two modules need each other, extract the shared abstraction into a third module.
- Keep module files focused. A module with more than ~300 lines likely needs splitting.
- One module per substantial class, named after it:
IterationFactorylives initeration_factory.pyand is tested intest_iteration_factory.py. Small definitions that exist only in service of that class (its params dataclass, a local enum, a domain exception, a private helper) live in the same module. - Module basenames are unique across the whole package, so editor tabs,
tracebacks, and grep are unambiguous. Do not rely on the package path to
disambiguate:
heart_rate/features.pybesidebreathing_rate/features.pyis two files calledfeatures.py. - When repeating the domain word makes names long
(
heart_rate/heart_rate_features.py), shorten it with an abbreviation that is standard in the domain and use that abbreviation consistently in package, module, and class:hr/hr_features.pyholdsHrFeatures,br/br_features.pyholdsBrFeatures. Expand the abbreviation once in the package's__init__.pydocstring. An abbreviation adopted this way is the "universally understood in the domain" exception to the no-abbreviation rule; it is never introduced ad hoc in a single identifier. - Falling back to the package path (
heart_rate/features.py) is acceptable only when no standard abbreviation exists. Never mix both styles within one package.
These are hard requirements, not style preferences. They prevent the design drift that creates bugs and technical debt.
Ask: "what is this thing mathematically / conceptually?" The fields and methods match that answer, nothing more. If a mathematical object is defined by X and Y, the class contains X and Y. Do not add Z because some algorithm that consumes the object needs Z; that algorithm derives Z from X and Y, or Z lives on the algorithm's side.
The test of a class design: how hard is it to construct or use one WRONGLY, with inconsistent fields, out of context, or bypassing the invariants its consumers rely on? Prefer designs where the illegal state cannot be expressed at all over designs where it is merely documented away.
Two kinds of data-bearing classes get different constructor policies:
- A value object's meaning is entirely in its fields: any combination
that passes validation is legitimate, whoever built it. These are
@dataclass(frozen=True)with an OPEN constructor plus a__post_init__that enforces every internal invariant (shape agreement, ranges, cross-field consistency). - A result object additionally CLAIMS provenance ("this is what the
fit/parser/resolver produced") and its fields can be individually valid
yet jointly a lie. Close the front door: construction goes through the
producer or a named factory (
between(...),of_result(...),parse(...)); fields are keyword-only; and where the blast radius warrants it, use a plain class with protected members behind read-only properties, so no open constructor exists at all.
Rules that follow:
- Invariants live on the TYPE, never only in the factory. A check
performed in
from_x()but not in__init__/__post_init__is a check every direct construction silently skips. Factories DERIVE; the type ENFORCES. - If a docstring needs to say "construct directly only in tests", the
constructor wants to be closed: either enforce the invariants in
__post_init__so direct construction is safe, or remove the open constructor. - Make fields keyword-only (
kw_only=True) whenever two fields share a type; positionally swapped same-type arguments are exactly the silent misuse this section prevents. - Do not reach for producer-only construction by default: it costs the
generated
__eq__/__repr__,dataclasses.replace, and cheap test fixtures. Reserve it for objects whose claim cannot be validated from their own fields and whose misconstruction would flow into artifacts or decisions undetected. Where such a class must still evolve (a resumed fit extending its history), give it a NAMED evolution method rather than leavingreplaceas the mutation surface.
A domain object has two kinds of information: what it IS (structure) and what it DOES (behavior). Represent them separately, and do not conflate derived properties with defining ones.
- If two properties seem related but can diverge in edge cases, they are separate concepts and are stored or computed independently.
- Properties computable from defining properties are derived
@propertymethods, not redundant stored fields. If computation is expensive, cache.
The values that define what an object IS are distinguished from the inputs it receives at execution time.
- A neural network is defined by its architecture and weights. The input
tensor is an argument to
forward(), not a constructor parameter. - A domain model is defined by its structure and parameters. Runtime inputs (initial states, time spans, solver settings) are passed at the call site, not stored in the same config or params object.
- A factory's params dataclass contains only defining parameters.
This separation prevents conflation in sampling, serialization, and composition.
If a function constructs something, transforms something, or dispatches on type, it belongs as a classmethod, factory method, or method on an enum. Free functions are acceptable only for pure stateless utilities (math helpers, tensor manipulation).
- A factory that selects a subclass based on an enum value is a method on the enum, or a classmethod on the base class.
- A function that closes over parameters and returns a callable becomes a
callable class with
__call__, so the captured parameters are inspectable via properties. Raw lambdas are acceptable only for throwaway one-liners in tests or examples, never in production code paths.
When a function is parameterized (e.g., a scoring function with coefficients), the parameters are captured at construction time, not passed at every call site.
- Bad:
score_fnandscore_paramsas parallel fields, params passed to fn at every evaluation. - Good: the scoring callable captures its params; the call site invokes
score(state)with no extra arguments. The callable is a class (not a lambda) exposing a.paramsproperty for serialization and debugging.
dict[str, float] or dict[str, Any] configuration gives the caller no
type checking, no autocompletion, and no safe refactoring. A misspelled key
(k_prodd instead of k_prod) is a runtime KeyError, not a type error.
- More than two related parameters that could be misspelled are grouped into a frozen dataclass with named, typed fields.
- A factory that constructs objects from a parameter set takes a typed dataclass, not a dict, and is generic over the params type.
- The single place where string-keyed dicts become typed params is a
dedicated
from_dict()/params_from_dict()method. No other code indexes into a params dict by string key. - Modules sharing parameter values need no special "sharing" machinery; Python variable binding of typed dataclass fields is sufficient.
If a tensor has semantic slots (params[0] = rate_constant, params[1] =
k_m), the mapping is defined in exactly one place: a frozen dataclass with
to_tensor() / from_tensor(). No code outside that dataclass indexes into
the tensor by position.
A constant intrinsic to a specific class is a ClassVar on that class, not
a module-level variable. Module-level constants are only for genuinely
module-wide values (a logger, a global registry). When several classes in
one module each have structural constants, module-level placement leaves it
ambiguous which constant belongs to which class. Constants callers might
customize are exposed through the constructor with the ClassVar as the
default.
When a dimension, count, or schema is determined by one module and consumed by another, define it once and have every consumer derive from it. Never duplicate a constant across files, even if both agree today.
- If a feature vector has N channels, define an Enum listing the channels in
the module that constructs the vector and export
N = len(TheEnum). Consumers import N. - Never store structural constants (dimensions dictated by the data format) in config. Config is for hyperparameters the user chooses; a dimension dictated by the code is a derived property.
- If adding a channel requires changing more than two locations (the enum + the builder), the abstraction is leaking.
- Co-locate field metadata (valid range, unit, description) with the field
via
dataclasses.field(metadata={...}). A field name that appears in the dataclass and again in a separate metadata dict keyed by string is a duplication bug waiting to happen.
Modules depend on interfaces, not on each other.
- A downstream consumer (solver, encoder, renderer) accepts generic inputs (matrices, tensors, callables), not a domain object. The domain object provides what the consumer needs via a method; the consumer never imports the domain class.
- A neural network encoder consumes a tensor representation, not a symbolic domain object. The symbolic-to-tensor conversion happens at an explicit boundary (a dedicated converter module), not inside the encoder.
When two modules disagree on a convention (frame, units, schema), the fix is a translator class at the boundary, not a patch to either endpoint. The endpoints stay correct in their own domain; the translator is the only place that knows both. Even when an endpoint could internalize the conversion via a flag, prefer the external translator so the boundary is named.
- Translators carry the assumption in their name (
MetricToImperialAdapterwrapping a metric sensor for an imperial consumer), so the cost of the translation is visible at the call site. - A configurable convention on one endpoint is a smell: it becomes a global setting propagating through every call. Multiple thin translators are healthier.
- The endpoint to leave alone is the one correct in its own domain: a third-party library staying bit-exact with upstream, a data source faithfully representing its format.
When two sibling packages need the same helper, move it to a shared, neutral module rather than reaching across package boundaries or duplicating it.
- A library package never imports from an application package. If a library needs something an application provides, that something is mis-located.
- Inlining a small helper into a second consumer to avoid the refactor is technical debt: the day the two copies diverge silently, the bug ships.
- "Neutral" means the module sits at or above the level of every consumer
in the dependency graph, e.g.
<project_root>/<helper>.py, not inside one of the siblings.
Feature slices (one package per feature, each owning its types, logic, and API) work only when features are independent. When components exchange data, form a pipeline, or feed back into each other, feature-slicing forces them to import one another and the graph tangles. Decompose by layer:
- Shared data types and contracts go down into a core layer everything depends on.
- Shared algorithms go down into a layer written against the core types only.
- The wiring that composes components into a pipeline goes up into an orchestration layer.
- Feature/domain modules sit in between, each depending only on layers below it, never on a sibling.
A feedback loop in data flow must never become a cycle in the code dependency graph. If A produces what B consumes and B produces what A consumes, define the exchanged objects in the core layer; both depend on core; an orchestrator on top closes the loop. The whole graph is a DAG. Decomposition test: can two components be built and tested without importing each other? If not, the shared part has not been pushed far enough down. Add an import-linter to CI so a layer violation fails the build.
A leading underscore means internal to its module. Other modules never import it.
- To dispatch on the type of an object from another module, use a protocol
(
hasattrcheck), a registry keyed on a public type (e.g., the params dataclass), or a method on the object itself. Neverisinstanceagainst a private class from another module. - If you find yourself importing
_FooBarfrom another module, stop. Either make it public and commit to the interface, or redesign so the import is unnecessary.
More than 10 lines of notebook logic duplicating a library class (e.g., a training loop that mirrors the Trainer) is a bug. Call the public API; if the API does not support what the notebook needs, extend the API.
- Trainer returns a result object that notebooks can plot directly.
- A custom training variant subclasses the Trainer or passes configuration; it does not copy-paste the loop.
If a loss requires M >= 2 samples to compute variance and receives M = 1, it
raises ValueError; it does not return zero. Silent fallbacks hide bugs.
- Never return a zero tensor as a "safe default" for a loss that cannot be computed. That is a gradient dead zone invisible during training.
- Never silently squeeze, unsqueeze, or reshape tensors to make shapes match. Raise a clear error stating expected and actual shapes.
- When dispatching on a capability via
hasattrorProtocol, the branch for an object lacking the capability raises unless the fallback behavior is provably correct. A plausible but incorrect result is worse than a crash.
When a class rejects an input, the exception message tells the caller
exactly how to fix it. "X is not Y" is insufficient; "X is not Y. Wrap it
in Z(...)." is the minimum bar. This matters most at numerically critical
boundaries (frame conventions, units, schemas), where the alternative to a
clear error is a silent wrong output.
- The reader of the error never needs to open the source. Include the remediation snippet, the suggested adapter, or the exact config change.
- Pair with type-tagged contracts (enums, properties) so mismatches are caught at construction, not after data has flowed.
- Never auto-correct silently. The user should see the wrap / convert / cast in their own code; auto-correction is exactly the silent-bug pattern this rule prevents.
Feed the encoder features derived from the existing data structure rather than manually annotated categories.
- Derived features (graph connectivity, statistical summaries, structural properties) are always consistent with the data, cannot be mislabeled, and generalize to novel structures.
- Manual annotations require domain expertise, are incomplete by nature, and let the model shortcut structural learning by memorizing labels.
- If the encoder cannot distinguish important cases from structure alone, enrich the computable features (edge features, sensitivity estimates) before resorting to annotations.
Layer counts, hidden dimensions, activations, and conditioning strategies are hyperparameters. They live in config dataclasses, not in constructor bodies.
- Never write out a fixed sequence of
nn.Linear/ activation pairs. Loop over ann_hidden_layersconfig value and store layers innn.ModuleList. - Extract a recurring pattern ("MLP with conditioning at every layer") into
its own
nn.Moduleclass. Networks sharing a pattern are instances of that class, not copy-pastednn.Sequentialblocks. - Conditioning (FiLM, concatenation, cross-attention) is applied at every hidden layer, not only at the output. Output-only conditioning restricts the network to context-independent intermediate features.
For models that generate sequences (trajectories, time series, autoregressive outputs), the primary training objective is a one-step prediction loss with teacher forcing. Full-rollout losses are for validation and optional fine-tuning.
- Full rollout compounds early errors; by step T the model is far from the training distribution and gradients are noisy.
- Teacher forcing keeps every training step on-distribution.
- When the model defines a tractable per-step likelihood (e.g., Gaussian transitions in an Euler-Maruyama SDE), use the negative log-likelihood as the loss. It trains mean and variance jointly and removes arbitrary loss-weighting hyperparameters.
- To bridge teacher-forced training and free-running inference, use scheduled sampling: start at 100% teacher forcing and gradually increase the fraction of steps that use the model's own predictions.
- Docstrings: Google style on all public classes, methods, and functions,
with
Args:,Returns:, andRaises:sections where applicable. - Private/protected methods get a one-line docstring explaining intent, unless the name is fully self-documenting.
- Module-level docstrings at the top of each
.pyfile explain the module's purpose in one to three sentences. - No docstrings that restate the name (
"""Gets the name."""onget_name).
Tests mirror the source tree exactly: src/<package>/foo/bar.py has
tests/foo/test_bar.py. Because modules are named after their class, each
substantial class gets its own test module. Every test directory has an
__init__.py.
- Test external interfaces, not internal behavior. Test a private
(
_-prefixed) method through the public method that calls it. - Tests explain why they exist. Names state the contract verified:
test_exponential_decay_matches_analytical. - Use analytical references for numerical tests, not cross-implementation comparisons.
- Statistical tests use wide tolerances and enough samples to keep the flaky failure rate below 1%.
- Do not test trivial behavior. Dataclass field defaults, enum string
values, and
__repr__need no tests. Test validation logic (__post_init__that raises on invalid inputs).
conftest.pyfiles provide stubs that satisfy interfaces without importing heavyweight modules.- Downstream component tests do NOT depend on upstream components being correct; use fakes or stubs to isolate.
- Prefer factory fixtures over mutable shared state.
- Use
pytest. No unittest-style classes unless grouping is genuinely needed. - Use
pytest.mark.parametrizefor the same logic across multiple inputs. - Use
pytest.approxortorch.testing.assert_closewith explicit tolerances for numerical comparisons. - Guard tests that depend on optional packages with
pytest.mark.skipif. - All tests are fast (<5 seconds each), use tiny configs and minimal data, and run on CPU only.
- Data pipeline correctness (corrupted inputs silently poison everything downstream).
- Loss function numerics (sign/normalization bugs are silent and devastating).
- Interface contracts (type hierarchy, protocol compliance, batch vs. single-item consistency).
- Factory/builder correctness (config-to-object mapping dispatches to the correct classes).
__repr__methods and dataclass field defaults (unless validation logic exists).- Full training loop integration (too slow for unit tests).
- External service integrations (require live credentials).
- All
nn.Modulesubclasses callsuper().__init__()first. - Define sub-modules in
__init__, compute inforward. No module creation insideforward. - Name tensor dimensions in comments where shapes are non-obvious:
# (batch, n_features, d_model). - Use
torch.no_grad()explicitly for inference and evaluation. - Prefer
torch.nn.functionalfor stateless operations; usenn.Modulewrappers for stateful ones (layers with parameters). - Never loop over a batch dimension in Python. If the same operation
applies to N independent items, reshape them into one batch tensor and run
one forward pass. Python loops launch thousands of tiny kernels; one
batched operation launches one, typically 10-100x faster. To support both
single-item and batched input, branch on
dim()at the entry point, not by looping internally.
- Commit messages are imperative and concise: "Add data loader", not "Added the data loader".
- One logical change per commit. Do not mix refactoring with feature additions.
- Run
uv run tox(or at minimumuv run pytest+uv run tox -e lint) before every commit. Commituv.lockwhenever dependencies change.