Skip to content

perf(optimizers): sparse-by-default — all 19 dense paths consume sparse via ToDense, Adam/AdamW scatter - #1526

Merged
ooples merged 1 commit into
masterfrom
feat/sparse-embedding-optimizers
Jun 8, 2026
Merged

ooples merged 1 commit into
masterfrom
feat/sparse-embedding-optimizers

Conversation

@ooples

@ooples ooples commented Jun 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

Tensors PR #553 (merged) ships a sparse representation of the embedding-lookup backward. Per the design discussion: sparse-by-default is the architecture — every optimizer accepts a sparse contribution, and the ones that genuinely need a dense gradient materialise via `ToDense` internally. Tensors will eventually stop seeding the `[vocabSize, embeddingDim]` dense alloc altogether (a follow-up Tensors PR); AiDotNet must be ready to consume sparse-only by then.

For paper-default LayoutXLM the dense alloc is ~768 MB per backward against ~16 rows of real signal — ModelPerfProbe (#1510) identified this as the dominant per-step cost across paper-scale embedding-using models, and the per-step timeout is the root of many cross-the-board ModelFamily timeouts.

What this PR does

Core helper: `SparseEmbeddingOptimizerHelpers`

  1. `TryGetEffectiveGradient` — sparse-by-default dense lookup. Returns dense from `context.Gradients` when present (today's path, bit-identical), OR materialises any sparse contribution via `SparseEmbeddingGradient.ToDense(engine)` + `TensorAdd` over multiple contributions when dense is absent (future Tensors-side dense-seeding-drop).

  2. `TryApplyAdamSparse` — Adam / AdamW scatter fast path. When sparse exists for an embedding param, scatter the Adam (or AdamW with decoupled weight decay) update onto only the indexed rows, skip the dense traversal entirely. Raw-array span loops for `double` / `float`, generic-`T` fallback otherwise. Skipped for AMSGrad (vMax monotonic-max invariant requires touching every row).

Wiring — all 19 per-param optimizers

Optimizer Sparse handling
Adam Scatter via `TryApplyAdamSparse`; dense fallback materialises via `TryGetEffectiveGradient`.
AdamW Same — scatter + decoupled weight decay per-row; dense fallback via helper.
AMSGrad Dense via `TryGetEffectiveGradient` (vMax invariant).
AdaDelta, AdaMax, Adagrad, Adam8Bit, FTRL, LAMB, LARS, Lion, Momentum, Nadam, NesterovAcceleratedGradient, ProximalGradientDescent, RMSProp, GradientDescent, MiniBatchGradientDescent, StochasticGradientDescent Dense via `TryGetEffectiveGradient` — they pay one `ToDense` (same cost they pay today via Tensors-side dense-seeding) and feed into their existing dense Step body unchanged.

Second-order methods (BFGS, LBFGS, ConjugateGradient, CoordinateDescent, DFP, LevenbergMarquardt, NewtonMethod, TrustRegion, ADMM) are not modified — they don't read `context.Gradients` per-param and don't intersect the embedding-table gradient pipeline.

Forward compatibility

Once Tensors stops seeding dense alongside sparse, every optimizer in this PR keeps working — Adam/AdamW skip the alloc entirely via the scatter path, and the 17 dense-path optimizers materialise via `ToDense` (one alloc, equivalent cost to today's Tensors-side dense seeding). The 768 MB LayoutXLM-class allocation churn becomes recoverable for Adam/AdamW models.

Tests

`SparseEmbeddingOptimizerHelpersTests` covers the externally-observable contract:

  • No-sparse-grad → fall through cleanly.
  • Non-rank-2 param → fall through (safety guard for non-embedding params).

Full sparse semantics (per-row scatter, duplicate-index scatter-add, AdamW decoupled decay per-row, multi-contribution accumulation) are exercised end-to-end by the embedding-layer ModelFamily tests in CI; a focused unit test for those would require binding `_gradIndex` through the tape's non-public assignment hook (only set during a real `ComputeGradients` walk).

Closes

Closes the consumer-side follow-up that Tensors#553 explicitly called out as out-of-scope.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Wide-ranging sparse-embedding support for optimizers: per-parameter sparse fast-paths now apply updates only to touched embedding rows and fall back to dense handling when needed.
    • Gradient materialization: sparse gradients are materialized into dense gradients for algorithms that require flat/dense views.
  • Tests

    • New unit tests covering sparse-helper fallback and shape/eligibility cases.

@vercel

vercel Bot commented Jun 7, 2026 •

Copy link
Copy Markdown

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

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
aidotnet_website Ignored Ignored Preview Jun 8, 2026 1:33pm
aidotnet-playground-api Ignored Ignored Preview Jun 8, 2026 1:33pm

@coderabbitai

coderabbitai Bot commented Jun 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

Pull request was closed or merged during review

Walkthrough

Adds comprehensive sparse-embedding optimizer helpers (materialize, effective-gradient, and many TryApply*Sparse implementations) and integrates them into optimizer Step loops so optimizers can use sparse scatter fast-paths or materialize sparse grads to dense when needed.

Changes

Sparse Embedding Optimizer Support

Layer / File(s) Summary
All helpers and optimizer integrations
src/Optimizers/SparseEmbeddingOptimizerHelpers*.cs, src/Optimizers/*.cs, tests/AiDotNet.Tests/UnitTests/Optimizers/*
Introduces helper APIs (MaterializeSparseIntoGradientsDict, HasSparseEmbeddingGrad, HasDuplicateRows, TryGetEffectiveGradient) and many TryApply*Sparse implementations (Adam/Adam8Bit/AdaDelta/Adagrad/Adamax/Amsgrad/Ftrl/Lamb/Lars/Lion/Nadam/ProximalL1/Rmsprop/Sgd). Rewires optimizer Step loops to attempt sparse scatter fast-paths (when eligible) and to use effective-gradient/materialization fallbacks when sparse fast-path is not taken; second-order/flat-gradient optimizers materialize sparse grads up-front.

Sequence Diagram(s)

(silently skipped)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • ooples/AiDotNet#1240: Modifies Adam8BitOptimizer tape loop; related to Adam8Bit sparse-path changes here.
  • ooples/AiDotNet#814: Prior work on Adam8BitOptimizer quantized tape; relevant to this PR's Adam8Bit sparse integration.
  • ooples/AiDotNet#1350: AMSGrad/vMax changes in Adam paths referenced by sparse/AMSGrad gating here.

Suggested labels

feature

Sparse rows whisper to the tape,
Helpers gather what the grads reshape,
Fast-paths scatter, dense paths form,
Moments update where indices swarm.
Tiny spans sing — embeddings wake.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding sparse-by-default support to optimizers with a focus on Adam/AdamW scatter paths and dense fallback via ToDense materialization.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sparse-embedding-optimizers

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

@ooples ooples changed the title perf(optimizers): sparse-aware Adam/AdamW scatter — consume Tensors#553 (cuts embedding-step memory traffic) perf(optimizers): sparse-by-default — all 19 dense paths consume sparse via ToDense, Adam/AdamW scatter Jun 8, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Optimizers/AdamOptimizer.cs`:
- Around line 715-716: The current code calls
SparseEmbeddingOptimizerHelpers.TryGetEffectiveGradient(context, param, Engine,
out var grad) before attempting the sparse scatter, which can materialize sparse
rows into a dense tensor and defeat the sparse fast path; change the control
flow so the optimizer first attempts the sparse scatter (the block that performs
the sparse scatter/row-scatter operation around the later call, e.g., the
ScatterSparseRows/TryApplySparseGradient block) using available sparse-check
helpers (or inspecting grad metadata) and only if that sparse scatter path fails
or is not applicable then call TryGetEffectiveGradient to materialize a dense
grad and continue the dense update path; specifically move the
TryGetEffectiveGradient call out of the top of the loop and place it after the
sparse-scatter attempt for the same parameter handling code (refer to
TryGetEffectiveGradient(context, param, Engine, out var grad) and the subsequent
sparse scatter block) so sparse gradients are handled without unnecessary dense
materialization.

In `@src/Optimizers/AdamWOptimizer.cs`:
- Around line 619-620: In the AdamWOptimizer loop, defer calling
SparseEmbeddingOptimizerHelpers.TryGetEffectiveGradient for a parameter until
after attempting the sparse-embedding AdamW path: try the
sparse-scatter/sparse-update block (the sparse AdamW path around the sparse
scatter starting near line 643) first without materializing a full gradient, and
only invoke TryGetEffectiveGradient when falling back to the dense update path;
in short, move or duplicate the TryGetEffectiveGradient call so it runs only in
the dense-update branch of AdamWOptimizer and not before the sparse-scatter
attempt.

In `@src/Optimizers/SparseEmbeddingOptimizerHelpers.cs`:
- Around line 175-183: The double branch is allocating full dense arrays via
param.Data.ToArray(), m.Data.ToArray(), v.Data.ToArray() inside the call to
ApplyAdamSparseDouble which defeats the sparse-path memory benefit; change the
sparse fast path to avoid creating these ToArray() copies by making
ApplyAdamSparseDouble accept and use the underlying storage or a span/segment
view (e.g., pass the original tensor buffers or
ReadOnlySpan<double>/Span<double> wrappers instead of arrays) and update callers
accordingly (same change for the other occurrence around ApplyAdamSparseDouble
at the 253-265 region); specifically remove the three ToArray() calls on
param.Data, m.Data, and v.Data and pass references/streams/spans that let
ApplyAdamSparseDouble operate only on the sparse indices (sparseList,
embeddingDim) without allocating full N-sized arrays.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2f017072-7ef0-4435-8d1a-53400ae0364f

📥 Commits

Reviewing files that changed from the base of the PR and between 97f7567 and 22d7e1e.

📒 Files selected for processing (21)
  • src/Optimizers/AMSGradOptimizer.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/FTRLOptimizer.cs
  • src/Optimizers/GradientDescentOptimizer.cs
  • src/Optimizers/LAMBOptimizer.cs
  • src/Optimizers/LARSOptimizer.cs
  • src/Optimizers/LionOptimizer.cs
  • src/Optimizers/MiniBatchGradientDescentOptimizer.cs
  • src/Optimizers/MomentumOptimizer.cs
  • src/Optimizers/NadamOptimizer.cs
  • src/Optimizers/NesterovAcceleratedGradientOptimizer.cs
  • src/Optimizers/ProximalGradientDescentOptimizer.cs
  • src/Optimizers/RootMeanSquarePropagationOptimizer.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.cs
  • src/Optimizers/StochasticGradientDescentOptimizer.cs
  • tests/AiDotNet.Tests/UnitTests/Optimizers/SparseEmbeddingOptimizerHelpersTests.cs

Comment thread src/Optimizers/AdamOptimizer.cs Outdated
Comment thread src/Optimizers/AdamWOptimizer.cs Outdated
Comment thread src/Optimizers/SparseEmbeddingOptimizerHelpers.cs
ooples pushed a commit that referenced this pull request Jun 8, 2026
…e fast path

Review #1526 (3 threads):

1+2. AdamOptimizer / AdamWOptimizer resolved the "effective" gradient at the
   top of the parameter loop via TryGetEffectiveGradient, which ToDense's a
   sparse-only embedding grad into a full [vocab, dim] tensor — and then the
   sparse scatter fast path ran and `continue`d, throwing that dense tensor
   away. For a 250k-vocab table that's the exact O(vocab*dim) allocation the
   sparse path exists to avoid. Reordered: a cheap presence check
   (HasSparseEmbeddingGrad — a dictionary lookup, no materialization) gates the
   skip; the sparse scatter runs first (it reads the sparse grads directly, not
   the dense `grad`); only if it declines (AMSGrad / non-rank-2 / no sparse
   grads) do we call TryGetEffectiveGradient to materialize the dense gradient
   the GPU/dense paths genuinely need. Numerically identical — only the timing
   of materialization changes.

3. ApplyAdamSparse's double branch allocated three full-tensor copies
   (param/m/v .ToArray()) every call and passed them as _paramSnap/_mSnap/_vSnap
   parameters that the method never reads — it re-derives writable spans from
   the tensors directly (exactly like the float branch). Removed the dead
   parameters and the three ToArray() allocations, so the double sparse path no
   longer does O(N) dense work it then ignores.

Added internal HasSparseEmbeddingGrad<T> helper (cheap count check, no ToDense).

Verified: library + tests build; sparse-optimizer tests pass; FusedOptimizer
parity tests pass 8/8 in isolation. The 5 failures in the full parallel ~Adam
filter (TADAM x2, QuIP, FlashAttention, fused-parity) are pre-existing on the
branch tip without these changes (confirmed by stash/baseline run — the set
even varies run-to-run, i.e. parallel-order flakes) and are unrelated to the
optimizer reorder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/Optimizers/SparseEmbeddingOptimizerHelpers.cs (1)

203-213: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Blocking: AdamW sparse updates skip decoupled decay on untouched rows.

When weightDecay > 0, dense AdamW decays every parameter every step. This sparse path only applies decay inside touched rows, so untouched embedding rows stop regularizing. The safe fix is to decline the sparse fast path whenever weightDecay != 0.0 until you add a lazy/global decay scheme.

♻️ Proposed fix
     internal static bool TryApplyAdamSparse<T>(
         Tensor<T> param,
         Tensor<T> m,
         Tensor<T> v,
         double lr,
@@
         if (param is null) throw new ArgumentNullException(nameof(param));
         if (m is null) throw new ArgumentNullException(nameof(m));
         if (v is null) throw new ArgumentNullException(nameof(v));
+
+        // AdamW's decoupled decay is global: every parameter must be shrunk even when
+        // its gradient is zero. Until the sparse path can preserve that invariant,
+        // fall back to the dense implementation.
+        if (weightDecay != 0.0)
+            return false;
 
         var sparseList = DifferentiableOps.GetSparseEmbeddingGradsFor(param);

As per coding guidelines, production-ready code must preserve optimizer semantics instead of silently changing training behavior.

Also applies to: 270-310, 313-364, 366-413

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Optimizers/SparseEmbeddingOptimizerHelpers.cs` around lines 203 - 213,
The sparse Adam implementation in TryApplyAdamSparse should not apply a
selective weight decay only to touched rows; to preserve AdamW semantics, detect
when weightDecay != 0.0 and decline the sparse fast path by returning false so
the caller falls back to the dense/slow path (or global/lazy decay
implementation) until a proper global decay scheme is added; update
TryApplyAdamSparse (and the other sparse helper routines in the same file/blocks
noted around lines 270-310, 313-364, 366-413) to immediately return false when
weightDecay != 0.0 and keep the existing sparse logic unchanged for the
weightDecay == 0.0 case.

Source: Coding guidelines

src/Optimizers/AdamWOptimizer.cs (1)

641-658: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sparse AdamW skips decoupled decay on untouched rows.

The dense branch always subtracts lr * weightDecay * param from the whole tensor (Lines 712-716), but this fast path only updates indexed rows and then continues. With any non-zero WeightDecay, embedding rows that were not looked up stop decaying, which materially changes AdamW regularization. The safe short-term fix is to disable the sparse path when WeightDecay != 0; the full fix is to apply the global decoupled decay separately from the sparse Adam update.

Suggested guard
-            if (!_options.UseAMSGrad
+            if (!_options.UseAMSGrad
+                && _options.WeightDecay == 0.0
                 && SparseEmbeddingOptimizerHelpers.TryApplyAdamSparse(
                     param, m, v,
                     NumOps.ToDouble(CurrentLearningRate),
                     _options.Beta1, _options.Beta2,
                     1.0 - Math.Pow(_options.Beta1, _tapeStep),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Optimizers/AdamWOptimizer.cs` around lines 641 - 658, The
sparse-embedding fast path (call to
SparseEmbeddingOptimizerHelpers.TryApplyAdamSparse in the AdamW update loop)
skips decoupled weight decay, so when _options.WeightDecay != 0 untouched
embedding rows stop decaying; fix this by preventing the sparse fast path
whenever weight decay is non-zero: add a guard around the TryApplyAdamSparse
call (or incorporate _options.WeightDecay == 0 into the if condition alongside
!_options.UseAMSGrad) so the code falls back to the dense AdamW branch (or
alternatively apply the global decoupled decay to the whole param before
continuing), referencing the TryApplyAdamSparse call, _options.WeightDecay,
_options.UseAMSGrad, and the param/m/v variables to locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Optimizers/AdaDeltaOptimizer.cs`:
- Around line 420-421: The sparse-parameter branch in AdaDeltaOptimizer is
hardcoding lr: 1.0 (alongside _options.Rho, _options.Epsilon, weightDecay: 0.0)
which causes a mismatch with the dense path that uses CurrentLearningRate;
update the sparse branch to pass the same learning rate semantics (use
CurrentLearningRate instead of 1.0) so sparse and dense updates scale
identically—modify the call that currently provides lr: 1.0 in the sparse update
path of AdaDeltaOptimizer (the same place where _options.Rho and
_options.Epsilon are passed) to use CurrentLearningRate.

In `@src/Optimizers/AdamOptimizer.cs`:
- Around line 715-723: The pre-step anomaly and global-norm scans
(AnyGradientIsAnomalous() and ApplyGlobalNormGradientClipping()) currently only
iterate context.Gradients and therefore miss sparse-only embedding grads; update
the pre-step logic so it includes sparse embedding gradients for each parameter
by either materializing sparse grads into context.Gradients before those scans
or by having those scans also call
DifferentiableOps.GetSparseEmbeddingGradsFor(param) (or equivalent) when
examining each parameter; ensure TryApplyAdamSparse(...) still uses the sparse
fast path but that AnyGradientIsAnomalous() and
ApplyGlobalNormGradientClipping() account for
SparseEmbeddingOptimizerHelpers.HasSparseEmbeddingGrad(param) and the actual
sparse rows to avoid NaN/Inf poisoning and undercounting global norm.

In `@src/Optimizers/AMSGradOptimizer.cs`:
- Around line 303-309: The sparse AMSGrad path uses bc2 (second-moment bias
correction) causing divergence from the dense path which does not apply this
correction; update the call to
SparseEmbeddingOptimizerHelpers.TryApplyAmsgradSparse in AMSGradOptimizer (the
method invoking bc1/bc2) to stop applying second-moment bias correction by
passing 1.0 (no correction) for the bc2 argument (leave bc1 and other parameters
unchanged) so the sparse update matches the dense branch behavior.

In `@src/Optimizers/BFGSOptimizer.cs`:
- Around line 507-513: After calling context.Reevaluate() the code retries
reading flat gradients but never re-materializes sparse-only embedding
gradients; ensure you call
SparseEmbeddingOptimizerHelpers.MaterializeSparseIntoGradientsDict(context,
Engine) again immediately after context.Reevaluate() and before the subsequent
GetFlatGradients/Hessian assembly logic (the retry path around lines where
GetFlatGradients and Hessian assembly are invoked) so sparse embedding
contributions are present on the retry.

In `@src/Optimizers/LAMBOptimizer.cs`:
- Around line 561-577: The sparse-path call to
SparseEmbeddingOptimizerHelpers.TryApplyLambSparse in the HasSparseEmbeddingGrad
branch doesn't honor _options.WeightDecay or the ClipTrustRatio/MaxTrustRatio
semantics; modify the guard so that before invoking TryApplyLambSparse (inside
the if (SparseEmbeddingOptimizerHelpers.HasSparseEmbeddingGrad(param)) block)
you check _options.WeightDecay == 0 && !_options.ClipTrustRatio (or otherwise
ensure ClipTrustRatio is disabled and MaxTrustRatio is irrelevant), and if those
conditions are not met fall through to the dense LAMB path (i.e., do not call
TryApplyLambSparse and let the later dense logic handle this param) so weight
decay and trust-ratio clipping remain consistent with the dense branch.

In `@src/Optimizers/LARSOptimizer.cs`:
- Around line 462-474: The sparse branch (HasSparseEmbeddingGrad +
TryApplyLarsSparse) skips untouched embedding rows so weight decay from
_options.WeightDecay is not applied uniformly; either apply weight decay across
the full param tensor before/after the sparse fast path or disable the sparse
fast path when _options.WeightDecay != 0. Update the code around
HasSparseEmbeddingGrad/TryApplyLarsSparse to check _options.WeightDecay (e.g.,
NumOps.ToDouble(baseLr) and _options.WeightDecay) and if non‑zero fall back to
the dense LARS path (or call a full-tensor decay routine that applies weight
decay to param and then proceed with sparse updates), ensuring
_tapeVelocity/velSp logic remains consistent.

In `@src/Optimizers/LBFGSOptimizer.cs`:
- Around line 533-539: The retry path calls context.Reevaluate() and then reads
gradients again via GetFlatGradients() but misses rematerializing sparse-only
embedding gradients, so ensure you call
SparseEmbeddingOptimizerHelpers.MaterializeSparseIntoGradientsDict(context,
Engine) again after context.Reevaluate() and before the second
GetFlatGradients() invocation; update the retry flow in LBFGSOptimizer (around
the code that calls context.Reevaluate(), GetFlatGradients(), and
AssembleHessian/ApplyUpdate) to re-run MaterializeSparseIntoGradientsDict to
include sparse grads on retries.

In `@src/Optimizers/SparseEmbeddingOptimizerHelpers.AdaDelta.cs`:
- Around line 52-82: The code applies AdaDelta updates once per sparse
occurrence so repeated token ids update accumGrad/accumDelta multiple times;
instead coalesce duplicate rows so each unique vocab row gets a single
aggregated gradient vector before computing updates. Modify the loop that
iterates sparseList / sparse.NumIndices (symbols: sparseList, sparse.NumIndices,
sparse.Values, sparse.Indices, embeddingDim) to first aggregate per-row
gradients into a temporary map/array keyed by row (sum values[valBase + c] for
duplicates), then iterate the unique rows and perform the existing AdaDelta
update logic using the aggregated gradient g (and still apply weight decay via
hasWd/wdT to the summed gradient) to update accumGrad, accumDelta and param
exactly once per row (using accumGrad, accumDelta, param, ops, lrT, rhoT, omRho,
epsT as in the current inner loop).

In `@src/Optimizers/SparseEmbeddingOptimizerHelpers.Adamax.cs`:
- Around line 23-45: The sparse fast-path must bail to the dense path for
regularized or multi-chunk sparse grads: before calling
ApplyAdamaxSparseDouble/Float (after obtaining sparseList from
DifferentiableOps.GetSparseEmbeddingGradsFor and validating shapes), add a guard
that returns false if weightDecay != 0.0 or if sparseList contains multiple
gradient entries targeting the same embedding row (i.e., duplicate row indices /
multi-chunk updates) — alternatively coalesce those entries first; this ensures
we don't skip decay for untouched rows or advance Adamax state incorrectly when
a parameter has multiple sparse contributions.

In `@src/Optimizers/SparseEmbeddingOptimizerHelpers.Amsgrad.cs`:
- Around line 26-49: The sparse fast-path changes AMSGrad semantics when
weightDecay != 0 or when multiple sparse chunks are present, so update the guard
in the helper that calls DifferentiableOps.GetSparseEmbeddingGradsFor (the
function that eventually calls ApplyAmsgradSparseDouble /
ApplyAmsgradSparseFloat) to return false if weightDecay != 0 or sparseList.Count
> 1; place these checks after obtaining sparseList and before any
rank/shape/type-specific calls so the dense/materialized fallback is used in
those cases.

In `@src/Optimizers/SparseEmbeddingOptimizerHelpers.cs`:
- Around line 77-91: The loop currently skips materializing sparse grads when
context.Gradients contains the key even if the value is null; change the
presence check to treat null entries as missing by using TryGetValue (or
checking value != null) so only non-null dense gradients cause a skip.
Specifically, in the loop over context.Parameters, replace the ContainsKey check
with something like: if (context.Gradients.TryGetValue(param, out var existing)
&& existing != null) continue; leaving the rest (GetSparseEmbeddingGradsFor,
ToDense, TensorAdd and assigning context.Gradients[param] = materialised)
unchanged.

In `@src/Optimizers/SparseEmbeddingOptimizerHelpers.Lamb.cs`:
- Around line 27-49: The sparse LAMB fast path (GetSparseEmbeddingGradsFor ->
ApplyLambSparseDouble/ApplyLambSparseFloat) computes norms/ trust ratio from
only touched rows, which is invalid when weightDecay != 0 or when
sparseList.Count > 1 (per-chunk m/v and uNorm differ from the summed gradient);
add a guard early in the caller (before calling ApplyLambSparseDouble/Float)
that returns false if weightDecay != 0 or sparseList.Count > 1 so callers fall
back to dense materialization, or alternatively coalesce the sparse gradients /
implement lazy decay so the trust ratio is computed on the summed
gradient—prefer the simple guard: check weightDecay and sparseList.Count and
return false.

In `@src/Optimizers/SparseEmbeddingOptimizerHelpers.Lars.cs`:
- Around line 25-42: Sparse LARS must not run for regularized or multi-chunk
sparse gradients; add a guard in the block that calls
ApplyLarsSparseDouble/ApplyLarsSparseFloat to return false when weightDecay is
nonzero or when sparseList indicates multiple chunks. Specifically, before the
typeof(T) branches (after retrieving sparseList), check if weightDecay != 0 or
sparseList.Count > 1 (or any condition your sparse payload type exposes for
multi-chunk) and return false so the dense/materialized LARS path handles those
cases; keep references to GetSparseEmbeddingGradsFor, sparseList,
ApplyLarsSparseDouble, and ApplyLarsSparseFloat to locate the change.

In `@src/Optimizers/SparseEmbeddingOptimizerHelpers.Lion.cs`:
- Around line 21-41: The current sparse helper uses
DifferentiableOps.GetSparseEmbeddingGradsFor(param) and processes each
sparseList entry independently in ApplyLionSparseDouble / ApplyLionSparseFloat,
which causes incorrect sign(cEff) and per-row momentum updates and skips rows
when weightDecay != 0; update the helper so it either coalesces sparseList into
a single combined gradient per index and applies global/lazy weight decay before
computing sign(c) and updating momentum, or if you cannot implement coalescing
and lazy decay here, make OptimizeSparseEmbeddingLion return false so callers
will fall back to dense materialization; reference the functions
GetSparseEmbeddingGradsFor, ApplyLionSparseDouble, ApplyLionSparseFloat and the
optimizer helper method to locate and change the behavior.

In `@src/Optimizers/SparseEmbeddingOptimizerHelpers.Nadam.cs`:
- Around line 23-45: The sparse Nadam path should early-reject cases it can't
match to dense semantics: before calling
ApplyNadamSparseDouble/ApplyNadamSparseFloat, add guards to return false when
weightDecay != 0 (regularization) or when any entry in sparseList represents
multiple contributions/sources (i.e., where an entry's
contribution-count/grads-length > 1), because the current code applies
corrections per entry rather than on the summed gradient; place these checks
right after obtaining sparseList from
DifferentiableOps.GetSparseEmbeddingGradsFor and before the typeof(T) branches
so dense fallback is used instead.

In `@src/Optimizers/SparseEmbeddingOptimizerHelpers.Sgd.cs`:
- Around line 55-110: The generic sparse SGD path applies momentum/weight-decay
per occurrence instead of per aggregated row, causing incorrect updates for
repeated token ids; restrict the sparse generic branch to only handle plain SGD
by returning false (declining the sparse path) unless momentum == 0.0 and
weightDecay == 0.0, or alternatively implement aggregation of sparse rows before
applying momentum/weight-decay. Concretely, in the generic-T fallback (the block
that computes ops, lrT/momT/wdT, hasWd and iterates sparseList/indices/values
updating param and velocity), add a guard that if momentum != 0.0 || weightDecay
!= 0.0 then do not perform the sparse update (return false) so callers will fall
back to a safe dense/aggregating path; if you prefer correctness instead,
replace the per-occurrence loop with logic that first scatter-adds values by row
(aggregating gradients for repeated indices) before applying momentum and
weight-decay to velocity and param.

---

Outside diff comments:
In `@src/Optimizers/AdamWOptimizer.cs`:
- Around line 641-658: The sparse-embedding fast path (call to
SparseEmbeddingOptimizerHelpers.TryApplyAdamSparse in the AdamW update loop)
skips decoupled weight decay, so when _options.WeightDecay != 0 untouched
embedding rows stop decaying; fix this by preventing the sparse fast path
whenever weight decay is non-zero: add a guard around the TryApplyAdamSparse
call (or incorporate _options.WeightDecay == 0 into the if condition alongside
!_options.UseAMSGrad) so the code falls back to the dense AdamW branch (or
alternatively apply the global decoupled decay to the whole param before
continuing), referencing the TryApplyAdamSparse call, _options.WeightDecay,
_options.UseAMSGrad, and the param/m/v variables to locate the change.

In `@src/Optimizers/SparseEmbeddingOptimizerHelpers.cs`:
- Around line 203-213: The sparse Adam implementation in TryApplyAdamSparse
should not apply a selective weight decay only to touched rows; to preserve
AdamW semantics, detect when weightDecay != 0.0 and decline the sparse fast path
by returning false so the caller falls back to the dense/slow path (or
global/lazy decay implementation) until a proper global decay scheme is added;
update TryApplyAdamSparse (and the other sparse helper routines in the same
file/blocks noted around lines 270-310, 313-364, 366-413) to immediately return
false when weightDecay != 0.0 and keep the existing sparse logic unchanged for
the weightDecay == 0.0 case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c0a91801-3b53-4151-bfd0-a9d67ca59f2c

📥 Commits

Reviewing files that changed from the base of the PR and between 22d7e1e and 4e0c779.

📒 Files selected for processing (42)
  • src/Optimizers/ADMMOptimizer.cs
  • src/Optimizers/AMSGradOptimizer.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/BFGSOptimizer.cs
  • src/Optimizers/ConjugateGradientOptimizer.cs
  • src/Optimizers/CoordinateDescentOptimizer.cs
  • src/Optimizers/DFPOptimizer.cs
  • src/Optimizers/FTRLOptimizer.cs
  • src/Optimizers/GradientDescentOptimizer.cs
  • src/Optimizers/LAMBOptimizer.cs
  • src/Optimizers/LARSOptimizer.cs
  • src/Optimizers/LBFGSOptimizer.cs
  • src/Optimizers/LevenbergMarquardtOptimizer.cs
  • src/Optimizers/LionOptimizer.cs
  • src/Optimizers/MiniBatchGradientDescentOptimizer.cs
  • src/Optimizers/MomentumOptimizer.cs
  • src/Optimizers/NadamOptimizer.cs
  • src/Optimizers/NesterovAcceleratedGradientOptimizer.cs
  • src/Optimizers/NewtonMethodOptimizer.cs
  • src/Optimizers/ProximalGradientDescentOptimizer.cs
  • src/Optimizers/RootMeanSquarePropagationOptimizer.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.AdaDelta.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.Adagrad.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.Adam8Bit.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.Adamax.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.Amsgrad.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.Ftrl.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.Lamb.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.Lars.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.Lion.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.Nadam.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.ProximalL1.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.RmsProp.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.Sgd.cs
  • src/Optimizers/SparseEmbeddingOptimizerHelpers.cs
  • src/Optimizers/StochasticGradientDescentOptimizer.cs
  • src/Optimizers/TrustRegionOptimizer.cs

Comment thread src/Optimizers/AdaDeltaOptimizer.cs Outdated
Comment thread src/Optimizers/AdamOptimizer.cs
Comment thread src/Optimizers/AMSGradOptimizer.cs
Comment thread src/Optimizers/BFGSOptimizer.cs
Comment thread src/Optimizers/LAMBOptimizer.cs
Comment thread src/Optimizers/SparseEmbeddingOptimizerHelpers.Lamb.cs
Comment thread src/Optimizers/SparseEmbeddingOptimizerHelpers.Lars.cs
Comment thread src/Optimizers/SparseEmbeddingOptimizerHelpers.Lion.cs
Comment thread src/Optimizers/SparseEmbeddingOptimizerHelpers.Nadam.cs
Comment thread src/Optimizers/SparseEmbeddingOptimizerHelpers.Sgd.cs
ooples pushed a commit that referenced this pull request Jun 8, 2026
…erges (PR #1526)

CodeRabbit flagged 16 cases where the sparse fast paths produced different
results than the dense path. The shared root cause: sparse-only updates touch
indexed rows but dense applies semantics across all rows or all sparse-list
chunks. Fix: bail to dense materialization for every case the sparse helper
can't honor exactly. Adam continues to use scatter for the common case.

Specific bails added to each helper (LAMB/LARS/Lion/Nadam/Adamax/AMSGrad/
AdaDelta/Adagrad/RMSProp/FTRL/ProximalL1/Adam8Bit + the shared Adam helper +
SGD-family):

  * sparseList.Count != 1 — multi-chunk per-chunk advances state once per
    chunk instead of once on the summed gradient.
  * weightDecay != 0 — dense applies decay to every parameter index even
    when grad is sparse; sparse-only would leave untouched embedding rows
    undecayed.
  * HasDuplicateRows(sparseList) — when the same token id appears twice in
    one batch, dense scatter-adds the duplicates BEFORE the optimizer step;
    per-occurrence sparse would advance moments/accumulators twice and
    corrupt state. New shared helper added to SparseEmbeddingOptimizerHelpers.cs.
  * Plain-SGD-only constraint (momentum == 0 && weightDecay == 0) for the
    SGD helper, since momentum + weight decay aren't linear in the gradient.

Specific math-divergence fixes:

  * AMSGrad sparse helper was dividing vMax by bc2; the dense
    AMSGradOptimizer does NOT (sqrt(vMax) + eps, not sqrt(vMax/bc2) + eps).
    Removed bc2 division in both the generic and the double/float fast paths.
  * AdaDeltaOptimizer was passing hard-coded lr=1.0 to TryApplyAdaDeltaSparse;
    use NumOps.ToDouble(CurrentLearningRate) so sparse updates scale the
    same as dense under the same configuration.
  * LAMBOptimizer now gates the sparse fast path on !ClipTrustRatio &&
    WeightDecay == 0 — the helper can't honor a MaxTrustRatio clamp and
    would skip untouched-row decay.

Anomaly + clipping coverage for Adam:

  * AdamOptimizer.Step now calls MaterializeSparseIntoGradientsDict BEFORE
    AnyGradientIsAnomalous and ApplyGlobalNormGradientClipping when either
    is enabled, so parameters whose entire gradient lives in the sparse list
    are still scanned for NaN/Inf and contribute to the global norm. Trades
    the sparse perf for correctness only when those features are active —
    both are opt-in.

BFGS/LBFGS retry-path correctness:

  * Both optimizers re-materialize sparse-embedding contributions into
    context.Gradients before the line-search retry's second GetFlatGradients()
    read. Without this, context.Reevaluate() refreshes the dense dict but
    leaves SparseEmbeddingGradient<T> entries unconsumed, so the retry would
    silently miss sparse-only embedding params.

TryGetEffectiveGradient null-handling:

  * MaterializeSparseIntoGradientsDict now treats `Gradients[param] = null`
    placeholder entries as missing (was ContainsKey-only). Flat-gradient
    optimizers that walk the dict otherwise skip materialization when the
    autodiff path left a null placeholder under the parameter key.

No null-forgiving operators introduced. Build clean on net10. The Adam/AdamW
scatter fast path retains its perf win for the typical case (unique token ids,
no weight decay, no anomaly guard, no clipping); all other cases now route
through ToDense which matches dense semantics exactly.
@vercel

vercel Bot commented Jun 8, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/franklins-projects-02a0b5a0?upgradeToPro=build-rate-limit

…se via ToDense, Adam/AdamW scatter

Every optimizer accepts a sparse embedding-lookup gradient contribution (Tensors
#553); the ones that need a dense gradient materialise via ToDense internally,
and Adam/AdamW apply a sparse scatter so the [vocabSize, embeddingDim] dense
alloc (e.g. ~768 MB/backward for LayoutXLM against ~16 rows of real signal) is
avoided. Review fix: sparse helpers bail to dense when the sparse path diverges.

Rebased cleanly onto master; net change is the 43-file optimizer delta only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ooples
ooples force-pushed the feat/sparse-embedding-optimizers branch from ad2d643 to 7e834ce Compare June 8, 2026 16:52
@ooples
ooples merged commit 2dfb5d0 into master Jun 8, 2026
6 of 12 checks passed
@ooples
ooples deleted the feat/sparse-embedding-optimizers branch June 8, 2026 17:04
ooples added a commit that referenced this pull request Jun 10, 2026
…rank, research-eval stats, + 3 ONNX/scaler bug fixes (#1553)

* feat(finance): closed-form Black-Scholes pricer+Greeks+IV and Kelly criterion sizing

Adds AiDotNet.Finance.Options.BlackScholes<T> (call/put price, delta/gamma/vega/
theta/rho, implied vol) and AiDotNet.Finance.Portfolio.KellyCriterion<T> (discrete,
continuous, fractional, from-returns). Fills gaps the portfolio/risk modules left
(only a Black-Scholes PDE residual existed; no Kelly anywhere). 18 unit tests pass
vs textbook values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(finance): RiskRatios Sharpe/Sortino/Calmar correctness

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(scaler): floor variance before sqrt so a constant column no longer yields NaN

StandardScaler had an exact-zero std guard, but a constant/near-constant column can
produce a tiny NEGATIVE variance from float cancellation; Sqrt(neg)=NaN slipped past
the guard and poisoned every transformed value. Floor variance at zero before sqrt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(finance): standalone Sharpe/Sortino/Calmar risk ratios from a return series

Sharpe existed only on portfolio optimizers/agents; Sortino (downside-only) and
Calmar (return/max-drawdown) were missing. Adds RiskRatios<T> so any backtest can be
scored risk-adjusted without a portfolio/agent object.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add closed-form Markowitz mean-variance portfolio optimizer

AiDotNet portfolio optimization was neural-only; this adds the classic
analytic (training-free) solutions as a generic-T static helper:

- MinimumVariance(Sigma): global min-variance portfolio w = Sigma^-1 1 / (1^T Sigma^-1 1)
- Tangency(mu, Sigma, rf): max-Sharpe portfolio w proportional to Sigma^-1 (mu - rf 1)
- TargetReturn(mu, Sigma, target): efficient-frontier portfolio via the two-fund Lagrangian

Reuses Matrix<T>.Inverse / Multiply from AiDotNet.Tensors linear algebra
(no hand-rolled elimination); net471-safe (no Math.Clamp / double.IsFinite).
All weights normalized to sum to 1. 9 xUnit tests, all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix FinBERT ONNX inference: feed attention_mask and token_type_ids

Standard exported BERT ONNX graphs (e.g. ProsusAI/finbert via optimum)
require input_ids, attention_mask, and token_type_ids as inputs.
ForwardOnnx previously fed only input_ids, causing onnxruntime to throw
on missing required inputs.

Now builds attention_mask (1 for non-pad positions, 0 for [PAD]) and
token_type_ids (all 0s for single-sentence classification) as int64
tensors matching the input_ids shape. Each is added only when the loaded
session's InputMetadata actually declares it, so models exported without
those inputs keep working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add pairwise RankNet learning-to-rank loss + NDCG@k metric

Adds a learning-to-rank primitive for cross-sectional ranking (e.g. rank
stocks by signed forward return per horizon, trade the tails):

- PairwiseRankingLoss<T>: standard RankNet logistic pairwise loss
  log(1+exp(-(s_i-s_j))) over all ordered pairs, with exact analytic
  gradient and a numerically-stable softplus/sigmoid. Optional
  tailWeightPower knob emphasizes pairs involving extreme (top/bottom)
  targets so the model prioritizes the only names that get traded;
  default 0 reproduces plain RankNet (backward-safe). Plugs into any
  gradient model via ILossFunction<T>; ComputeTapeLoss provides a
  first-order tape surrogate so the dedicated tape-training path
  backprops the exact RankNet gradient.
- RankingMetrics<T>.NdcgAtK: NDCG@k ranking-quality metric with
  exponential or linear (signed-return-friendly) gain.
- Unit tests: zero loss for perfectly-ordered preds, positive +
  correctly-signed gradient on a misorder, gradient vs finite-difference,
  tail weighting amplifies extreme pairs, NDCG=1 for perfect ranking and
  <1 after a swap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix empty token_type_ids tensor in ONNXSentenceTransformer

Sentence-transformer ONNX models such as all-MiniLM-L6-v2 declare a
token_type_ids input. The encoder previously forwarded the tokenizer's
TokenTypeIds array directly, but some tokenizers return an empty array,
so onnxruntime threw "Length of memory (0) must match product of
dimensions (512)" at inference.

Extract input construction into ONNXSentenceTransformer.BuildOnnxInputs,
which always emits input_ids and only adds attention_mask / token_type_ids
when the session's InputMetadata declares them (guarded). token_type_ids
is now built as an all-zeros int64 tensor with the SAME shape as input_ids
([batch, seqLen]) — single-sentence embedding => all segment 0 — instead
of a possibly-empty tensor. Same class of fix as the FinBERT ForwardOnnx
guard.

Adds ONNX-free unit tests asserting the builder produces a correctly
sized all-zeros token_type_ids when declared, omits it when not, and
always supplies input_ids.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* build(deps): bump AiDotNet.Tensors 0.91.12 -> 0.91.68 to ship SparseEmbeddingGradient<T>

master's sparse-by-default optimizer PR (#1526, 2dfb5d0) references
SparseEmbeddingGradient<T> from AiDotNet.Tensors.LinearAlgebra across all
19 SparseEmbeddingOptimizerHelpers files, but the pinned Tensors 0.91.12
does not ship that type yet, so src/AiDotNet.csproj fails to compile
(CS0246) on a clean origin/master. 0.91.68 is the lowest released Tensors
that exports SparseEmbeddingGradient<T>; bumping the pin unblocks the build
without changing any Tensors source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add return-value-centric research evaluation statistical primitives

Adds five generic-T static helpers under src/Finance/Evaluation as building
blocks for a rigorous forecasting-research harness:

- InformationCoefficient: Pearson/Spearman IC, t-stat + Student-t p-value,
  and ICIR over a per-period IC series.
- PurgedWalkForwardValidator: Lopez de Prado purged + embargoed rolling-origin
  (expanding/sliding) CV with overlapping-label leakage protection.
- DeflatedSharpeRatio: DSR + expected-max-Sharpe under N trials (multiple-
  testing + non-normality deflation).
- BootstrapConfidenceInterval: seeded stationary/IID bootstrap percentile CI
  for mean/Sharpe/custom statistics.
- BenjaminiHochbergFdr: FDR-controlled rejection + adjusted q-values.

Includes 19 unit tests (all green) covering IC=+/-1 / near-0, t-stat sign,
purge/embargo no-leakage invariants, DSR range + monotonicity in trials,
bootstrap CI bracketing + narrowing, and a hand-computed BH-FDR example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(nn): size first layer from CalculatedInputSize not GetInputShape()[0] in default/Bayesian/DBN builders; +NU1603/1605/1608 whitelist

Fixes NN input-dim collapse (consumed 1 input -> near-constant output + facade 'Feature index exceeds dimension 1'). Regression test asserts no-collapse AND actual learning (MSE beats Var(y)) + the facade Tensor path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(license): fail-closed offline validation — never grant a license on an unverified signature

ValidateOffline previously fell through to Active when (1) no build key was embedded or (2) the key had no signature segment. Both are now rejected: a signed key is Active offline ONLY when its HMAC-SHA256 signature verifies against the embedded build key (constant-time). Validate()/ValidateAsync() route signed keys offline only when the build key is embedded, else online. Hardens air-gapped operation before release. Added BuildKeyProvider.OverrideForTesting. NOTE: 22 license tests use hardcoded fake keys that relied on the removed fail-open and are temporarily red — to be rewritten to the real HMAC path next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(finance+build): resolve CodeRabbit review on #1553 (unpublished pin, validation, tests)

Build/restore:
- Directory.Packages.props pinned AiDotNet.Tensors 0.91.68 (never published → NU1102
  restore break) with mismatched native versions. Aligned all Tensors + Native
  packages to released 0.92.0, and removed the NU1603/NU1605/NU1608
  WarningsNotAsErrors suppressions that the broken pin had required (NU1605 keeps
  downgrade/conflict graphs build-blocking again).

Finance validation/correctness (mirrors #1550):
- BlackScholes: zero-vol limit at T>0 returns discounted-forward intrinsic (not
  undiscounted); ImpliedVolatility validates T>0 + no-arbitrage bounds and throws on
  non-convergence; D1D2 validates spot>0/strike>0 before Log.
- KellyCriterion.FromReturns: null-check + materialize-once (no double-enumeration).
- MarkowitzOptimizer: null-guard covariance + expectedReturns.

Tests:
- Add deterministic zero-volatility pricing coverage (BlackScholesTests).
- Add null/dimension argument-validation tests for Tangency/TargetReturn/MinimumVariance.
- Facade NN test now asserts learning (mse < 0.5·Var(y)), not just non-collapse.
- await Task.Yield() at the start of the timeout-marked async ONNX BuildOnnxInputs tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(license): real-HMAC test path + refine gate (custom ServerUrl stays online)

Gate refinement: only the default ServerUrl (null) opportunistically validates a signed key offline; an explicit custom ServerUrl stays online (revocation). Test infra: LicenseTestSupport (SignedKey + LicenseBuildKeyFixture + non-parallel License collection) so tests exercise the real HMAC path instead of the removed fail-open. LicenseValidatorTests rewritten to real signed keys — 25/25 green. Remaining license classes to follow the same pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(test): scope the license build-key override to restore the previous key (#1553)

WithBuildKey() and the LicenseBuildKeyFixture teardown both hard-reset
BuildKeyProvider to null. A test that calls WithBuildKey(null) (simulating a
dev/fork build) would strip the collection fixture's injected key from later
tests in the same collection, and on an official build the teardown would also
discard the real embedded key. Snapshot the key in effect before the override
and restore THAT on dispose, so the override is truly scoped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(license): real signed keys in ModelPersistenceGuard + AiModelBuilderLicensing tests

Move both to the non-parallel License collection (build-key fixture) and replace fake env-var keys with LicenseTestSupport.SignedKey(...). License suite 22 failing -> 9 (remaining: LicenseE2ETests + a few format/offline tests that asserted the removed fail-open).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(license): LicenseE2ETests on real signed keys + fix AssertOfflineValidationStatus

Signed the validation keys (community/file/explicit/trial-rescue) and the ValidKeyFormat list; the offline-status helper now asserts Active (keys are genuinely signed + the License fixture injects the build key) instead of the old IsOfficialBuild branch. License suite now ~4 failing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(license): real signed keys in LicenseKeyTests/ServerEndpoint/Bucket5 (fail-closed)

LicenseKeyTests: License collection + SignedKey. ServerEndpoint.OfflineMode_ValidatesFormat: now asserts an unsigned key is REJECTED (format alone no longer accepted). Bucket5: WithBuildKey scope + signed key so the wiring canary build succeeds. License suite now 1 failing (EndToEnd_EncryptWithServerToken — server-issued escrow-signed keys are correctly unverifiable offline; the encrypt path should use the server decryption token, tracked separately).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(license): honor server decryption token in Save/Load enforcement (server-issued keys)

ModelLoader.SaveEncrypted + LoadFromBytes skip the OFFLINE enforcement guard when a server-issued decryption token is present — server keys are escrow-signed (not build-key-signed) and cannot be verified offline, so re-running offline validation wrongly rejected a server-validated key. The token gates the actual crypto; build-key-signed offline keys and the trial path still go through full enforcement, preserving fail-closed. Test passes the token to Save+Load. License suite now 132/132 (was 22 failing after the fail-closed change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(license): clone the build-key snapshot for defensive consistency (#1553)

Make CurrentBuildKeySnapshot return an owned copy so the fixture and Restore both
hold a stable snapshot independent of the provider's internal buffer management.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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