Skip to content

refactor(optimizers): delete the cached typed options copy, 42 updateoptions overrides to 6 - #2058

Closed
ooples wants to merge 6 commits into
masterfrom
refactor/optimizer-typed-options
Closed

refactor(optimizers): delete the cached typed options copy, 42 updateoptions overrides to 6#2058
ooples wants to merge 6 commits into
masterfrom
refactor/optimizer-typed-options

Conversation

@ooples

@ooples ooples commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Stacked on #2055 and depends on it. Base it there, not on master. #2055 makes the base adopt restored options in Deserialize; without that, a computed property would read the constructor's options after a restore rather than the restored ones.

The problem

UpdateOptions was abstract, so all 42 concrete optimizers implemented it — and 36 were the same eight lines:

protected override void UpdateOptions(OptimizationAlgorithmOptions<T, TInput, TOutput> options)
{
    if (options is AdamOptimizerOptions<T, TInput, TOutput> adamOptions)
    {
        _options = adamOptions;
    }
    else
    {
        throw new ArgumentException("Invalid options type. Expected AdamOptimizerOptions.");
    }
}

That private field is a second copy of state the base already owns, and keeping two copies in step is the only reason the method existed. Every new optimizer inherited the obligation, and any change to how options are held meant 42 edits.

The change

The 36 read their typed options through a computed property over the base's single instance:

private AdamOptimizerOptions<T, TInput, TOutput> _options
    => (AdamOptimizerOptions<T, TInput, TOutput>)Options;

The override then has nothing to do and is deleted. UpdateOptions becomes virtual with a no-op default, to be overridden only to react to a new option set.

Six still do, and keep both field and override: ADMM and ProximalGradientDescent rebuild a regularizer, Bayesian updates a kernel, GradientBasedOptimizerBase caches a scheduler mode, Lion re-seeds adaptive parameters, Rprop validates hyperparameters.

It closes a latent bug, not just noise

The constructors read:

: base(model, options ?? new())
{
    _options = options ?? new AdamOptimizerOptions<T, TInput, TOutput>();

When the caller passes null those are two different default instances — the base holding one, the derived class the other, each blind to changes in the other. There is now one instance and no way to desynchronise them.

Adam8BitOptimizer's hand-rolled Deserialize kept its own copy of the options JSON too. The read stays — the stream position depends on it and a malformed payload must still fail there — but the value is discarded, since base.Deserialize has already adopted the authoritative copy.

Verification

  • 1693 of 1693 optimizer tests pass.
  • src builds clean on net10.0, net8.0 and net471.
  • 42 → 6 overrides; net -780 lines.

Note on how this was produced

The mechanical conversion was scripted, but two passes had to be redone: a first regex over-reached into the six exception files and stripped constructor assignments they still need, and RootMeanSquarePropagationOptimizer is a partial class whose override lives in another file, so a per-file pass missed it. Both were caught by the compiler, not by inspection — worth knowing if this pattern is applied elsewhere.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U1HFtoj81avXvuLebTpWyk

t and others added 6 commits August 29, 2026 13:56
Seed is on the shared options and OptimizerBase.CreateSearchRandom documents the
contract: "a run is a random variable. One that cannot be repeated cannot be debugged."
The function-minimisation overloads keep it. Optimize(inputData) did not, because three
generators on that path were built without ever consulting the seed.

OptimizerBase ran

    _model = model;
    Random = new();
    NumOps = ...;
    Options = options ?? new ...;

so the shared generator was constructed on the line ABOVE the Options assignment. It
could not have read a seed even had it tried, and RandomlySelectFeatures and
InitializeRandomSolution both draw from it. Reordered so Options is assigned first and
the generator derives from Options.Seed, via RandomHelper as the coding standard
requires rather than a bare new Random().

The candidate-perturbation loop in BuildDataDerivedRandomParameters created its own
CreateSecureRandom(). It now draws from the shared generator, since that perturbation
decides the parameters every candidate starts from.

ParticleSwarmOptimizer built its own field with CreateSecureRandom() in both
constructors, ignoring the seed outright; velocity initialisation and the cognitive and
social draws all come from it. Both constructors now derive it from the options, and
UpdateOptions refreshes it, because a generator still running on the previous seed would
contradict the options the optimizer reports. The field loses readonly for that reason.

DifferentialEvolutionOptimizer needed nothing - it already takes CreateSearchRandom.

Two tests, both failing before this change and passing after. The narrow one asserts the
generators directly and is the honest statement of what changed; without the fix it
reports

    Expected: Tuple (2072998746, 0.30262624952133105)
    Actual:   Tuple (493046785, 0.83774535955756224)

from two constructions with the SAME seed. The broader one asserts a same-seed
Optimize(inputData) returns the same BestFitnessScore, which is the behaviour callers
actually want. Equality rather than a tolerance is deliberate: a seed either determines
the run or it does not.

Verified: 1690 of 1690 optimizer tests pass, so seeding the shared generator regresses
nothing across the other optimizers that inherit it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1HFtoj81avXvuLebTpWyk
…r optimizer

Addresses both review findings, and takes the fix to the level it belonged at.

Deriving one class's generator from the seed was the wrong shape. A survey of all 60
files under src/Optimizers found the same private-generator pattern in
SimulatedAnnealingOptimizer, which would have kept the defect after a particle-swarm-only
fix, and nothing stops the next optimizer from adding another. So both private
generators are deleted and both classes now draw from the seeded Random that
OptimizerBase already owns. There is no longer a per-optimizer generator to forget to
seed. OptimizationDataBatcher was the only other hit and already seeds correctly.

That also dissolves the first review finding rather than patching it. UpdateOptions no
longer rebuilds anything, because the class holds nothing to rebuild.

The second half of that finding was real and is fixed at the same single point:
Deserialize handed restored options to UpdateOptions while the base kept its constructor
values, so a restored optimizer reported one seed and drew from another. The base now
adopts the restored options before handing them on, which fixes it for every optimizer
at once. Deliberately narrow - the collaborators cached at construction (FitDetector,
FitnessCalculator, the statistics options) are left alone, since replacing live
collaborators mid-lifetime is a separate concern from reproducibility.

The second finding was correct and the remarks were stale: they claimed
Optimize(inputData) was not reproducible and that the test only inspected generators.
Both were written while a sync test carrying Fact(Timeout) was failing with "Tests marked
with Timeout are only supported for async tests" - an infrastructure error I read as an
assertion failure. The tests are async with Task.Yield now, the Timeout guard works, and
the remarks describe what is actually asserted.

Tests follow the same move: the generator test covers particle swarm AND simulated
annealing, because the contract is the base class's rather than either optimizer's.

Verified: 1691 of 1691 optimizer tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1HFtoj81avXvuLebTpWyk
…ze clobbering collaborators

The review was right that adopting Options and Random while leaving PredictionOptions,
ModelStatsOptions, FitDetector, FitnessCalculator and ModelCache on their constructor
values is a torn state. Adopting them literally, however, is worse - and the difference
is measurable.

Interface-typed properties carry no type information through the options JSON, so
Newtonsoft rebuilds each as its property initializer's default. Measured: an optimizer
configured with MeanSquaredErrorFitnessCalculator serializes, and the payload comes back
holding RSquaredFitnessCalculator, the default. Adopting the payload's collaborators
would therefore swap a caller's evaluator, fit detector and cache for defaults on every
deserialize - silently, and on a path whose entire purpose is to restore configuration.
An earlier probe of mine looked like they round-tripped; they did not. Both sides simply
held the default, so "non-null" read as "restored".

So the restored payload contributes its VALUES, and the live collaborators are carried
onto it before adoption. Options and the cached fields then agree, which is the property
the review actually asked for, without a deserialize quietly replacing configuration.

Adoption itself is now one method. ApplyOptions defines how an option set becomes state
and is the only thing that does; the constructor and Deserialize both route through it,
so they cannot drift and a future option-derived field is restored for free by being
added there. Declared with MemberNotNull rather than a null-forgiving operator, which the
coding standard forbids and which several call sites in this repo already model.

Verified: 1692 of 1692 optimizer tests pass; src builds clean on net10.0, net8.0 and
net471, so MemberNotNull holds on the framework target too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1HFtoj81avXvuLebTpWyk
…rror

A fix that has to be remembered is a fix that comes back. Two optimizers had already
built private generators that no seed could reach, and nothing stopped a third. AIDN077
closes the class rather than the instances.

Inside any type deriving from OptimizerBase, both spellings are refused:

    new Random(...)
    RandomHelper.CreateSecureRandom() / CreateSeededRandom(...)

because the base already exposes a Random derived from Options.Seed and re-derives it
when options are restored. OptimizerBase itself is exempt - it is the one type that must
build the generator, and where the seed is honoured for everything below it.

This is a HARD gate, unlike its neighbours. TreatWarningsAsErrors is on and AIDN070
through AIDN076 are each listed in WarningsNotAsErrors, because existing code violates
them and they are being paid down gradually. AIDN077 has zero violations the moment it
lands, so it is deliberately NOT added to that list and fails the build from day one.
Grandfathering it would have meant shipping a rule that never bites.

Proven by reintroducing the exact defect it exists to stop:

    error AIDN077: 'RandomHelper.CreateSecureRandom()' creates a generator of its own;
      draw from the inherited OptimizerBase.Random, which is built from Options.Seed
    error AIDN077: 'new Random(...)' creates a generator of its own; ...

Errors, not warnings, and the build exits nonzero. With the defect removed the same
build is clean on net10.0, net8.0 and net471, which also shows OptimizerBase's own
exemption works rather than merely being written down.

Registered in AnalyzerReleases.Unshipped.md. The category there must match the
descriptor's Category constant exactly - AiDotNet.GoldenPattern, singular - or the
release tracker reports RS2001 against a rule that is otherwise fine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1HFtoj81avXvuLebTpWyk
…not the initial one

Chasing whether the 42 optimizers overriding UpdateOptions leave option-derived state
stale, this looked like a defect: the constructor seeds _currentInertia and its siblings
from Initial* on the options, and no UpdateOptions override re-runs
InitializeAdaptiveParameters, so a restored optimizer reads like it keeps the
configuration it was BUILT with.

It does not. Measured across a deserialize, driving the source's live value away from
its initial one first:

  BEFORE cur=0.91 opt=0.91 | AFTER cur=0.137 opt=0.42 | SRC cur=0.137 opt=0.42

The state layer carries the live values, so a mid-run optimizer serialized at inertia
0.137 comes back at 0.137 rather than at either side's Initial*. There is no staleness
to fix.

Which makes the obvious "fix" harmful. Calling ResetAdaptiveParameters() from Deserialize
overwrites restored state, silently rewinding a resumed optimizer to the start of its
schedule - and I had written exactly that before measuring. This test refuses it: against
that implementation it fails with

  Expected: 0.13700000000000001
  Actual:   0.41999999999999998

and passes against the current one. The distinction only appears when the live value is
driven away from the initial value, which is why the test sets it explicitly instead of
trusting a short run to diverge - an earlier attempt that skipped that step passed under
both implementations and proved nothing.

No production change. 379 optimizer tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1HFtoj81avXvuLebTpWyk
…options overrides to 6

UpdateOptions was abstract, so all 42 concrete optimizers implemented it, and 36 of those
were the same eight lines: downcast the options, assign them to a private typed field,
throw otherwise. That field was a second copy of state the base already owns, and keeping
two copies in step is the only reason the method existed. Every new optimizer inherited
the obligation, and any change to how options are held meant 42 edits.

Those 36 now read their typed options through a computed property over the base's single
instance:

    private AdamOptimizerOptions<T, TInput, TOutput> _options
        => (AdamOptimizerOptions<T, TInput, TOutput>)Options;

so the override has nothing to do and is deleted. UpdateOptions becomes virtual with a
no-op default, to be overridden only to REACT to a new option set. Six do: ADMM and
ProximalGradientDescent rebuild a regularizer, Bayesian updates a kernel,
GradientBasedOptimizerBase caches a scheduler mode, Lion re-seeds adaptive parameters,
Rprop validates hyperparameters. Those keep their field and their override.

This closes a latent bug rather than only removing noise. The constructors read

    : base(model, options ?? new())
    {
        _options = options ?? new AdamOptimizerOptions<T, TInput, TOutput>();

which builds TWO different default instances whenever the caller passes null - the base
holding one, the derived class the other, and a change to either invisible to the other.
There is now one instance and no way to desynchronise them.

Adam8BitOptimizer's hand-rolled Deserialize also kept its own copy of the options JSON.
The read stays, because the stream position depends on it and a malformed payload must
still fail there, but the value is discarded: base.Deserialize has already adopted the
authoritative copy.

Stacked on fix/pso-honours-its-seed and depends on it. That branch makes the base adopt
restored options in Deserialize; without it, a computed property would read the
constructor's options after a restore rather than the restored ones.

Verified: 1693 of 1693 optimizer tests pass, and src builds clean on net10.0, net8.0 and
net471. Net -783 lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1HFtoj81avXvuLebTpWyk
@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
aidotnet_website Ready Ready Preview Aug 30, 2026 6:06am
aidotnet-playground-api Ready Ready Preview Aug 30, 2026 6:06am

@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 2 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 185edf2a-0d95-4eb5-bf74-59b7e0150c54

📥 Commits

Reviewing files that changed from the base of the PR and between 969f198 and c687c6f.

📒 Files selected for processing (41)
  • src/AiDotNet.Generators/AnalyzerReleases.Unshipped.md
  • src/AiDotNet.Generators/GoldenPatternValidationGenerator.cs
  • src/Optimizers/AMSGradOptimizer.cs
  • src/Optimizers/ASGDOptimizer.cs
  • src/Optimizers/AdaDeltaOptimizer.cs
  • src/Optimizers/AdaMaxOptimizer.cs
  • src/Optimizers/AdagradOptimizer.cs
  • src/Optimizers/Adam8BitOptimizer.cs
  • src/Optimizers/AdamOptimizer.cs
  • src/Optimizers/AdamWOptimizer.cs
  • src/Optimizers/AntColonyOptimizer.cs
  • src/Optimizers/BFGSOptimizer.cs
  • src/Optimizers/CMAESOptimizer.cs
  • src/Optimizers/ConjugateGradientOptimizer.cs
  • src/Optimizers/CoordinateDescentOptimizer.cs
  • src/Optimizers/DFPOptimizer.cs
  • src/Optimizers/DifferentialEvolutionOptimizer.cs
  • src/Optimizers/FTRLOptimizer.cs
  • src/Optimizers/GeneticAlgorithmOptimizer.cs
  • src/Optimizers/GradientDescentOptimizer.cs
  • src/Optimizers/LAMBOptimizer.cs
  • src/Optimizers/LARSOptimizer.cs
  • src/Optimizers/LBFGSOptimizer.cs
  • src/Optimizers/LevenbergMarquardtOptimizer.cs
  • src/Optimizers/MiniBatchGradientDescentOptimizer.cs
  • src/Optimizers/MomentumOptimizer.cs
  • src/Optimizers/NadamOptimizer.cs
  • src/Optimizers/NelderMeadOptimizer.cs
  • src/Optimizers/NesterovAcceleratedGradientOptimizer.cs
  • src/Optimizers/NewtonMethodOptimizer.cs
  • src/Optimizers/NormalOptimizer.cs
  • src/Optimizers/OptimizerBase.cs
  • src/Optimizers/ParticleSwarmOptimizer.cs
  • src/Optimizers/PowellOptimizer.cs
  • src/Optimizers/RAdamOptimizer.cs
  • src/Optimizers/RootMeanSquarePropagationOptimizer.cs
  • src/Optimizers/SimulatedAnnealingOptimizer.cs
  • src/Optimizers/StochasticGradientDescentOptimizer.cs
  • src/Optimizers/TabuSearchOptimizer.cs
  • src/Optimizers/TrustRegionOptimizer.cs
  • tests/AiDotNet.Tests/IntegrationTests/Optimizers/MetaheuristicOptimizerIntegrationTests.cs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ooples

ooples commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Folded into #2055 rather than run a second multi-hour CI cycle on work that cannot build without it. The commit c687c6fd65 is unchanged and now sits on top of the seeding fixes in that branch — nothing is lost by closing this.

Splitting them was my mistake: the refactor depends on #2055's ApplyOptions to adopt restored options, so the two were never independently mergeable and two PRs meant paying CI twice for one logical change.

@ooples ooples closed this Aug 30, 2026
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