Skip to content

feat: add enums and strategy protocols, and validate learning_rate - #10

Merged
andremsouza merged 1 commit into
masterfrom
feat/typed-seams
Jul 30, 2026
Merged

feat: add enums and strategy protocols, and validate learning_rate#10
andremsouza merged 1 commit into
masterfrom
feat/typed-seams

Conversation

@andremsouza

Copy link
Copy Markdown
Owner

Enums for every string option, Protocols for the three replaceable strategies, and validation for
learning_rate. Fourth of five PRs for 0.4.0. No numerical change: weights identical to master
across all 81 combinations.

Enums, without breaking a single call

som.train(data, mode=TrainingMode.BATCH)   # new
som.train(data, mode="batch")              # unchanged, still works

Each member is a str, so the two are equal, hash equal, serialise to the same JSON, and work as
the same dictionary key. TrainingMode, Neighborhood, WeightInit, SampleMode.

The parameters are typed as the enum or a Literal of the valid strings, which is what makes this
worth doing — a bare str annotation would catch nothing:

som.train(data, mode="bacth")
    error: incompatible type "Literal['bacth']";
           expected "TrainingMode | Literal['random', 'sequential', 'batch']"

The trade, stated because it will affect someone: a variable of plain str type no longer
satisfies these parameters. The TrainingModeStr / NeighborhoodStr / WeightInitStr /
SampleModeStr aliases are exported for annotating one, and TrainingMode(value) validates at
runtime when the value comes from a config file.

Why the base class is hand-rolled

enum.StrEnum is 3.11+ and this package supports 3.10. A bare class X(str, Enum) is not
equivalent:

str(member) f-string
enum.StrEnum (3.11+) 'batch' 'batch'
bare class X(str, Enum) 'X.BATCH' 'X.BATCH'
_StrEnum here 'batch' 'batch'

The middle row would silently put the wrong text into any filename or log line built from a member.
Checked against native StrEnum on 3.10, 3.12 and 3.13 — including format() and
json.dumps — rather than assumed, and asserted in tests/test_typed_seams.py.

Strings are deprecated but do not warn

Version What happens
0.4.0 (this) Enums added. Strings accepted, no warning.
0.5.0 Strings emit DeprecationWarning.
1.0.0 Strings removed.

Warning now would fire on mode="batch", which is exactly what the README and every docs page showed
until this PR added docs/options-and-types.md. A minor release that scolds users for following its
own documentation is the wrong trade, and it would also force pytest.warns wrappers through much of
the existing suite, since filterwarnings = ["error"].

Roadmap consequence: 0.5.0 is now a prerequisite for 1.0.0. The stated policy is that a removal in
a major release follows at least one minor release that warns, and documented-only does not satisfy
that. Recorded in the plan.

Protocols for the strategy seams

NeighborhoodFunction, DecayFunction, DistanceFunction, plus KernelFunction for the batch
kernel, replacing bare Callable[...] aliases that checked little more than argument count.

Structural — nothing inherits from them, and every existing callable already satisfies its protocol.
Every parameter is positional-only, which is load-bearing rather than stylistic: without the /,
a Protocol also requires matching parameter names, so a user's def my_decay(rate, step, total)
would fail type checking purely for naming its arguments differently. A test exercises a custom decay
whose parameter names deliberately differ.

learning_rate validation

Unchecked through 0.3.0. Two failure modes, treated differently:

  • Rejected (ValueError): non-positive or non-finite. 0 freezes every model, so training runs
    to completion and changes nothing. -1 moves models away from the samples they match, taking the
    quantization error from 0.0 to 11.7 and the largest weight to 30 on a map that started inside the
    unit cube. nan needs the explicit isfinite check, since nan <= 0 is False and a bare
    comparison lets it through.
  • Warned (UserWarning): above 1. It overshoots and oscillates, but does not necessarily diverge
    — at alpha = 5 with decay disabled the largest weight stayed at 3.61, because the neighborhood
    damps the correction away from the winner. Kohonen gives no upper bound, so rejecting it would
    invent a limit the sources do not give.

A test asserts the warning's stacklevel blames the caller's line rather than _som.py.

Also

  • TRAINING_MODES and INITIALIZATION_MODES are now derived from the enums, so the accepted values
    and the enum members cannot drift apart. A test checks the derivation is still wired up.
  • The public-surface test is split in two. It previously pinned __all__ to exactly what 0.3.0
    shipped, and this PR adds names, so it fired. Rather than just updating the list, it now asserts two
    different things: a subset check that nothing 0.3.0 exported has been removed (the compatibility
    promise, which holds until 1.0.0), and an equality check on the full surface (so an addition is a
    diff to read rather than a failure to explain).
  • Coverage gains one exclusion: a line whose entire content is .... That is a Protocol method body,
    a declaration of shape that never executes. Written as a rule rather than four identical pragmas,
    and narrow enough to stay honest — such lines occur only in _core/_protocols.py, which is checked.
  • New docs page options-and-types.md, in the nav, covering both spellings, the deprecation table,
    custom strategies and the learning-rate rules.

Verification

  • 321 tests, 100% coverage, ruff / ruff format / mypy --strict / mkdocs build --strict
    clean, twine check passes, all four Python versions.
  • 81/81 combinations bit-identical to master — this PR adds types and validation, not arithmetic.
  • Typo-catching verified end to end: a scratch file passing mode="bacth" and
    neighborhood_function="guassian" produces exactly two mypy errors, while the enum and the correct
    strings produce none.

Not in this PR

SOMConfig and from_config() were planned here and are deferred to #11. With the 13-argument
constructor staying, a config dataclass would be a second spelling of the same thing with no caller
of its own — the charge that removed _progress.py from #7. PR #11 adds provenance and
save_npz/load_npz, which need a serialisable description of a map's configuration and so give it
a real second caller.

Three additions, all backward compatible. No numerical behaviour changes: weights are identical to
master across all 81 combinations of training mode, neighborhood, initializer and cyclic setting.

Enums for the string-valued options: TrainingMode, Neighborhood, WeightInit, SampleMode. Each member
is a `str`, so `TrainingMode.BATCH` and `"batch"` are equal, hash equal, serialise to the same JSON
and work as the same dict key. The parameters are typed as the enum or a Literal of the valid
strings, so a type checker now rejects `mode="bacth"` while accepting both spellings. The cost is
that a variable of plain `str` type no longer satisfies the parameter; the `*Str` aliases are
exported for annotating one.

`enum.StrEnum` is 3.11+ and this package supports 3.10, so `_StrEnum` reproduces it. A bare
`class X(str, Enum)` is not equivalent -- its `str()` returns `'X.MEMBER'`, which would put the wrong
text into any f-string, filename or log line built from a member. Behaviour was checked against
native StrEnum on 3.10, 3.12 and 3.13.

Plain strings are deprecated in writing only. They warn in 0.5.0 and are removed in 1.0.0. Warning
now would fire on `mode="batch"`, which is what the documentation showed until this release. This
makes 0.5.0 a prerequisite for 1.0.0, since the policy is that a removal in a major release follows
at least one minor release that warns.

Protocols for the three replaceable strategies, plus KernelFunction, replacing bare Callable
aliases. Structural, so nothing inherits from them and every existing callable already satisfies its
protocol. Parameters are positional-only: without that a Protocol also requires matching parameter
names, and a user's `def my_decay(rate, step, total)` would fail for naming its arguments
differently.

learning_rate is validated, having been unchecked. Non-positive or non-finite raises ValueError:
`0` freezes every model so training completes and changes nothing, and `-1` moves models away from
the samples they match, taking the quantization error from 0.0 to 11.7. Above 1 warns rather than
raises -- it overshoots and oscillates but does not necessarily diverge, measuring max|w| 3.61 at
alpha=5 with decay disabled, and Kohonen gives no upper bound.

TRAINING_MODES and INITIALIZATION_MODES are now derived from the enums so the two cannot drift.

The public-surface test is split in two: a subset check that nothing 0.3.0 exported has been removed,
which is the compatibility promise, and an equality check on the full surface, so an addition shows
up as a diff to read rather than a failure to explain.

Coverage excludes a line whose entire content is `...`, which is a Protocol method body: a
declaration of shape that never executes. Narrow enough to stay honest -- such lines occur only in
_core/_protocols.py.
@andremsouza
andremsouza merged commit 5b7e9d5 into master Jul 30, 2026
8 checks passed
@andremsouza
andremsouza deleted the feat/typed-seams branch July 30, 2026 03:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant