1. Methods are a registry of objects, not a hardcoded dispatch

Status: accepted, 2026-08-26

Context

v0.1.0 dispatched methods through an if/elif chain over a frozen METHODS tuple:

if method == "spotis":
    return spotis(decision.matrix, weights, decision.directions, bounds)
data = orient(decision.matrix, decision.directions)
if method == "promethee":
    return promethee(data, weights, _shapes(decision))
return _ORIENTED[method](data, weights)

Two methods needed special handling that the others did not — SPOTIS wants the un-oriented matrix and the bounds, PROMETHEE wants per-criterion preference shapes — and sensitivity.py carried a third special case, suppressing SPOTIS’s warning by checking the method name.

A method written outside the package could not express any of that. It could not say “give me raw data”, could not report a caveat, and could not be added without editing ranking.py. Three name checks in the library meant three places where an outside method was second-class by construction.

Decision

A method is an object implementing a Method protocol. It declares what it needs (wants = Wants.RAW | Wants.ORIENTED), receives everything through a ScoringContext, and may return either a bare score array or a MethodResult carrying reversal_free and warnings. A registry maps names to instances, and importlib.metadata entry points let a package advertise methods that are discovered on install.

METHODS became a live view of the registry rather than a frozen tuple, because a snapshot taken at import would not see a plugin registered later.

The three name checks are gone. ranking.py no longer mentions any method by name; sensitivity.py suppresses warnings generically around the bisection, which works for methods nobody has written yet.

Consequences

Good. Third-party methods are indistinguishable from built-ins: reachable by name, included in compare_methods, analysable by sensitivity. Adding a built-in no longer touches the dispatcher. Declared capabilities are self-documenting — wants = Wants.RAW says what a comment used to.

Cost. More surface area: Method, ScoringContext, MethodResult, Wants and the registry are all public API now, and public API is a promise. An indirection sits between rank() and the arithmetic.

A bug it surfaced. With options passed as opaque **kwargs, a method that ignored them let a misspelled argument vanish silently — the same class of bug as the original dropped-kwargs defect. ScoringContext.opts tracks whether options were read, and rank() raises if nobody read them.

Not changed. The eight algorithms are byte-for-byte the same functions, still importable directly and still covered by the tests that verified them against hand computations and pymcdm. The refactor moved dispatch, not arithmetic — 236 existing tests passed unchanged, which is the evidence that it was behaviour-preserving.

Alternatives considered

A @method decorator over plain functions. Lighter, and closer to the existing code. Rejected because metadata in decorator arguments is harder to discover than attributes on a class, and a function has nowhere natural to put validate.

Passing (data, weights) and keeping capabilities implicit. Rejected: it is exactly what made SPOTIS need a special case, and the next method with an unusual need would need another one.