Abstract Base Classes

Background reading: how Python ABCs define interfaces and enforce contracts

The Problem ABCs Solve

Suppose you want to build a system where different teams can independently write different metric implementations (line length, Hjorth, PSD, entropy, ...) and have them all work interchangeably with the rest of the system.

The challenge: how do you guarantee that every metric implementation provides a compute() function? Without any enforcement, someone might forget to add it, or name it differently (run(), calculate()), and the system breaks at runtime in hard-to-debug ways.

Abstract Base Classes solve this by defining an interface contract: a set of methods that every subclass must implement. If a subclass is missing any required method, Python refuses to instantiate it — you get a clear error immediately.

How ABCs Work in Python

Python's abc module provides two key tools: ABC (the base class to inherit from) and @abstractmethod (the decorator that marks a method as required).

from abc import ABC, abstractmethod

class Animal(ABC):          # ABC = Abstract Base Class

    @abstractmethod
    def speak(self) -> str:
        ...                  # no implementation here — just the signature

    def greet(self):          # regular method — concrete, inherited as-is
        print(f"I say: {self.speak()}")


# This works — Dog provides speak():
class Dog(Animal):
    def speak(self) -> str:
        return "Woof"

d = Dog()
d.greet()   # "I say: Woof"


# This fails at instantiation — Cat doesn't implement speak():
class Cat(Animal):
    pass

c = Cat()   # TypeError: Can't instantiate abstract class Cat with abstract method speak

The Metric ABC in Montage Concord

Here is the actual Metric ABC from concord-core:

from abc import ABC, abstractmethod
from .containers import Recording, MetricResult

class Metric(ABC):

    @property
    @abstractmethod
    def name(self) -> str: ...

    @property
    @abstractmethod
    def requires(self) -> MetricRequirements: ...

    @abstractmethod
    def compute(self, recording: Recording) -> MetricResult: ...

Any class that inherits from Metric but doesn't implement all three (name, requires, compute) cannot be instantiated. Python enforces this automatically.

@property + @abstractmethod

name and requires use both @property and @abstractmethod. The @property decorator means they are accessed as attributes, not called as methods:

# With @property:
metric = LineLength(window_s=1.0)
print(metric.name)      # "line_length"  — no parentheses!

# Without @property, you'd call it as a method:
print(metric.name())    # would fail with @property

This is a design choice: name and requires are conceptually constant properties of a metric class, not computations. Using @property signals this intent.

The Implementation Pattern

Here is how a concrete metric class is structured, following the contract:

from concord_core.abc import Metric, MetricRequirements
from concord_core import Recording, MetricResult

class LineLength(Metric):
    def __init__(self, window_s=None, step_s=None):
        self.window_s = window_s   # config stored here
        self.step_s = step_s

    @property
    def name(self) -> str:
        return "line_length"

    @property
    def requires(self) -> MetricRequirements:
        return MetricRequirements(
            min_duration_s=self.window_s or 0.0
        )

    def compute(self, recording: Recording) -> MetricResult:
        # ... actual computation ...
        return MetricResult(data=result, metric_name=self.name, ...)

Why This Design?

Interchangeability

Because every metric has compute(recording), you can write code that works with any metric without knowing which one it is:

def run_all_metrics(recording, metrics: list[Metric]):
    results = []
    for metric in metrics:
        results.append(metric.compute(recording))
    return results

# Works with any combination:
run_all_metrics(rec, [LineLength(), HjorthParameters(), WelchPSD()])

Discoverability

The concord-fit package (future) will discover all installed metrics via entry_points and run them uniformly — without knowing their names in advance. It just needs to know they all have compute().

Validation via requires

Before running a metric, code can check its requirements:

def safe_compute(metric: Metric, recording: Recording):
    req = metric.requires
    if recording.duration < req.min_duration_s:
        raise ValueError(f"Recording too short for {metric.name}")
    if recording.n_channels < req.min_channels:
        raise ValueError(f"Not enough channels for {metric.name}")
    return metric.compute(recording)

Stateless Design

Notice that a Metric subclass only stores configuration in its __init__ (window size, filter parameters, etc.) — never intermediate computation results. The result comes out of compute() and is returned as a new MetricResult object.

This makes metrics pickle-safe: you can send a Metric object to a worker process via multiprocessing, have it call compute(), and get the MetricResult back. If the metric stored its own state (half-computed arrays, etc.), pickling could fail or produce wrong results.

The Model ABC — Same Pattern, Different Domain

Montage Concord uses the same ABC pattern for neural mass models. The Model ABC in concord-core defines the simulation contract:

class Model(ABC):

    @property
    @abstractmethod
    def name(self) -> str: ...

    @abstractmethod
    def default_parameters(self) -> ParameterVector: ...

    @abstractmethod
    def simulate(self, parameters, duration_s, fs, seed=None) -> ModelOutput: ...

Just like Metric guarantees every metric has compute(), Model guarantees every model has simulate(). The optimization engine (concord-fit) can discover and run any installed model without knowing its internals — it only needs to know the ABC contract. See Neural Mass Models for more about what these models simulate.