Architecture and Design¶
This document describes the internal architecture and design principles of coola.
Overview¶
coola is designed around a flexible, extensible comparison framework that can handle various data
types through a plugin-like architecture. The core design follows these principles:
- Separation of concerns: Comparison logic is separated from data type handling
- Extensibility: New data types can be added without modifying core code
- Type safety: Strong type checking to prevent subtle bugs
- Composability: Complex comparisons are built from simpler ones
Core Components¶
1. Comparison Functions¶
The main entry points for users:
objects_are_equal: Checks exact equalityobjects_are_allclose: Checks equality within tolerance
These functions provide a simple interface while delegating to the internal comparison system.
2. Testers¶
A tester implements the comparison logic for one type (or family of types). Testers live in
coola.equality.tester.
BaseEqualityTester¶
Abstract base class defining the tester interface:
class BaseEqualityTester(ABC, Generic[T]):
@abstractmethod
def equal(self, other: object) -> bool:
"""Indicate if ``other`` is a tester of the same type."""
@abstractmethod
def objects_are_equal(self, actual: T, expected: object, config: EqualityConfig) -> bool:
"""Indicate if two objects are equal."""
Built-in Testers¶
DefaultEqualityTester: Fallback for any type (identity, type check, then==)MappingEqualityTesterandSequenceEqualityTester: Recurse into mappings and sequencesScalarEqualityTester,EqualEqualityTester,EqualNanEqualityTester,TolerantEqualEqualityTester: Scalars and objects with anequalmethod- Array and dataframe testers for NumPy, PyTorch, pandas, polars, xarray, JAX and PyArrow (for
example
NumpyArrayEqualityTester,TorchTensorEqualityTester,PandasDataFrameEqualityTester) HandlerEqualityTester: Wraps a chain of handlers (see below)
3. Registry¶
EqualityTesterRegistry¶
The registry maps types to testers. It is built on BaseTypeDispatchRegistry
(coola.registry):
find_equality_tester(data_type)returns the tester for the most specific registered type, walking the Method Resolution Order (MRO);objectis registered withDefaultEqualityTester, so a tester is always foundobjects_are_equal(actual, expected, config)finds the tester fortype(actual)and delegates to it- Lookup results are cached per type, and the cache is cleared whenever the registry changes
get_default_registry() returns the global registry. Testers for optional libraries are only
registered when the library is installed. register_equality_testers(mapping, exist_ok=False)
adds testers to it.
4. Configuration¶
EqualityConfig¶
A dataclass that carries the comparison settings through the comparison tree:
@dataclass
class EqualityConfig:
registry: EqualityTesterRegistry # defaults to the default registry
equal_nan: bool = False
atol: float = 0.0
rtol: float = 0.0
show_difference: bool = False
max_depth: int = 1000
This allows behavior to be customized without changing tester signatures. A config tracks the current recursion depth, so it is not thread-safe: create one config per comparison.
5. Handlers¶
Handlers (coola.equality.handler) are small reusable checks that are chained together
(chain of responsibility). Each handler either returns a result or passes the comparison to the
next handler in the chain. Examples:
SameObjectHandler,SameTypeHandler,SameLengthHandler: Generic checksSameDTypeHandler,SameShapeHandler: Metadata checksTorchTensorSameDeviceHandler: Compares PyTorch device placementObjectEqualHandler,NanEqualHandler,TolerantEqualHandler: Value checksMappingSameKeysHandler,MappingSameValuesHandler,SequenceSameValuesHandler: Recursive checks for containers- Library-specific handlers such as
NumpyArrayEqualHandlerorPandasDataFrameEqualHandler
create_chain(*handlers) links handlers and returns the first one. A HandlerEqualityTester
turns a chain into a tester. Handlers promote code reuse and consistency across testers.
6. Comparison Results¶
compare returns a ComparisonResult, and assert_objects_equal / assert_objects_allclose
raise an AssertionError with a description of the difference.
Data Flow¶
Here's how a comparison flows through the system:
User calls objects_are_equal(obj1, obj2)
↓
Creates EqualityConfig with settings (and the registry)
↓
Calls registry.objects_are_equal(obj1, obj2, config)
↓
Registry looks up the tester based on type(obj1) (MRO lookup)
↓
Calls tester.objects_are_equal(obj1, obj2, config)
↓
Tester runs its handler chain (type check, metadata, values)
↓
May recursively call registry.objects_are_equal() for nested objects
↓
Returns boolean result
Design Patterns¶
1. Strategy Pattern¶
Testers implement different comparison strategies for different types, allowing the algorithm to vary independently from the clients that use it.
2. Chain of Responsibility¶
Handlers are chained: each one either decides the result or passes the comparison to the next handler. The MRO-based tester lookup also tries more specific testers before falling back to general ones.
3. Template Method¶
Many testers follow a template:
- Check types match
- Check metadata (shape, dtype, etc.)
- Check values
- Optionally show differences
4. Registry Pattern¶
The tester registry allows runtime type-to-tester mapping, enabling extensibility.
5. Visitor Pattern¶
The recursive nature of comparison through nested structures follows a visitor-like pattern.
Extension Points¶
Adding Support for New Types¶
To add support for a custom type:
- Implement a Tester:
from coola.equality.config import EqualityConfig
from coola.equality.tester import BaseEqualityTester
class MyTypeEqualityTester(BaseEqualityTester[MyType]):
def equal(self, other: object) -> bool:
return type(other) is type(self)
def objects_are_equal(self, actual: MyType, expected: object, config: EqualityConfig) -> bool:
# Type check
if type(actual) is not type(expected):
return False
# Custom comparison logic
return actual.compare_to(expected)
- Register the Tester:
from coola.equality.tester import register_equality_testers
register_equality_testers({MyType: MyTypeEqualityTester()})
To avoid modifying the global registry, create a EqualityTesterRegistry, register the tester
on it and pass it with registry=.
- Use it:
from coola.equality import objects_are_equal
objects_are_equal(obj1, obj2)
See the extending guide for more details.
Type System¶
Strict Type Checking¶
coola enforces strict type checking:
1(int) ≠1.0(float) ≠True(bool)list≠tupledict≠OrderedDict
This prevents subtle bugs from type coercion.
Type Hierarchy Support¶
Through MRO-based lookup, coola supports inheritance:
- A tester for
Sequenceapplies tolist,tuple, etc. - More specific testers override general ones
- Custom subclasses inherit parent testers
Performance Considerations¶
Early Exit¶
Testers check fast properties first:
- Type check (very fast)
- Metadata checks (fast: shape, dtype, device)
- Value comparison (potentially slow)
Lazy Evaluation¶
Comparisons short-circuit on first difference when possible.
Caching¶
The registry caches tester lookups by type for performance.
Recursive Depth¶
For deeply nested structures, comparison is recursive. EqualityConfig.max_depth (default 1000)
bounds the nesting depth. Each level uses several interpreter frames, so Python's own recursion
limit (sys.setrecursionlimit) may be reached first, in which case a RecursionError with an
actionable message is raised.
Error Handling¶
Graceful Degradation¶
When a specific tester is not available, coola falls back to:
- More general tester (via MRO)
DefaultEqualityTester(registered forobject), which uses==
Informative Messages¶
When show_difference=True, testers log:
- What objects differ
- Where in the structure the difference is
- The actual values that differ
Testing Strategy¶
The coola codebase uses:
- Unit tests: Test individual testers and handlers in isolation
- Integration tests: Test complete comparison workflows
- Property-based tests: Test invariants (e.g., reflexivity)
- Cross-library tests: Test integration with PyTorch, NumPy, etc.
Dependencies¶
Core Dependencies¶
- Python 3.10+: Core language features
Optional Dependencies¶
- torch: PyTorch tensor support
- numpy: NumPy array support
- pandas: DataFrame support
- polars: Polars DataFrame support
- xarray: xarray support
- jax: JAX array support
- pyarrow: PyArrow table support
Each optional dependency is only imported when used (lazy loading).
Module Organization¶
coola/
├── equality/ # Equality and tolerance comparison
│ ├── interface.py # objects_are_equal, objects_are_allclose
│ ├── result.py # compare, assert_objects_equal, ...
│ ├── config.py # EqualityConfig
│ ├── tester/ # Type-specific testers and the registry
│ └── handler/ # Reusable comparison logic
├── registry/ # Generic registries and type dispatch
├── summary/, hashing/, recursive/, iterator/, nested/, random/, reducer/
├── io/, factory/, identifier/, display/, validation/, testing/
└── utils/ # Utility functions
Design Decisions¶
Why Strict Type Checking?¶
Rationale: Prevents subtle bugs from implicit type coercion. In scientific computing, knowing
that 1 (int) and 1.0 (float) are treated differently can catch numerical issues.
Trade-off: Less convenient for some use cases, but more explicit and safe.
Why Registry-Based Dispatch?¶
Rationale: Allows extensibility without modifying core code. Users can add support for their own types.
Trade-off: Slightly more complex than if/else chains, but much more maintainable.
Why Separate Testers and Handlers?¶
Rationale: Separation of concerns. Testers are the per-type entry points found by the registry, handlers are small reusable checks that testers chain together.
Trade-off: More classes/files, but better modularity.
Why Handlers?¶
Rationale: Code reuse. Many testers need similar checks (dtype, shape, etc.).
Trade-off: One more abstraction layer, but reduces duplication.
Future Directions¶
Potential areas for enhancement:
- Parallel comparison: For large independent comparisons
- Streaming comparison: For very large objects that don't fit in memory
- Approximate structural matching: For comparing objects with similar but not identical structure
- Diff generation: Not just boolean result, but detailed diff
- Performance optimizations: Cython/Numba for hot paths
Package Layering¶
Packages fall into two groups:
- Core:
registry(type-based dispatch),utils(generic helpers),displayandvalidation. These must not import from any feature package. - Features:
equality,hashing,summary,recursive,iterator,random,nested,reducer,io,factory,identifierandtesting. Onlyequality,hashing,summary,recursiveanditeratorare built onregistry;identifier,reducerandrandomare standalone helpers.
The only exception is a deferred (function-level) import in coola.registry.base, which is listed in
ignore_imports. display and utils currently import each other, so they are kept in the same
group.
The contract is enforced with import-linter
(configured in pyproject.toml). Run it with:
lint-imports
References¶
- PEP 8: Python style guide
- PyTorch documentation
- NumPy documentation
- Design Patterns: Gang of Four patterns
Contributing¶
To contribute to coola's architecture:
- Understand the existing patterns
- Follow the established conventions
- Document design decisions
- Write tests for new components
- Update this document for significant changes
See the contributing guide for more details.