diff --git a/.github/test-shards.yml b/.github/test-shards.yml
index c8ebdea359..9a5169f3ef 100644
--- a/.github/test-shards.yml
+++ b/.github/test-shards.yml
@@ -659,7 +659,7 @@ shard:
framework: net10.0
filter: >-
FullyQualifiedName~ModelFamilyTests.Generated&
- (FullyQualifiedName~Generated.Tem|FullyQualifiedName~Generated.Tex|FullyQualifiedName~Generated.Tho|FullyQualifiedName~Generated.Thr|FullyQualifiedName~Generated.TiD|FullyQualifiedName~Generated.TiM|FullyQualifiedName~Generated.Tim|FullyQualifiedName~Generated.Tin|FullyQualifiedName~Generated.Tit|FullyQualifiedName~Generated.Too|FullyQualifiedName~Generated.Tor|FullyQualifiedName~Generated.Tra|FullyQualifiedName~Generated.Tri)
+ (FullyQualifiedName~Generated.Tem|FullyQualifiedName~Generated.Tex|FullyQualifiedName~Generated.Tho|FullyQualifiedName~Generated.Thr|FullyQualifiedName~Generated.TiD|FullyQualifiedName~Generated.TiM|FullyQualifiedName~Generated.Tim|FullyQualifiedName~Generated.Tin|FullyQualifiedName~Generated.Tit|FullyQualifiedName~Generated.Too|FullyQualifiedName~Generated.Tor|FullyQualifiedName~Generated.Tra|FullyQualifiedName~Generated.Tri|FullyQualifiedName~Generated.TrO)
- name: ModelFamily - Generated Layers U
project: tests/AiDotNet.Tests/AiDotNetTests.csproj
framework: net10.0
diff --git a/review-tests/Pr2154.APRangeBenchmark/Pr2154.APRangeBenchmark.csproj b/review-tests/Pr2154.APRangeBenchmark/Pr2154.APRangeBenchmark.csproj
new file mode 100644
index 0000000000..a8fcfee1cd
--- /dev/null
+++ b/review-tests/Pr2154.APRangeBenchmark/Pr2154.APRangeBenchmark.csproj
@@ -0,0 +1,13 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+ $(MSBuildThisFileDirectory)../../src
+
+
+
+
+
diff --git a/review-tests/Pr2154.APRangeBenchmark/Program.cs b/review-tests/Pr2154.APRangeBenchmark/Program.cs
new file mode 100644
index 0000000000..18b0ea8c0d
--- /dev/null
+++ b/review-tests/Pr2154.APRangeBenchmark/Program.cs
@@ -0,0 +1,147 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text.Json;
+using AiDotNet.Augmentation.Image;
+using AiDotNet.ComputerVision.Detection.ObjectDetection;
+using AiDotNet.Metrics;
+using AiDotNet.Tensors.Engines;
+
+// A CPU metric microbenchmark, not a detector/GPU pipeline benchmark. Identical source, inputs,
+// warmups and iteration counts are used with each separately retained production assembly.
+const int seed = 2154;
+const int imageCount = 12;
+const int classCount = 4;
+const int boxesPerClass = 24;
+#if !AP_WORKLOAD_COUNTER
+const int measuredRuns = 9;
+#endif
+var random = new Random(seed);
+var predictions = new List>>();
+var truth = new List>>();
+for (int image = 0; image < imageCount; image++)
+{
+ var actual = new List>();
+ var predicted = new List>();
+ for (int classId = 0; classId < classCount; classId++)
+ {
+ for (int box = 0; box < boxesPerClass; box++)
+ {
+ double x = box % 6 * 12;
+ double y = box / 6 * 12;
+ actual.Add(new Detection(new BoundingBox(x, y, x + 10, y + 10), classId, 1));
+ for (int duplicate = 0; duplicate < 3; duplicate++)
+ {
+ double width = 4 + random.NextDouble() * 6;
+ double height = 4 + random.NextDouble() * 6;
+ predicted.Add(new Detection(new BoundingBox(x, y, x + width, y + height), classId, random.NextDouble()));
+ }
+ }
+ }
+ truth.Add(actual);
+ predictions.Add(predicted);
+}
+
+string assembly = typeof(ObjectDetectionMetrics).Assembly.Location;
+using var assemblyStream = File.OpenRead(assembly);
+string assemblyHash = Convert.ToHexString(SHA256.HashData(assemblyStream));
+var metrics = new ObjectDetectionMetrics();
+var cases = new[]
+{
+ (MetricWorkload.SingleMeanAveragePrecision, 1),
+ (MetricWorkload.FullPrecisionRecallCurve, 1),
+ (MetricWorkload.ThresholdRange, 1),
+ (MetricWorkload.ThresholdRange, 10),
+ (MetricWorkload.ThresholdRange, 32),
+ (MetricWorkload.ThresholdRange, 33),
+ (MetricWorkload.ThresholdRange, 65)
+};
+foreach (var (workload, thresholds) in cases)
+{
+ double step = thresholds == 10 ? 0.05 : 1.0 / 128;
+ double maximum = 0.5 + (thresholds - 1) * step;
+ double Score()
+ {
+ switch (workload)
+ {
+ case MetricWorkload.SingleMeanAveragePrecision:
+ return metrics.MeanAveragePrecision(predictions, truth);
+ case MetricWorkload.FullPrecisionRecallCurve:
+ var curve = metrics.PrecisionRecallCurve(predictions, truth, 0);
+ double checksum = 0;
+ for (int point = 0; point < curve.Precision.Length; point++)
+ checksum += curve.Precision[point] + curve.Recall[point];
+ return checksum;
+ case MetricWorkload.ThresholdRange:
+ return metrics.MeanAveragePrecisionRange(predictions, truth, 0.5, maximum, step);
+ default:
+ throw new ArgumentOutOfRangeException(nameof(workload));
+ }
+ }
+#if AP_WORKLOAD_COUNTER
+ WorkloadProbe.IoUCalls = 0;
+ double result = Score();
+ Console.WriteLine(JsonSerializer.Serialize(new
+ {
+ instrumentedAssemblyHash = assemblyHash,
+ workload = workload.ToString(),
+ thresholds,
+ minimumIoU = 0.5,
+ maximumIoU = maximum,
+ iouStep = step,
+ score = result,
+ iouCalls = WorkloadProbe.IoUCalls
+ }));
+#else
+ double expected = Score();
+ var warmupTimer = Stopwatch.StartNew();
+ do
+ {
+ if (Score() != expected) throw new InvalidOperationException("Warmup changed the deterministic metric score.");
+ } while (warmupTimer.Elapsed < TimeSpan.FromSeconds(1));
+
+ var times = new double[measuredRuns];
+ var allocations = new long[measuredRuns];
+ for (int iteration = 0; iteration < measuredRuns; iteration++)
+ {
+ long allocated = GC.GetAllocatedBytesForCurrentThread();
+ long start = Stopwatch.GetTimestamp();
+ double actual = Score();
+ times[iteration] = Stopwatch.GetElapsedTime(start).TotalMilliseconds;
+ allocations[iteration] = GC.GetAllocatedBytesForCurrentThread() - allocated;
+ if (actual != expected) throw new InvalidOperationException("Measured run changed the deterministic metric score.");
+ }
+ Array.Sort(times);
+ Array.Sort(allocations);
+ Console.WriteLine(JsonSerializer.Serialize(new
+ {
+ assemblyHash,
+ seed,
+ imageCount,
+ classCount,
+ boxesPerClass,
+ predictions = imageCount * classCount * boxesPerClass * 3,
+ groundTruth = imageCount * classCount * boxesPerClass,
+ workload = workload.ToString(),
+ thresholds,
+ minimumIoU = 0.5,
+ maximumIoU = maximum,
+ iouStep = step,
+ measuredRuns,
+ score = expected,
+ medianMilliseconds = times[measuredRuns / 2],
+ minimumMilliseconds = times[0],
+ medianAllocatedBytes = allocations[measuredRuns / 2]
+ }));
+#endif
+}
+
+internal enum MetricWorkload { SingleMeanAveragePrecision, FullPrecisionRecallCurve, ThresholdRange }
+
+internal static class BenchmarkEnvironment
+{
+ // Select CPU before Main's workload is touched. The engine's static initialization may
+ // probe GPUs first; that startup is outside the warmup and measurement intervals.
+ [ModuleInitializer]
+ internal static void Initialize() => AiDotNetEngine.ResetToCpu();
+}
diff --git a/review-tests/Pr2154.APRangeWorkload/Pr2154.APRangeWorkload.csproj b/review-tests/Pr2154.APRangeWorkload/Pr2154.APRangeWorkload.csproj
new file mode 100644
index 0000000000..e0ea57cc24
--- /dev/null
+++ b/review-tests/Pr2154.APRangeWorkload/Pr2154.APRangeWorkload.csproj
@@ -0,0 +1,29 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+ $(DefineConstants);AP_WORKLOAD_COUNTER
+
+ $(NoWarn);CS0436
+ $(MSBuildThisFileDirectory)../Pr2154.APRangeBenchmark/bin/Release/net10.0
+
+
+
+ $(ReviewedDependencyDirectory)/AiDotNet.dll
+ false
+
+
+ $(ReviewedDependencyDirectory)/AiDotNet.Tensors.dll
+ false
+
+
+
+
+
+
+
+
diff --git a/review-tests/Pr2154.APRangeWorkload/WorkloadProbe.cs b/review-tests/Pr2154.APRangeWorkload/WorkloadProbe.cs
new file mode 100644
index 0000000000..ed9ac93631
--- /dev/null
+++ b/review-tests/Pr2154.APRangeWorkload/WorkloadProbe.cs
@@ -0,0 +1,17 @@
+global using AiDotNet.Tensors.Interfaces;
+global using AiDotNet.Tensors.Helpers;
+
+using AiDotNet.Augmentation.Image;
+
+// Only compiled into the isolated counter executable. The timed production assembly has no
+// counter, callback, subclassed geometry, or numeric-provider mutation.
+internal static class WorkloadProbe
+{
+ internal static long IoUCalls { get; set; }
+
+ internal static double CountIoU(BoundingBox prediction, BoundingBox candidate) where T : struct
+ {
+ IoUCalls++;
+ return prediction.IoU(candidate);
+ }
+}
diff --git a/review-tests/Pr2154.ComputerVision/Pr2154.ComputerVision.csproj b/review-tests/Pr2154.ComputerVision/Pr2154.ComputerVision.csproj
new file mode 100644
index 0000000000..032f8c29bf
--- /dev/null
+++ b/review-tests/Pr2154.ComputerVision/Pr2154.ComputerVision.csproj
@@ -0,0 +1,42 @@
+
+
+ net471;net8.0;net10.0
+ latest
+ AiDotNetTests
+ enable
+ enable
+ true
+ false
+ false
+ $(MSBuildThisFileDirectory)../../src
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/review-tests/Pr2154.ComputerVision/README.md b/review-tests/Pr2154.ComputerVision/README.md
new file mode 100644
index 0000000000..5793975f28
--- /dev/null
+++ b/review-tests/Pr2154.ComputerVision/README.md
@@ -0,0 +1,179 @@
+# PR #2154 bounded review validation
+
+The current AP-cache follow-up and 271-case inventory are documented in [AP_CACHE_PROOF.md](AP_CACHE_PROOF.md). The historical commands below explicitly retain their original net10.0 fixture scopes; the focused project now also supports net8.0 and net471.
+
+This project compiles the real AiDotNet library and generator and source-links the changed regression tests, the relevant existing numerical/metrics tests, and all four edited detection/OCR model-family bases. It does not stub production contracts or replace generated family fixtures.
+
+The baseline is PR head `ebf7a1c9891af791e8715fb4bc5c74c1b270c34a` (base `1c8647e293ff9f5180a071a8e42f16dc90849102`). The reviewed changes are a local follow-up, not a claim that the whole PR is merge-ready. The corrected exhaustive inventory contained **36 threads, 34 unresolved**; thread pagination and every per-thread comments connection reported `hasNextPage: false`. The earlier 33/31 inventory was incomplete, not evidence that three threads had been resolved.
+
+## Reproduction
+
+Run from the follow-up worktree using PowerShell and the installed .NET 10 SDK. Enter the path to your own clean baseline worktree when prompted; the guard rejects a missing path, a different commit, or uncommitted files before starting a build. These commands reproduce the recorded 175-case inventory, excluding the later text-input cases documented separately below. This focused project uses the repository's real `ModuleInitializer.cs`, licensing test support, `GlobalUsings.cs`, and `xunit.runner.json`; no numerical tolerance is relaxed. CPU selection is test-only. Runtime preprocessing still uses the selected tensor engine, with no CPU-only production switch.
+
+```powershell
+$baselineRoot = Read-Host 'Path to the clean PR #2154 baseline worktree'
+if ([string]::IsNullOrWhiteSpace($baselineRoot)) {
+ throw 'A baseline worktree path is required.'
+}
+$baselineRoot = (Resolve-Path -LiteralPath $baselineRoot -ErrorAction Stop).ProviderPath
+$expectedBaselineHead = 'ebf7a1c9891af791e8715fb4bc5c74c1b270c34a'
+$baselineHead = git -C $baselineRoot rev-parse --verify HEAD
+if ($LASTEXITCODE -ne 0 -or $baselineHead -ne $expectedBaselineHead) {
+ throw "The baseline must be checked out at exactly $expectedBaselineHead."
+}
+$baselineChanges = @(git -C $baselineRoot status --porcelain)
+if ($LASTEXITCODE -ne 0 -or $baselineChanges.Count -ne 0) {
+ throw 'The baseline worktree must have no tracked or untracked changes.'
+}
+$baselineSourceRoot = Join-Path $baselineRoot 'src'
+$baselineProject = Join-Path $baselineSourceRoot 'AiDotNet.csproj'
+if (-not (Test-Path -LiteralPath $baselineProject -PathType Leaf)) {
+ throw 'The baseline worktree does not contain src/AiDotNet.csproj.'
+}
+$env:AIDOTNET_FORCE_CPU='1'
+dotnet build $baselineProject -c Release -f net10.0 -p:GeneratePackageOnBuild=false
+if ($LASTEXITCODE -ne 0) { throw 'The baseline build failed; do not test a stale DLL.' }
+dotnet test review-tests/Pr2154.ComputerVision/Pr2154.ComputerVision.csproj -c Release -f net10.0 "-p:ReviewedSourceRoot=$baselineSourceRoot" -p:BuildProjectReferences=false -p:GeneratePackageOnBuild=false --filter 'FullyQualifiedName!~Pyramid_RejectsOverflowingStrideWithoutEnteringLegacyShiftLoop&FullyQualifiedName!~CvInputBoundaryReviewTests.TextDetector_&FullyQualifiedName!~ObjectDetectionRangeCacheReviewTests' --logger 'trx;LogFileName=pr2154-boundary-full-baseline.trx' --results-directory artifacts/pr2154-review --verbosity quiet '-clp:ErrorsOnly' '-flp:logfile=pr2154-boundary-full-baseline.log;verbosity=normal'
+
+dotnet test review-tests/Pr2154.ComputerVision/Pr2154.ComputerVision.csproj -c Release -f net10.0 -p:GeneratePackageOnBuild=false --filter 'FullyQualifiedName!~CvInputBoundaryReviewTests.TextDetector_&FullyQualifiedName!~ObjectDetectionRangeCacheReviewTests' --logger 'trx;LogFileName=pr2154-boundary-full-after.trx' --results-directory artifacts/pr2154-review --verbosity quiet '-clp:ErrorsOnly' '-flp:logfile=pr2154-boundary-full-after.log;verbosity=normal'
+```
+
+The baseline source lives in a separate, clean, detached worktree at the exact head above. `ReviewedSourceRoot` changes only the real library/generator project references; both runs compile the same final test sources. Do not run the commands concurrently: the focused project's output directory is intentionally shared. Within the recorded 175-case inventory, the baseline excludes only two stride values above `2^30`: the legacy signed left-shift loop does not terminate for them. These tests are not skipped in source or CI and execute in the unfiltered follow-up run.
+
+## Text-input follow-up evidence (historical 208-case inventory)
+
+Comments **3991259128** and **3991259119** add one shared text-detector input validator and portable reproduction inputs. The text-detector fix is at the shared base, not in concrete detectors or generated leaf tests. Both consuming paths validate a snapshot of the publicly mutable `InputSize` array before indexing, resizing, or allocating a deferred input. The already-resolved serialization path does not consume the option and still returns without another forward pass.
+
+The 33 added cases cover prediction, preprocessing and initial serialization with null external bindings, empty/short/long arrays, zero/negative dimensions, valid `1x1`/`2x3` inputs, normalization and input nonmutation, repeated serialization, and in-place dimension mutation after a real prediction. The **before** library is the unchanged review head `f74c1a6d5c80b197f22ec2d2c4f76895c5ff5762`; both runs compile the same final test sources. To reproduce this newer baseline, use that exact commit as `$expectedBaselineHead` in the guard above and replace the historical filters with `--filter 'FullyQualifiedName!~ObjectDetectionRangeCacheReviewTests'`. That head already contains the stride-overflow fix, so no case in this historical 208-case inventory needs exclusion; the filter only removes the subsequently added AP-cache fixture.
+
+| Run | Passed | Failed | Skipped | TRX under `artifacts/pr2154-review` |
+| --- | ---: | ---: | ---: | --- |
+| Unchanged `f74c1a6d5c` DLL, 33 new cases | 7 | 26 | 0 | `pr2154-text-boundary-before.trx` |
+| Unchanged `f74c1a6d5c` DLL, unfiltered suite | 182 | 26 | 0 | `pr2154-text-boundary-full-before.trx` |
+| Shared validator, unfiltered suite | 208 | 0 | 0 | `pr2154-text-boundary-full-after.trx` |
+| Fresh-process no-build repeat | 208 | 0 | 0 | `pr2154-text-boundary-full-after-repeat.trx` |
+| Primary reviewer, independent no-build replay | 208 | 0 | 0 | `pr2154-text-boundary-root-independent.trx` |
+
+All original 175 controls passed before and after. Before the fix, null/short arrays produced `NullReferenceException`/`IndexOutOfRangeException`, negative dimensions reached an `OverflowException`, and long arrays were accepted. These are 26 failing cases for one missing shared validation boundary, not 26 distinct defects. The new guard consistently reports `ArgumentException` with `ParamName == "InputSize"` before forwarding. The selected-engine resize/multiply path and strict pixel/gradient controls are unchanged; these CPU runs are not physical-GPU proof.
+
+The final core build completed with **0 errors, 2,842 warnings**, in 4m29s. The unfiltered before/after runs took five seconds each; the fresh-process repeat took four seconds. The loaded focused-project DLL hashes were captured before subsequent builds could replace them:
+
+| Artifact | Before SHA-256 | After SHA-256 |
+| --- | --- | --- |
+| `AiDotNet.dll` | `52D186AFC8B5484E5A131D39CC060C2E27833F11A7A0FFB1E51BFD08353F2C7F` | `B254FAD5B62ABC52973F4634AD50B30343EF4A64E34A83F4B454280008556FD3` |
+| `AiDotNetTests.dll` | `F45C6701F6837599D9BE1A69346FA4F65E6B049826CAF68B2F04B5AD7D5132A7` | `9E36E347EC2C93DC22640B8B142FEDC31B0DC0B7EEAA2FC36918A551A8D0DD02` |
+
+`AiDotNet.Tensors.dll` remained `EB681AE60F23B03CF08E0BF3AB70A372673927ACD87A428C74536D424846D5E7`. The documented PowerShell block parsed without errors; its guard accepted the actual clean `ebf7a1c989` baseline and rejected empty input, a missing path, and the wrong-head review worktree. No build was launched by those guard-only checks.
+
+The historical scoped reproduction commands, after building the current core, are:
+
+```powershell
+dotnet test review-tests/Pr2154.ComputerVision/Pr2154.ComputerVision.csproj -c Release -f net10.0 -p:BuildProjectReferences=false -p:GeneratePackageOnBuild=false --filter 'FullyQualifiedName!~ObjectDetectionRangeCacheReviewTests' --logger 'trx;LogFileName=pr2154-text-boundary-full-after.trx' --results-directory artifacts/pr2154-review --verbosity quiet '-clp:ErrorsOnly'
+dotnet test review-tests/Pr2154.ComputerVision/Pr2154.ComputerVision.csproj -c Release -f net10.0 --no-build --no-restore --filter 'FullyQualifiedName!~ObjectDetectionRangeCacheReviewTests' --logger 'trx;LogFileName=pr2154-text-boundary-full-after-repeat.trx' --results-directory artifacts/pr2154-review --verbosity quiet
+```
+
+## Extended boundary evidence (recorded 175-case inventory)
+
+This recorded suite includes the primary reviewer's independent non-integer `3x5 -> 2x2` pixel/gradient oracle and 42 separate input/stride boundary cases. The exact commands are above.
+
+| Run | Passed | Failed | Skipped | Not selected | TRX under `artifacts/pr2154-review` |
+| --- | ---: | ---: | ---: | ---: | --- |
+| Exact baseline DLL, current test sources | 123 | 50 | 0 | 2 | `pr2154-boundary-full-baseline.trx` |
+| Follow-up, unfiltered | 175 | 0 | 0 | 0 | `pr2154-boundary-full-after.trx` |
+| Follow-up, fresh-process no-build repeat | 175 | 0 | 0 | 0 | `pr2154-boundary-full-after-repeat.trx` |
+| Primary reviewer's independent final replay | 175 | 0 | 0 | 0 | `pr2154-expanded-root-independent.trx` |
+
+The baseline's 50 failures comprise the earlier 17, the independent resize oracle, and 32 boundary-contract cases. Some invalid-stride cases previously rejected the value only inside `Log2` with a singular `stride` parameter error; the new boundary consistently rejects the caller's `strides` configuration before assignment. These counts are cases, not distinct defects. Eight new valid/fast-path controls already passed on the baseline; all 123 baseline-passing controls remain green. The two unselected baseline cases are `int.MaxValue` and `2^30 + 1`, whose legacy loop cannot terminate; their unfiltered after results are passing, not claimed before executions.
+
+That source build reported **0 errors, 2,775 warnings**, 3m43s. Test durations were eight seconds before, five seconds after, and four seconds for the repeat. The test sources and numerical tolerances were identical across the baseline and follow-up compilations. The new guard preserves the already-resolved serialization return, and valid stride boundaries include both `1` and the largest positive signed-int power of two (`2^30`).
+
+| Artifact | Baseline SHA-256 | Follow-up SHA-256 |
+| --- | --- | --- |
+| Loaded `AiDotNet.dll` | `9114068D81C131953BBA5087943181725C0046348E61DEFA3CDAA433E136C2DD` | `52D186AFC8B5484E5A131D39CC060C2E27833F11A7A0FFB1E51BFD08353F2C7F` |
+| `AiDotNetTests.dll` | `5FB685DE4C67610C78BE4374FFE27BC25059CF11E9F81EAF9CA02ED8C8298221` | `DFC40A44A69B4B74705C7A1A984364393FFBEDE9BF8E8370E8188689A09A09C3` |
+| Boundary TRX | `A6479CF7DCF80A27668818FBC4D69C62787D039914AD8BF77B1BC6A6A05BE48E` | `B98C784F9149C8D62FA4427B39510FB3358AEFB01E9BC34A15DB12AE9D1E5B3F` |
+
+The loaded tensor dependency is unchanged (`AiDotNet.Tensors` 0.130.3; SHA-256 `EB681AE60F23B03CF08E0BF3AB70A372673927ACD87A428C74536D424846D5E7`). The repeat used `dotnet test` with `--no-build --no-restore` and the repeat TRX name, so it did not rebuild or change the tested library.
+
+## Initial corrected-harness evidence (132 cases, historical)
+
+Both initial corrected-harness runs used the same test sources and the published `AiDotNet.Tensors` package `0.130.3`. SDK: `10.0.401`, Windows x64, `net10.0`.
+
+| Actual source | Passed | Failed | Skipped | Source build | TRX under `artifacts/pr2154-review` |
+| --- | ---: | ---: | ---: | --- | --- |
+| Exact PR baseline | 115 | 17 | 0 | 0 errors, 2,842 warnings; 3m48s | `pr2154-review-final-harness-baseline.trx` |
+| Follow-up source | 132 | 0 | 0 | 0 errors, 2,775 warnings; 3m53s | `pr2154-review-final-harness-after.trx` |
+
+Each test run reported a five-second test duration, separate from source compilation. These are 17 failing regression **cases**, not 17 distinct root causes. All 115 baseline-passing controls remained green after the changes. The 132 cases comprise 19 new CV regressions/controls, four actual nullable-generator/runtime cases, and 109 existing numerical, FPN/PANet, TrOCR-decoding and metric cases. The four edited abstract family bases compile but are not counted as executed generated model families.
+
+Concrete observations pinned by the before/after tests include:
+
+- CTC/attention with a two-character budget: baseline emits `abc`; follow-up emits `ab`. Leading CTC blanks/repeats and attention EOS are also checked.
+- Multi-image empty RoIs: baseline throws `ArgumentNullException` while concatenating no tensors; follow-up returns shape `[0,3,2,2]`.
+- Concurrent warm-up: baseline produced seven duplicate-key exceptions and one silent empty-registry success; follow-up initializes once and reports the real empty-registry error to all eight callers.
+- Text prediction: baseline skips the configured spatial resize and gives input gradient `1`; follow-up uses the expected shape and gradient `1/255`, with unchanged input pixels.
+- Tensor-list/adapter chunk IDs: baseline layout `weights` does not match `weights/0.0` and `weights/1.0`; follow-up IDs and live storage identity agree. Flat snapshots are not advertised as writable model storage, and mutating them does not change the model.
+- The ready-training control passes both before and after: weight `2 -> 1.6 -> 1.28`, losses `4` then `2.56`, and exactly three forward calls (one warm-up plus two gradient steps).
+
+SHA-256 hashes were read from the **loaded focused-project output**, before the next run replaced it:
+
+| Artifact | Baseline SHA-256 | Follow-up SHA-256 |
+| --- | --- | --- |
+| `AiDotNet.dll` | `9114068D81C131953BBA5087943181725C0046348E61DEFA3CDAA433E136C2DD` | `0C7E3196109DE670DE47C7F7DC6828D0E6700A3C06F01327176C40375EB8A23F` |
+| `AiDotNetTests.dll` | `DA3FA766CDA03FB24B63890FB50BB1254A64F0D74F6C526361C0B12FA63A6FA5` | `CE99EC7801BC612958C846ED1E596511C058191BAD1BEA0EAE464CD6A5E7D39C` |
+| Final TRX | `7CAB5D99F44A52D8527E11DDDFD0DD43BC637A9232E7975FB4FF6EB97C100961` | `2772A2227F0C706520558DC0E7BCD549D1C25CCF45095B8810EC9AAD96A6930B` |
+
+The `AiDotNet.Tensors.dll` hash is identical in both runs: `EB681AE60F23B03CF08E0BF3AB70A372673927ACD87A428C74536D424846D5E7`. Earlier diagnostic runs remain in `artifacts/pr2154-review`, but are not the final before/after claim.
+
+A fresh-process no-build repeat also passed **132/132**, with no skipped tests, in four seconds: `pr2154-review-final-harness-after-repeat.trx`. This was the initial 132-case inventory, before the independent resize oracle and boundary cases below. `git diff --check` passed; the added C# lines and new C# files contain no null-forgiving operators.
+
+The primary reviewer independently inspected the production/test diff and repeated the
+same final binary in another fresh process: **132 passed, zero failed, zero skipped**,
+five seconds (`pr2154-review-root-independent.trx`). The loaded `AiDotNet.dll`
+SHA-256 matches the follow-up hash above. This replay does not expand the tested scope
+to the complete generated model families or GPU execution.
+
+The corrected initialization intermediate run, `pr2154-review-exact-initializer-before-fallback.trx`, passed 129 tests and failed 2 (0 skipped). Both failures were copied parameter payloads incorrectly marked `IsWritableInPlace`, after the separate chunk-ID fix. Those failures directly justify the two shared fallback metadata corrections.
+
+### Diagnostic mistakes explicitly excluded from defect counts
+
+- The first isolated harness omitted the repository's CPU initialization. That produced float-sized discrepancies in strict double numerical tests; importing the real initializer/configuration made those controls pass without source or tolerance changes. `AIDOTNET_FORCE_CPU` alone was not an equivalent setup.
+- The original nullable-generator probe used the global namespace. The generator emitted an invalid namespace for that unrelated configuration. The final probe uses an explicit namespace and executes real generated component adapters in four enabled/disabled and optional/required cases. The existing global-namespace generator limitation is not fixed here.
+- An initial text-output oracle expected a flattened tensor even though the existing single-output contract preserves rank. The final oracle uses `[1,3,2,2]`.
+- An intermediate CRNN cleanup removed `_sequenceFeatureDim` before its weight-file header consumers were checked. The resulting three `CS0103` errors were introduced during this work, not baseline errors. The field and its value `512` are preserved; its persistence role is documented. Unused duplicate scratch state was removed instead.
+
+## Review mapping
+
+IDs below are GitHub review-comment database IDs; the corresponding full thread IDs were retained in the review inventory. The mapping distinguishes completed fixes from the explicitly pending items below; it is not a claim that every review item is complete.
+
+| Comment IDs | Scoped disposition and evidence |
+| --- | --- |
+| 3985472465 | Both base decoders honor the emitted-character budget. CTC blanks and repeated timesteps do not consume it; zero-budget, blank/repeat and EOS controls are included. |
+| 3985472468, 3990067342 | Empty live trainable discovery throws with model identity. A typed shared warm-up gate prevents duplicate initialization, retries failed initialization, and preserves the ready-model fast path. Tests include concurrent first calls, failure retry and two real gradient steps with exact expected weights. |
+| 3990067371 | Tensor-list layouts and live chunks use identical stable IDs. Accessor/collection passthrough requires compatible layout metadata; fallback payloads are explicitly not writable model storage. Tests cover live identity, both adapters, flat-only sources and snapshot nonmutation. |
+| 3990067051 | Empty RoIs preserve `[0,C,outH,outW]` for single- and multi-image inputs without concatenating an empty list. |
+| 3990067177 | Text prediction uses the same asymmetric resize and normalization as detection. The implementation uses tensor-engine operations and retains the input gradient. Exact pixel, input-nonmutation and gradient tests are included. |
+| 3990067093 | All three CV base copy-preparation paths replay batch one without modifying the source shape; text and OCR runtime probes cover the shared behavior. |
+| 3990067103 | A shared object-detector base guard validates exactly two positive input dimensions before deferred probing or preprocessing. Tests cover null external binding, empty/short/long arrays, nonpositive dimensions, valid 1x1/2x3 inputs and the already-resolved fast path. |
+| 3991259128 | The shared text-detector base now applies the same exact-two-positive-dimensions contract to prediction/preprocessing and deferred serialization. The 33 new cases reproduce 26 failures on `f74c1a6d5c` and pass with the private validator; all 175 earlier controls remain green. |
+| 3991259119 | Reproduction prompts for the caller's baseline root and validates its exact recorded commit, clean Git state and real project path before building. Both commands use that resolved input rather than an author-specific path. Guard-only positive and negative controls are recorded above. |
+| 3990067121 | Shared FPN assignment validates nonempty, positive power-of-two, contiguous doubling strides before taking logarithms. The integer logarithm shifts its value down, so it cannot wrap a left-shift count indefinitely. Tests cover invalid assignment/pooling inputs and valid/invalid signed-int boundaries. |
+| 3990067140 | Do not internalize `RPN`: it was already public at merge base `1c8647e293ff9f5180a071a8e42f16dc90849102`, so that recommendation would break an existing public type. Its explicit parameter members already delegate to the shared internal `DelegatingCvParameterModule`; the forwarding does not duplicate the implementation. |
+| 3990067157 | Both YOLO head decoders hoist `scaleX`/`scaleY` once per level, using the existing feature dimensions and preserving the arithmetic. Source inspection and real library compilation verify this cleanup; no dedicated decode-speed benchmark is claimed. |
+| 3985472484 | Corpus CER/WER return NaN for nonzero edits over zero reference length, consistent with the existing per-sample contract; zero-edit and ordinary-reference controls remain. |
+| 3985472480 | Polygon conversion is internal. Existing text-detection metric tests compile and execute against the actual implementation. |
+| 3985472488, 3985472504, 3985472513 | Shared family bases verify clone mutation, batch box coordinates and exact source image dimensions. Their real sources compile in this harness; complete generated model families have not been rerun here. |
+| 3990067400, 3990067419 | Input dependence always reaches an assertion, including length differences; NMS invariants honor the fixture's typed/overridable threshold properties. No generated leaf tests were edited. |
+| 3990067450, 3990067474 | FPN pooling uses an independent exact level oracle; PANet adds the meaningful level-zero pathway case. Existing numerical/pathway tests execute unchanged except these stronger test inputs/oracles. |
+| 3990067066, 3990067079, 3990067167, 3990067215 | Stale XML parameter/summary tags and unreferenced GELU helpers are removed. Actual source compilation and repository-wide caller search validate the cleanup; no public detector API is substituted. |
+| 3990067246, 3990067275, 3990067306, 3990067323 | Remove unreachable grayscale branching, unused duplicate CRNN scratch state, unused private shape arguments/locals and dead helper code. CRNN's persisted feature dimension remains. Existing TrOCR incremental/full-decoder parity controls execute. |
+| 3990067038 | No readiness weakening: actual Roslyn/generator/runtime tests show explicit `?` remains optional under either nullable context, while unannotated components remain required and raise `ParameterLayoutNotReadyException`. |
+| 3990067227 | The current concrete TrOCR `Train` override uses teacher-forced logits and cross-entropy, not the base inference `NoGrad` path. This is source-backed rejection of that specific premise, not proof that every real-model training case is correct. |
+
+## Explicitly pending / not claimed
+
+- **3985472460:** proper detector-specific annotation/loss training remains an architectural gap. Concatenating all head outputs prevents dropping heads, but is not proof that generic MSE is a correct detector loss.
+- **3985472491 and 3985472507:** deterministic non-empty object/text detection fixtures still need a shared base/generator design. Random-image tests may produce no detections; compiling stronger invariants does not prove their nonvacuity.
+- **3985472478:** caching threshold-independent AP data remains a performance follow-up. Per-threshold greedy matching must remain independent; this batch does not replace it with a shared match set.
+- This is focused local Windows `net10.0` validation, not the complete CI workflow, other target-framework builds, GPU execution or a full generated model-family sweep. The existing project emits many warnings. GitHub readiness/merge decisions must retain these limitations and the pending architecture work.
+- No package-version change or merge-ready claim is part of this batch. The PR remains draft while the explicitly pending work is incomplete.
diff --git a/review-tests/Pr2154.DetectionBoundaries/Pr2154.DetectionBoundaries.csproj b/review-tests/Pr2154.DetectionBoundaries/Pr2154.DetectionBoundaries.csproj
new file mode 100644
index 0000000000..3a84b6a3c8
--- /dev/null
+++ b/review-tests/Pr2154.DetectionBoundaries/Pr2154.DetectionBoundaries.csproj
@@ -0,0 +1,40 @@
+
+
+ net471;net8.0;net10.0
+ AiDotNetTests
+ latest
+ enable
+ enable
+ true
+ false
+ false
+ false
+ false
+ <_GetChildProjectCopyToOutputDirectoryItems>false
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/review-tests/Pr2154.DetectionBoundaries/README.md b/review-tests/Pr2154.DetectionBoundaries/README.md
new file mode 100644
index 0000000000..e32a20dc88
--- /dev/null
+++ b/review-tests/Pr2154.DetectionBoundaries/README.md
@@ -0,0 +1,142 @@
+# PR #2154: detection metric and geometry boundaries
+
+This batch addresses review comments `3994110796`, `3994110814`, and
+`3994110819`. It changes the shared metric implementation and shared text-test
+invariant, not generated leaf tests or detector numerical forwards.
+
+## Contracts and regression controls
+
+Single-threshold AP, mean AP, and precision/recall now reject non-finite IoU
+thresholds and thresholds outside `[0,1]`, including inputs with no ground-truth
+classes. Null outer lists and mismatched image counts retain their earlier
+exception precedence. Invalid thresholds must be rejected before enumerating
+either inner detection list; finite endpoints 0 and 1 remain valid. As before,
+zero-overlap boxes do not become matches merely because the threshold is zero.
+
+The range API replaces the fixed `1e-9` quotient bias with a correction scaled
+to binary64 roundoff. An endpoint mathematically on the grid can be recovered
+from division roundoff. If reconstructing that final grid point slightly exceeds
+the caller's maximum, it is clamped only within the corresponding arithmetic
+tolerance. An off-grid maximum is not appended. The existing 32-threshold batches,
+independent match claims, stable ranking, lazy IoU access, and ordered averaging
+are unchanged.
+
+The grid controls distinguish `max=0.9999999995, step=1` from a genuine endpoint,
+cover an interior near-grid maximum, decimal `0.1..0.3` by `0.1`, the COCO grid,
+an off-grid maximum, and a single threshold with `double.Epsilon` step. Existing
+controls also require overflow rejection before enumeration for both
+`step=1/Int32.MaxValue` and `step=double.Epsilon` over `[0,1]`, where the raw
+quotient is infinity. A representable `Int32.MaxValue` threshold count with no
+classes still takes the empty result path without allocating matching state.
+
+The shared random-input text invariant now checks all four coordinates for both
+NaN and infinity before its unchanged positive-width/height assertions. Four
+coordinates times three non-finite values are tested directly. Finite negative
+coordinates remain legal for an unclipped EAST box; no image-boundary condition
+was added to that contract. The 18 existing generated positive text cases are
+included unchanged.
+
+## Failure-before evidence
+
+The first run used the actual production core at
+`d8183f1d0e8e8f552b828f33efc459f64318d1ab`, with SHA-256
+`69BCCF0B1B5AD45EB4050C3D83D352980BB7F0F8EC4D3153EBEBB5469A4B9B8D`.
+For direct testing, the old text helper's visibility was widened from private to
+internal, but its assertion body was unchanged. The same 172 cases produced
+120 passes, 52 failures, and zero skips:
+
+- 45 invalid single-threshold cases failed their required argument-validation
+ contract across three APIs and populated/empty inputs.
+- Three range cases failed: the two near-grid maxima admitted a forbidden extra
+ threshold, and decimal `0.1..0.3` reconstructed a final threshold above `0.3`.
+- Four text cases exposed accepted infinities: negative infinity in left/top,
+ positive infinity in right/bottom. Other non-finite combinations were already
+ rejected by the old NaN or positive-area conditions.
+
+The other 120 controls passed. The baseline report is
+`artifacts/pr2154-review/pr2154-boundaries-first-before.trx`; its test assembly
+hash is `B52FB918A966E1A11C9D367CF09E428D96CC6363DB81A2513B0E484AF2963AD1`.
+This is actual-library CPU evidence, not a stub or a copied metric implementation.
+
+## Corrected-code results
+
+The 172 cases comprise 15 existing metric cases, 63 existing range/cache cases,
+18 generated positive-text cases, 62 new threshold-boundary cases, and 14 new
+shared text-geometry cases. No test or assertion was removed to obtain green.
+
+| Actual run | Passed | Failed | Skipped | Report in `artifacts/pr2154-review` |
+| --- | ---: | ---: | ---: | --- |
+| Historical core and historical text assertions, .NET 10 | 120 | 52 | 0 | `pr2154-boundaries-first-before.trx` |
+| Historical core, corrected text assertions only, .NET 10 | 124 | 48 | 0 | `pr2154-boundaries-text-only-before-core.trx` |
+| Corrected core and text assertions, .NET 10 | 172 | 0 | 0 | `pr2154-boundaries-final-net10.trx` |
+| Corrected core and text assertions, .NET 8 | 172 | 0 | 0 | `pr2154-boundaries-final-net8.trx` |
+| Corrected core and text assertions, .NET Framework 4.7.1 | 172 | 0 | 0 | `pr2154-boundaries-final-net471.trx` |
+| Independent parent-agent replay, .NET 10 | 172 | 0 | 0 | `pr2154-boundaries-root-independent.trx` |
+
+All three actual core builds succeeded: net10/net8/net471 had zero errors and
+2,775/2,775/2,777 warnings respectively, taking 3m40s/4m12s/3m40s. All three
+focused test builds had zero errors and zero warnings. The final test executions
+took 14/14/20 seconds. The independent net10 replay checked the exact core/test
+hashes and passed in 16 seconds.
+
+| Artifact | SHA-256 |
+| --- | --- |
+| Corrected .NET 10 core | `BB9F13F390C9EB9056891A0BE91E17312851F1095E99F95B512976BF04565E8F` |
+| Corrected .NET 10 test assembly | `C230662175B48E3295FD814E0CA59988666FB779050B0BE3D8237D54A9AEB15A` |
+| Corrected .NET 8 core | `1EC7D7A5409FD0360239D5FAE3B54B8EAED3251CAA552E23EC6C4EA5BAEAC9AE` |
+| Corrected .NET 8 test assembly | `05B40C3CEED00287AEB826B7C0402DE04241B7FDB391816128D8C2D2BB7810CE` |
+| Corrected .NET Framework 4.7.1 core | `4FAED56C5CB86ED460CCAB3371918600F91E2ACCB973E45CE851FE47C5A43831` |
+| Corrected .NET Framework 4.7.1 test assembly | `F4D7E6337E4D976356376BD7A34CCD3A429B3D7886D7FAFEA10A417E32567D82` |
+| Unchanged actual generator | `1AF4448E70ED82A2248EDC7077225B7925ED9F69E78CE642331D71BC5587141F` |
+
+## Focused runner integration
+
+The preceding object-positive fixture added a helper referenced by
+`ObjectDetectionTestBase`. Two older focused projects linked the base explicitly
+but omitted that helper. Both failed with the same two `CS0103` diagnostics.
+The source includes are corrected in `Pr2154.ComputerVision` and
+`Pr2154.DetectionParameters`; all six project/framework compile configurations
+then succeeded. A repository-wide scan of project/props/targets source files,
+including paths outside `review-tests`, found only these two omissions. The
+other matching runners already included their required helpers.
+
+These checks used `dotnet msbuild -t:Compile`, so they compiled the current source
+into `obj` without replacing the historical test binaries in `bin`. SHA-256
+checks confirmed all six frozen binaries were unchanged. Logs are named
+`artifacts/pr2154-review/pr2154-focused-links--.log`.
+The six test hashes and all three earlier positive-object core hashes were
+checked again after the compatibility builds and remained unchanged.
+This source-list integration check is not a claim that the full main test
+project or every model family was executed.
+
+```powershell
+foreach ($project in @('ComputerVision', 'DetectionParameters')) {
+ foreach ($framework in @('net10.0', 'net8.0', 'net471')) {
+ dotnet msbuild "review-tests/Pr2154.$project/Pr2154.$project.csproj" -t:Compile -p:TargetFramework=$framework -p:Configuration=Release -p:BuildProjectReferences=false -p:CopyLocalRuntimeTargetAssets=false -p:CopyLocalLockFileAssemblies=false -p:_GetChildProjectCopyToOutputDirectoryItems=false -m:1 -nodeReuse:false -nologo -v:quiet -clp:ErrorsOnly
+ if ($LASTEXITCODE -ne 0) { throw "Focused source compilation failed for $project / $framework." }
+ }
+}
+```
+
+## Reproduction
+
+Run from the repository root. The small runner includes the actual module
+initializer, license helper, trace helper, and xUnit configuration. It reuses
+actual built core assemblies and the existing CPU native closure; all-RID asset
+copying and child Content propagation are disabled. On .NET Framework it uses
+the existing managed-only dependency closure target.
+
+```powershell
+$framework = 'net10.0' # Also exercise net8.0 and net471.
+dotnet restore review-tests/Pr2154.DetectionBoundaries/Pr2154.DetectionBoundaries.csproj -p:NuGetAudit=false
+dotnet build src/AiDotNet.Generators/AiDotNet.Generators.csproj -c Release --no-restore -m:1 -nodeReuse:false
+dotnet build src/AiDotNet.csproj -f $framework -c Release --no-restore -m:1 -nodeReuse:false -p:BuildProjectReferences=false -p:GeneratePackageOnBuild=false -p:CopyLocalRuntimeTargetAssets=false -p:CopyLocalLockFileAssemblies=false -p:_GetChildProjectCopyToOutputDirectoryItems=false
+dotnet build review-tests/Pr2154.DetectionBoundaries/Pr2154.DetectionBoundaries.csproj -f $framework -c Release --no-restore -m:1 -nodeReuse:false -p:BuildProjectReferences=false -p:GeneratePackageOnBuild=false
+$cpuNativeDirectory = (Resolve-Path "review-tests/Pr2154.DetectionParameters/bin/Release/$framework").Path
+$env:PATH = $cpuNativeDirectory + ';' + $env:PATH
+dotnet test review-tests/Pr2154.DetectionBoundaries/Pr2154.DetectionBoundaries.csproj -f $framework -c Release --no-build --no-restore --logger 'trx;LogFileName=boundaries-replay.trx' --results-directory artifacts/pr2154-review
+```
+
+The semantic detector-training review remains separate open work. These
+boundary checks are not GPU proof, trained detection accuracy, or full-repository
+CI proof, and do not by themselves make this draft PR ready to merge.
diff --git a/review-tests/Pr2154.DetectionParameters/Pr2154.DetectionParameters.csproj b/review-tests/Pr2154.DetectionParameters/Pr2154.DetectionParameters.csproj
new file mode 100644
index 0000000000..c23add19e1
--- /dev/null
+++ b/review-tests/Pr2154.DetectionParameters/Pr2154.DetectionParameters.csproj
@@ -0,0 +1,49 @@
+
+
+ net471;net8.0;net10.0
+ AiDotNetTests
+ latest
+ enable
+ enable
+ true
+ false
+ false
+ false
+
+ <_GetChildProjectCopyToOutputDirectoryItems>false
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/review-tests/Pr2154.DetectionParameters/README.md b/review-tests/Pr2154.DetectionParameters/README.md
new file mode 100644
index 0000000000..437914025e
--- /dev/null
+++ b/review-tests/Pr2154.DetectionParameters/README.md
@@ -0,0 +1,179 @@
+# PR #2154: live parameter-layout and metadata proof
+
+This batch fixes shared parameter exposure needed by the detector review. It does
+**not** claim that positive detection fixtures or task-specific detector training
+are complete. The separate AP-cache proof remains in
+[`../Pr2154.ComputerVision/AP_CACHE_PROOF.md`](../Pr2154.ComputerVision/AP_CACHE_PROOF.md).
+
+## Root defects and contracts
+
+The five shared backbone adapters exposed actual parameter chunks but not their
+underlying layouts. The registry correctly refused to treat an unverified chunk
+source as writable and used a detached fallback. `CvParameterModule` and RPN had
+the same missing layout contract. They now delegate the real layer layout; the
+registry's safety gate and its negative controls are unchanged.
+
+Shared module layout and chunks use the same canonical indexed child IDs. Tests
+check tensor identity, role, flat order, normalized offsets, zero-sized own
+slots, null children, deferred shapes, and every propagated layout descriptor.
+Accessor and collection registration both expose actual tape weights: a real
+gradient update must change the real forward and reduce an independently defined
+sum objective. No replacement forward or synthetic optimizer is used.
+
+Exposing the real MHA layout uncovered a second defect: querying optional child
+structure called its value initializer and allocated projection weights. The
+base still initializes unknown child structures. Only a conservative generator
+proof can omit this step for an exact runtime layer type; derived types retain
+the original path. Nullable fields alone are not evidence that children are
+ready, and collections, inherited/unknown structural paths, owner escapes,
+callbacks, setters, user-defined operators, and unknown external contracts
+retain initialization.
+
+The proof assumes the explicitly resolved numeric, tensor, engine, and
+initialization APIs honor their value-only contracts. An initialization strategy
+that secretly captures its owner to create child layers violates that contract.
+This is not general whole-program side-effect analysis. Metadata tests use a
+rejecting initialization strategy to prove that weight initialization is not
+invoked at all, not merely that it leaves the same values behind.
+
+## Failure-first evidence
+
+TRXs and logs are retained locally in `artifacts/pr2154-review/`; they are not
+committed binaries. Counts below are TRX executed/passed/failed counts, not just
+process exit codes. The original adapter baseline is the frozen AP-complete
+core (commit `7ccc544f94694dd41c6a73350473f0c397bac023`, core SHA256
+`2D5355184C81886DA018076141A8A1030F9E5C49527C8DDB145B75253399388C`).
+
+| Snapshot / control | Evidence file | Passed / executed |
+| --- | --- | ---: |
+| Original adapter regression cohort | `pr2154-adapter-live-before.trx` | 20 / 50 (30 failed) |
+| Expanded original adapter cohort | `pr2154-adapter-live-expanded-before.trx` | 20 / 56 (36 failed) |
+| Layout delegation before canonical child-ID and metadata fixes | `pr2154-adapter-live-expanded-after.trx` | 51 / 56 (5 failed) |
+| Canonical IDs, before actual MHA initializer eligibility | `pr2154-adapter-live-intermediate.trx` | 57 / 59 (2 failed) |
+| Actual MHA eligibility, before final primitive-contract tightening | `pr2154-adapter-cv310-intermediate.trx` | 310 / 310 |
+| Adversarial setter / owner-alias controls before correction | `pr2154-metadata-generator-adversarial-red.trx` | 12 / 19 (7 failed) |
+| Reassigned-null callback controls before correction | `pr2154-metadata-generator-callback-red.trx` | 20 / 23 (3 failed) |
+| Constructor / user-operator controls before correction | `pr2154-metadata-generator-operator-red.trx` | 22 / 32 (10 failed) |
+| Implicit callback controls before correction | `pr2154-metadata-generator-implicit-effects-red.trx` | 38 / 41 (3 failed) |
+| Non-primitive `SpecialType` controls before correction | `pr2154-metadata-generator-specialtype-red.trx` | 41 / 44 (3 failed) |
+| All final policy cases against the exact unchanged baseline generator | `pr2154-metadata-generator-final44-baseline.trx` | 36 / 44 (8 failed) |
+| Final 44 generator policy controls | `pr2154-metadata-generator-specialtype-after.trx` | 44 / 44 |
+
+The intermediate real-core SHA256 was
+`69BCCF0B1B5AD45EB4050C3D83D352980BB7F0F8EC4D3153EBEBB5469A4B9B8D`.
+That earlier runtime result proved actual MHA eligibility but was not the final
+generator-policy snapshot. The final reviewed-source rebuild and replays below
+produced the same net10 core hash: the additional conservative rejects do not
+change emitted code for these actual layer types. The source-level adversarial
+controls, not an assumed change to the core DLL hash, prove those rejects.
+
+## Final reviewed-source validation
+
+All three actual core builds completed with zero errors. Build logs are
+`pr2154-layout-reviewed-core-net10.log` (2775 warnings, 2m52s),
+`pr2154-layout-reviewed-core-net8.log` (2775 warnings, 2m57s), and
+`pr2154-layout-reviewed-core-net471.log` (2777 warnings, 2m59s). These are core
+compatibility builds plus bounded actual-source test runners, not a claim that
+the entire main test assembly or every model-family shard ran.
+
+| Target | Runtime TRX | Passed / executed / skipped |
+| --- | --- | ---: |
+| net10.0 | `pr2154-layout-reviewed-net10.trx` | 310 / 310 / 0 |
+| net8.0 | `pr2154-layout-reviewed-net8.trx` | 310 / 310 / 0 |
+| net471 | `pr2154-layout-reviewed-net471.trx` | 310 / 310 / 0 |
+
+The actual core and copied runner DLL hashes agree for each target:
+
+- net10.0: `69BCCF0B1B5AD45EB4050C3D83D352980BB7F0F8EC4D3153EBEBB5469A4B9B8D`
+- net8.0: `97F5D10424D8B75698A144E248797B18A149F08B376727361A37E609E82B27BA`
+- net471: `B6E19F6F44C9F6E43FFA2A9A29B14B9999B9F76F86669F273644027CA6BEB64B`
+
+### Harness negative controls
+
+The initial isolated generator harness omitted the real test initializer and
+failed two existing semantic-compilation tests against both the old and new
+generator. With the actual `ModuleInitializer.cs`, licensing helper, and core
+dependency graph, the unchanged baseline passes 59/59
+(`pr2154-metadata-generator-initialized-baseline.trx`). No semantic assertion was
+weakened. That unchanged generator DLL has SHA256
+`D71ABAE9452767B828CEFB6379D25A2A78E5F38016DD36F3F11A8A3B64D73CB0`;
+the reviewed generator DLL has SHA256
+`488763248BE3B050EB1EACB8BAE44CBAECAE80FCC67797C6A436B83D22993A53`.
+The final generator then passes all 59 existing plus 44 new cases:
+
+| Target | Evidence file | Passed / executed / skipped |
+| --- | --- | ---: |
+| net10.0 | `pr2154-generator-reviewed-restored-net10.trx` | 103 / 103 / 0 |
+| net8.0 | `pr2154-generator-reviewed-net8.0.trx` | 103 / 103 / 0 |
+| net471 | `pr2154-generator-reviewed-net471.trx` | 103 / 103 / 0 |
+
+After the last historical baseline control, the net10 runner was rebuilt with
+the reviewed generator; its copied generator hash was checked and all 103 cases
+were rerun. No runner is left pointing at the baseline generator.
+
+An earlier net471 launch exited zero but discovered no tests because managed
+xUnit dependencies were missing; it is **not** a passing result. The corrected
+runner copies the SDK-resolved managed runtime assemblies on .NET Framework,
+which cannot use `.runtimeconfig.dev.json` NuGet probing. It does not copy every
+platform's native runtime assets. The repository's actual CPU initializer and
+xUnit configuration remain in both runners.
+
+## Focused reproduction
+
+Run from the repository root. Restore the two runner projects once. Build the
+generator and actual core for the chosen target before using `--no-build` tests;
+`BuildProjectReferences=false` deliberately prevents a hidden large rebuild.
+The detection runner contains 310 unique cases: the existing 271 CV/AP cases
+and 39 new shared-adapter cases. The generator runner contains 103 cases.
+
+```powershell
+dotnet restore review-tests/Pr2154.DetectionParameters/Pr2154.DetectionParameters.csproj
+dotnet restore review-tests/Pr2154.LayerStructureGenerator/Pr2154.LayerStructureGenerator.csproj
+dotnet build src/AiDotNet.Generators/AiDotNet.Generators.csproj -c Release --no-restore -m:2 -nodeReuse:false
+
+$reviewTarget = 'net10.0' # Repeat with net8.0 and net471.
+dotnet build src/AiDotNet.csproj -f $reviewTarget -c Release --no-restore -m:2 -nodeReuse:false -p:BuildProjectReferences=false -p:GeneratePackageOnBuild=false -p:CopyLocalLockFileAssemblies=false
+dotnet build review-tests/Pr2154.DetectionParameters/Pr2154.DetectionParameters.csproj -f $reviewTarget -c Release --no-restore -m:2 -nodeReuse:false -p:BuildProjectReferences=false -p:GeneratePackageOnBuild=false
+dotnet build review-tests/Pr2154.LayerStructureGenerator/Pr2154.LayerStructureGenerator.csproj -f $reviewTarget -c Release --no-restore -m:2 -nodeReuse:false -p:BuildProjectReferences=false -p:GeneratePackageOnBuild=false
+
+dotnet test review-tests/Pr2154.DetectionParameters/Pr2154.DetectionParameters.csproj -f $reviewTarget -c Release --no-build --no-restore --logger "trx;LogFileName=pr2154-layout-$reviewTarget.trx" --results-directory artifacts/pr2154-review
+dotnet test review-tests/Pr2154.LayerStructureGenerator/Pr2154.LayerStructureGenerator.csproj -f $reviewTarget -c Release --no-build --no-restore --logger "trx;LogFileName=pr2154-generator-$reviewTarget.trx" --results-directory artifacts/pr2154-review
+```
+
+These bounded runners suppress transitive platform-native Content copies. Supply
+the ordinary CPU-native dependencies through an existing validated CPU runtime
+directory on the native library search path. Do not copy another runner's
+managed dependency directory wholesale: the generator runner deliberately uses
+its own resolved Roslyn 4.14.0 package version. On Windows, an existing CPU-native
+directory can be prepended to `PATH` for this shell. This is CPU correctness
+proof, not a physical-GPU performance claim.
+
+Verify the expected executed counts as well as the exit status:
+
+```powershell
+$reviewCases = @{
+ "pr2154-layout-$reviewTarget.trx" = 310
+ "pr2154-generator-$reviewTarget.trx" = 103
+}
+foreach ($reviewCase in $reviewCases.GetEnumerator()) {
+ [xml] $reviewResult = Get-Content -LiteralPath (Join-Path artifacts/pr2154-review $reviewCase.Key)
+ $reviewCounters = $reviewResult.TestRun.ResultSummary.Counters
+ if ([int]$reviewCounters.total -ne $reviewCase.Value -or
+ [int]$reviewCounters.executed -ne $reviewCase.Value -or
+ [int]$reviewCounters.passed -ne $reviewCase.Value -or
+ [int]$reviewCounters.failed -ne 0 -or
+ [int]$reviewCounters.notExecuted -ne 0) {
+ throw "Unexpected test result counts: $($reviewCase.Key)"
+ }
+}
+```
+
+## Remaining review scope
+
+Diagnostic runs demonstrate that CRAFT, DBNet, EAST, and all nine object
+detectors now expose live trainable chunks. Controlled actual text heads produce
+nondegenerate output. These probes are not yet shared positive fixture tests,
+and tied object scores do not prove sorting or NMS. Those reviews remain open.
+Likewise, preserving raw-output regression training does not implement typed
+detection assignment/classification/box losses; that is a separate unfinished
+review item.
diff --git a/review-tests/Pr2154.LayerStructureGenerator/Pr2154.LayerStructureGenerator.csproj b/review-tests/Pr2154.LayerStructureGenerator/Pr2154.LayerStructureGenerator.csproj
new file mode 100644
index 0000000000..ccfaca47de
--- /dev/null
+++ b/review-tests/Pr2154.LayerStructureGenerator/Pr2154.LayerStructureGenerator.csproj
@@ -0,0 +1,35 @@
+
+
+ net471;net8.0;net10.0
+ AiDotNetTests
+ latest
+ enable
+ enable
+ true
+ false
+ false
+ false
+
+ <_GetChildProjectCopyToOutputDirectoryItems>false
+ true
+ true
+ true
+ ../../src/AiDotNet.Generators/bin/Release/netstandard2.0/AiDotNet.Generators.dll
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/review-tests/Pr2154.ManagedTestClosure.targets b/review-tests/Pr2154.ManagedTestClosure.targets
new file mode 100644
index 0000000000..264e314c25
--- /dev/null
+++ b/review-tests/Pr2154.ManagedTestClosure.targets
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/review-tests/Pr2154.PositiveDetections/Pr2154.PositiveDetections.csproj b/review-tests/Pr2154.PositiveDetections/Pr2154.PositiveDetections.csproj
new file mode 100644
index 0000000000..fae662f205
--- /dev/null
+++ b/review-tests/Pr2154.PositiveDetections/Pr2154.PositiveDetections.csproj
@@ -0,0 +1,36 @@
+
+
+ net471;net8.0;net10.0
+ AiDotNetTests
+ latest
+ enable
+ enable
+ true
+ false
+ false
+ false
+ <_GetChildProjectCopyToOutputDirectoryItems>false
+ true
+ true
+ true
+ $(MSBuildThisFileDirectory)../../src/AiDotNet.Generators/bin/Release/netstandard2.0/AiDotNet.Generators.dll
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/review-tests/Pr2154.PositiveDetections/README.md b/review-tests/Pr2154.PositiveDetections/README.md
new file mode 100644
index 0000000000..e72ef5529e
--- /dev/null
+++ b/review-tests/Pr2154.PositiveDetections/README.md
@@ -0,0 +1,132 @@
+# PR #2154: generated positive text-detector proof
+
+This batch addresses review comment `3985472507`. It adds one shared positive
+invariant and a typed factory emitted by the actual test scaffold generator for
+CRAFT, DBNet, and EAST. No generated leaf test is edited by hand. Production
+models, forward methods, decoding, and thresholds are unchanged.
+
+## What is proved
+
+The shared fixture draws a deterministic 64-by-64 `HI` bitmap without a font or
+external image dependency. A separate generated factory supplies the bounded
+Nano profile; ordinary generated factories retain their original defaults.
+After a real forward initializes lazy shapes, every trainable chunk must be
+writable in place. The fixture controls those actual weights and calls the
+production `Predict` and `Detect` paths, without replacing `Forward`.
+
+| Detector | Controlled positive result at threshold 0.05 |
+| --- | --- |
+| CRAFT | One region, confidence 0.5, box `(0, 0, 60, 60)` |
+| DBNet | One region, confidence 0.5, box `(0, 0, 63, 63)` |
+| EAST | 64 eligible score cells and known RBOX distances; real IoU-0.2 suppression leaves eight regions, first box `(-20, -12, 28, 20)` |
+| EAST, separated geometry | The same live head changed to distances 0.25 produces 64 nonoverlapping 4-by-4 boxes at exact grid coordinates |
+
+All positive regions must have a nonempty finite polygon enclosing area, a
+positive finite box containing that polygon, the expected confidence, and the
+correct source-image dimensions. The EAST raw-output assertions cover all 384
+values of the documented flattened `[1, 384]` public `Predict` result: 64 scores
+and five geometry channels. With the same model and image, raising the detection
+threshold to 0.75 must reject every known 0.5-confidence region.
+
+The exact shared geometry assertions also run through the original random-input
+factories. Their existing empty-result and box-only behavior is preserved; the
+new positive invariant separately rejects empty results and empty polygons.
+
+This is a **controlled numerical-pipeline and decoder proof**, not evidence of
+trained text-recognition accuracy or image-to-label generalization. Zeroing the
+weights intentionally makes the known scores analytically predictable. It is
+CPU validation, not a physical-GPU or full-repository shard result. The distinct
+object-detector positive-fixture and semantic-training findings remain open.
+
+## Actual before/after evidence
+
+The focused suite has 18 cases: three generated-factory contracts, three real
+generated positive fixtures, three unchanged default-profile geometry replays,
+one valid-result oracle control, and eight malformed/empty-result controls.
+Invalid controls cover empty regions, empty/degenerate/nonfinite polygons,
+inverted/nonfinite boxes, invalid confidence, and a box not containing its
+polygon. The nonfinite-box and containment controls adjust the separately
+expected first box so another exact-box comparison cannot mask the relevant
+missing guard.
+
+| Run | Passed | Failed | Skipped |
+| --- | ---: | ---: | ---: |
+| Final 18-case suite, prior generator from `1d8961633155aefd881d7f0f025b0a4f3e6ca625`, .NET 10 | 9 | 9 | 0 |
+| Current generator restored, .NET 10 | 18 | 0 | 0 |
+| Current generator, .NET 8 | 18 | 0 | 0 |
+| Current generator, .NET Framework 4.7.1 | 18 | 0 | 0 |
+
+All nine before failures identify the absent positive factory: three syntax
+assertions and six generated-source compilations reporting only the missing
+abstract factory implementation. These are regression-guard failures for the
+new generator/base contract, not nine claimed production detector defects.
+The nine assertion controls still pass against that prior generator.
+
+Final result files in `artifacts/pr2154-review/`:
+
+- `pr2154-positive-text-complete-before.trx`
+- `pr2154-positive-text-complete-restored-net10.trx`
+- `pr2154-positive-text-complete-net8.0.trx`
+- `pr2154-positive-text-net471-final.trx`
+- `pr2154-positive-root-independent18.trx` — independent reviewer replay:
+ 18 passed, zero failed/skipped, .NET 10, 13 seconds.
+
+All three focused project builds completed with zero warnings and zero errors.
+An earlier EAST oracle mistakenly expected a four-dimensional public result;
+the actual documented flattened contract was checked and corrected before the
+final runs. Earlier harness failures concerning synchronous timeout support or
+missing trace-helper references are not included as before/after proof.
+
+The generator's normal metadata discovery runs against real production model
+types. This small discovery compilation omits the repository's unrelated manual
+test census, so its global coverage/name-collision diagnostics are printed, not
+claimed as whole-project validation. The test requires exactly the three real
+hint identities and compiles **every selected generated source** against the
+actual shared test base and actual detector assemblies with zero emit errors.
+It then invokes the generated classes. No mock detector/base or handwritten
+replacement leaf supplies the proof.
+
+SHA-256 identities:
+
+| Assembly | SHA-256 |
+| --- | --- |
+| Prior generator | `488763248BE3B050EB1EACB8BAE44CBAECAE80FCC67797C6A436B83D22993A53` |
+| Current generator | `7F99F0DA179EB2C184AEAFCA88BEBD9146DF8C0C16437D72E1DFC76A6D3A80F2` |
+| Actual unchanged .NET 10 core | `69BCCF0B1B5AD45EB4050C3D83D352980BB7F0F8EC4D3153EBEBB5469A4B9B8D` |
+| Actual unchanged .NET 8 core | `97F5D10424D8B75698A144E248797B18A149F08B376727361A37E609E82B27BA` |
+| Actual unchanged .NET Framework core | `B6E19F6F44C9F6E43FFA2A9A29B14B9999B9F76F86669F273644027CA6BEB64B` |
+
+These are the previously validated shared-layout core binaries; this batch
+changes only the scaffold generator and tests. The frozen AP and shared-layout
+review runner outputs were not rebuilt or overwritten.
+
+## Reproduce from the repository root
+
+First build the current generator and actual Release core for each framework,
+or reuse the hash-identified core outputs above. The preceding
+`Pr2154.DetectionParameters` proof documents the existing validated CPU native
+closure. This runner reuses that closure rather than copying every native RID.
+It includes the repository's real module initializer, licensing support,
+generated-test trace helper, and xUnit configuration. The imported managed-only
+closure target supplies .NET Framework dependencies without a native-tree copy.
+
+```powershell
+$project = 'review-tests/Pr2154.PositiveDetections/Pr2154.PositiveDetections.csproj'
+$env:PATH = (Resolve-Path -LiteralPath 'review-tests/Pr2154.DetectionParameters/bin/Release/net10.0').Path + ';' + $env:PATH
+dotnet restore $project
+dotnet build src/AiDotNet.Generators/AiDotNet.Generators.csproj -c Release --no-restore -p:GeneratePackageOnBuild=false
+foreach ($framework in @('net10.0', 'net8.0', 'net471')) {
+ dotnet build $project -f $framework -c Release --no-restore -m:2 -nodeReuse:false -p:BuildProjectReferences=false -p:GeneratePackageOnBuild=false
+ if ($LASTEXITCODE -ne 0) { throw "Build failed for $framework" }
+ dotnet test $project -f $framework -c Release --no-build --no-restore --logger "trx;LogFileName=positive-text-$framework.trx" --results-directory artifacts/pr2154-review
+ if ($LASTEXITCODE -ne 0) { throw "Tests failed for $framework" }
+}
+```
+
+To reproduce the isolated prior-generator control, supply its exact DLL through
+`-p:GeneratorAssemblyPath=` on the focused build. Verify its
+SHA-256 against the table before running the same 18 tests; expect nine failures
+and nine passes, not a green baseline. Omitting that property on the subsequent
+build restores the current generator; verify its copied hash and rerun green.
+The property changes only this runner's reference and does not modify either
+generator binary or the actual core.
diff --git a/review-tests/Pr2154.PositiveObjects/Pr2154.PositiveObjects.csproj b/review-tests/Pr2154.PositiveObjects/Pr2154.PositiveObjects.csproj
new file mode 100644
index 0000000000..9e4dbe28d4
--- /dev/null
+++ b/review-tests/Pr2154.PositiveObjects/Pr2154.PositiveObjects.csproj
@@ -0,0 +1,38 @@
+
+
+ net471;net8.0;net10.0
+ AiDotNetTests
+ latest
+ enable
+ enable
+ true
+ false
+ false
+ false
+ false
+ <_GetChildProjectCopyToOutputDirectoryItems>false
+ true
+ true
+ true
+ $(MSBuildThisFileDirectory)../../src/AiDotNet.Generators/bin/Release/netstandard2.0/AiDotNet.Generators.dll
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/review-tests/Pr2154.PositiveObjects/README.md b/review-tests/Pr2154.PositiveObjects/README.md
new file mode 100644
index 0000000000..31ed8e67c2
--- /dev/null
+++ b/review-tests/Pr2154.PositiveObjects/README.md
@@ -0,0 +1,122 @@
+# PR #2154: controlled positive object detections
+
+This batch addresses review `3985472491` / `PRRT_kwDOKSXUF86hUmqh`.
+It adds a shared positive invariant and a typed factory emitted by the real test
+scaffold generator. No generated leaf test or production numerical forward was
+edited or substituted. Existing random-input tests continue to permit empty
+results and keep their geometry, ordering, and NMS assertions.
+
+## What was proved
+
+The runner discovers the actual nine model types through production metadata,
+runs the real generator, selects their exact nine generated source identities,
+compiles all nine against the actual shared base classes, and invokes them.
+The positive factory uses an explicit Nano/64x64/two-class profile. Ordinary
+generated factories retain their previous defaults.
+
+The fixture initializes each real model, requires live writable trainable chunks,
+then configures actual weights. `Predict` receives normalized 0/1 pixels;
+`Detect` receives the equivalent 0/255 image, matching its public normalization
+contract. Each same initialized model is checked at NMS thresholds 1 and 0.45,
+then must reject its known scores at confidence 0.99.
+
+| Actual model/profile | Candidates at NMS 1 | Result at requested NMS 0.45 | Independent checks |
+| --- | ---: | ---: | --- |
+| YOLOv8/v9/v11 | 84 | 1 | Zero 16-bin DFL logits imply distance 7.5; all clipped boxes are `[0,0,64,64]`. Three live classification biases produce 64 scores of 0.5, 16 of 0.75, and 4 of 0.875. |
+| YOLOv10 default | 84 | 84 | Its documented NMS-free default is preserved; the candidates remain confidence-ranked. |
+| YOLOv10 with explicit `useNmsFree: false` | 84 | 1 | The actual supported NMS mode suppresses duplicates without replacing the numerical forward. |
+| DETR / RT-DETR / DINO | 2 | 1 | Two distinct live query directions pass through actual decoder normalization. An independent scalar normalization/softmax oracle includes the background class. Zero box logits imply exact `[16,16,48,48]` boxes. Scores are approximately 0.952574 and 0.658553. |
+| Faster R-CNN / Cascade R-CNN | 275 | 39 | A real spatial channel passes through backbone, FPN, ROIAlign, and classifier. Raw heads establish scores/proposals; independent softmax and greedy IoU/NMS check decoded detections and ordering. |
+
+For the R-CNN profiles, zero RPN score/delta heads yield fixed proposals and zero
+ROI regression heads preserve proposal geometry. The highest-score proposal is
+independently pinned to the stride-4, ratio-1/2 anchor centered at `(62,46)`:
+`[62-16*sqrt(2), 46-16/sqrt(2), 64, 46+16/sqrt(2)]`. R-CNN's oracle derives other
+scores and proposals from actual raw model outputs; it is **not** a separately
+implemented reference backbone or evidence of trained recognition accuracy.
+
+Non-vacuity guards require at least two same-class candidates, genuinely distinct
+scores, and an overlap that requires suppression. Independent negative controls
+reject empty/missing results, reversed/tied scores, wrong classes/geometry,
+non-finite values, wrong image dimensions, ignored NMS, lower-score winners, and
+over-suppression. Three additional real-model mutants corrupt only `Detect`
+(empty/reverse/ignore NMS), never `Forward`. Each must fail the shared invariant;
+`MutationApplied` proves the failure occurred after reaching that corruption,
+not at an earlier constructor, live-state, or raw-oracle precondition.
+
+## Failure-before / success-after evidence
+
+The same final 49-case test source was compiled against the previous generator
+from `7e1cb86a4ba9a5d4a02569d870d28d26a4a5d0de`, then against the new generator.
+Both used the same unchanged actual .NET 10 production DLL. The old generator
+misses nine positive factories: nine syntax assertions fail and eighteen runtime
+cases fail compilation with the exact nine `CS0534` missing-factory diagnostics.
+The other 22 controls pass. Those failures are missing coverage infrastructure,
+not fabricated claims that the old production models returned empty results.
+
+| Run | Passed | Failed | Skipped | Report in `artifacts/pr2154-review` |
+| --- | ---: | ---: | ---: | --- |
+| Final-source historical generator, .NET 10 | 22 | 27 | 0 | `pr2154-positive-object-final-before49.trx` |
+| Final .NET 10 | 49 | 0 | 0 | `pr2154-positive-object-final-net10.trx` |
+| Final .NET 8 | 49 | 0 | 0 | `pr2154-positive-object-final-net8.trx` |
+| Final .NET Framework 4.7.1 | 49 | 0 | 0 | `pr2154-positive-object-final-net471.trx` |
+| Independent parent-agent replay, .NET 10 | 49 | 0 | 0 | `pr2154-positive-object-root-independent49.trx` |
+
+The 49 cases are nine factory contracts, nine actual positive invariants, nine
+ordinary generated-fixture replays (each invokes three existing invariants),
+19 oracle/precondition controls, and three live-model negative controls.
+Generator build: zero errors, 67 analyzer warnings. Focused runner builds:
+zero errors on all three frameworks (0/2/0 warnings on net10/net8/net471).
+No production core rebuild was required for these test/generator-only changes.
+The independent replay verified the exact final test-DLL hash and completed in
+31 seconds after a separate full source review of the shared fixture and controls.
+
+SHA-256 identities:
+
+| Artifact | SHA-256 |
+| --- | --- |
+| Historical generator | `7F99F0DA179EB2C184AEAFCA88BEBD9146DF8C0C16437D72E1DFC76A6D3A80F2` |
+| Final generator | `1AF4448E70ED82A2248EDC7077225B7925ED9F69E78CE642331D71BC5587141F` |
+| Unchanged net10 core | `69BCCF0B1B5AD45EB4050C3D83D352980BB7F0F8EC4D3153EBEBB5469A4B9B8D` |
+| Unchanged net8 core | `97F5D10424D8B75698A144E248797B18A149F08B376727361A37E609E82B27BA` |
+| Unchanged net471 core | `B6E19F6F44C9F6E43FFA2A9A29B14B9999B9F76F86669F273644027CA6BEB64B` |
+| Final net10 test DLL | `6A5E039CB1106415509DDD8F74F38D3EFBDD9602175E07834CF96A4E8EF72AD8` |
+| Final net8 test DLL | `B7A103D563DBBF33C38D7D79B8E23C6D639DF128460CFCA2740B13566B4512D5` |
+| Final net471 test DLL | `0943A30D97872B3780B261AF566AFF1E8E1FF795A35BC9D11867875A001B424A` |
+
+## Reproduction
+
+Run from the repository root after the actual production core has been built for
+the selected framework. This small runner reuses that output and the installed
+CPU native closure; it does not copy all RID/native assets. Its settings explicitly
+disable `CopyLocalRuntimeTargetAssets`, `CopyLocalLockFileAssemblies`, and child
+project Content propagation. The actual module initializer, license helper,
+generated trace helper, and xUnit configuration are included. .NET Framework uses
+the existing managed-only test dependency closure target.
+
+```powershell
+dotnet restore review-tests/Pr2154.PositiveObjects/Pr2154.PositiveObjects.csproj -p:NuGetAudit=false
+dotnet build src/AiDotNet.Generators/AiDotNet.Generators.csproj -c Release --no-restore -m:1 -nodeReuse:false
+$framework = 'net10.0' # also validated: net8.0 and net471
+dotnet build review-tests/Pr2154.PositiveObjects/Pr2154.PositiveObjects.csproj -f $framework -c Release --no-restore -m:1 -nodeReuse:false -p:BuildProjectReferences=false -p:GeneratePackageOnBuild=false
+$cpuNativeDirectory = (Resolve-Path "review-tests/Pr2154.DetectionParameters/bin/Release/$framework").Path
+$env:PATH = $cpuNativeDirectory + ';' + $env:PATH
+dotnet test review-tests/Pr2154.PositiveObjects/Pr2154.PositiveObjects.csproj -f $framework -c Release --no-build --no-restore --logger 'trx;LogFileName=positive-object-replay.trx' --results-directory artifacts/pr2154-review
+```
+
+For an isolated historical comparison, pass the path of a generator built from
+the exact historical revision above as `GeneratorAssemblyPath`. Verify it before
+using it; the retained previous text-fixture output is the local evidence source:
+
+```powershell
+$historicalGenerator = (Resolve-Path 'review-tests/Pr2154.PositiveDetections/bin/Release/net10.0/AiDotNet.Generators.dll').Path
+if ((Get-FileHash -LiteralPath $historicalGenerator).Hash -ne '7F99F0DA179EB2C184AEAFCA88BEBD9146DF8C0C16437D72E1DFC76A6D3A80F2') { throw 'Historical generator hash mismatch.' }
+dotnet build review-tests/Pr2154.PositiveObjects/Pr2154.PositiveObjects.csproj -f net10.0 -c Release --no-restore -m:1 -nodeReuse:false -p:BuildProjectReferences=false -p:GeneratorAssemblyPath=$historicalGenerator
+# Run the same 49 tests; expected outcome: 22 passed, 27 failed, no skips.
+# Rebuild without GeneratorAssemblyPath to restore the current generator afterward.
+```
+
+This is focused local CPU proof, not a full detector-family/all-repository CI run,
+GPU validation, a trained accuracy benchmark, or semantic detector-training proof.
+The production training review and newly reported metric/text-boundary findings
+remain separate open work; this batch alone does not make the PR ready to merge.
diff --git a/src/AiDotNet.Generators/LayerStructureInitializationAnalysis.cs b/src/AiDotNet.Generators/LayerStructureInitializationAnalysis.cs
new file mode 100644
index 0000000000..6c73857fb7
--- /dev/null
+++ b/src/AiDotNet.Generators/LayerStructureInitializationAnalysis.cs
@@ -0,0 +1,355 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace AiDotNet.Generators;
+
+///
+/// Proves the narrow case where a layer's initializer cannot construct its optional children.
+/// Unknown paths retain ordinary initialization; this is not whole-program side-effect analysis.
+///
+internal static class LayerStructureInitializationAnalysis
+{
+ internal static bool IsChildIndependent(
+ Compilation compilation, INamedTypeSymbol owner, IReadOnlyList children)
+ {
+ var layerBase = compilation.GetTypeByMetadataName("AiDotNet.NeuralNetworks.Layers.LayerBase`1");
+ if (layerBase is null || !SymbolEqualityComparer.Default.Equals(owner.BaseType?.OriginalDefinition, layerBase))
+ return false;
+ if (children.Count == 0 || children.Any(child => child.DeclaredAccessibility != Accessibility.Private
+ || child.NullableAnnotation != NullableAnnotation.Annotated))
+ return false;
+ var initializer = owner.GetMembers("EnsureInitialized").OfType()
+ .SingleOrDefault(method => !method.IsStatic && method.Parameters.Length == 0);
+ if (initializer is null) return false;
+ var proof = new Proof(compilation, owner, children);
+ foreach (var constructor in owner.InstanceConstructors.Where(constructor => !constructor.IsImplicitlyDeclared))
+ if (!proof.VisitMethod(constructor)) return false;
+ return proof.VisitMethod(initializer);
+ }
+
+ private sealed class Proof : OperationWalker
+ {
+ private const int MaximumMethods = 128;
+ private readonly Compilation _compilation;
+ private readonly HashSet _children;
+ private readonly HashSet _ownerTypes = new(SymbolEqualityComparer.Default);
+ private readonly HashSet _valueContracts = new(SymbolEqualityComparer.Default);
+ private readonly HashSet _exceptionContracts = new(SymbolEqualityComparer.Default);
+ private readonly IPropertySymbol? _memberName;
+ private readonly INamedTypeSymbol? _runtimeType;
+ private readonly Dictionary> _visited = new(SymbolEqualityComparer.Default);
+ private IMethodSymbol? _currentMethod;
+ private ulong _knownNullParameters;
+ private int _methodContexts;
+ private bool _independent = true;
+
+ internal Proof(Compilation compilation, INamedTypeSymbol owner, IEnumerable children)
+ {
+ _compilation = compilation;
+ _memberName = compilation.GetTypeByMetadataName("System.Reflection.MemberInfo")?
+ .GetMembers(nameof(System.Reflection.MemberInfo.Name)).OfType().SingleOrDefault();
+ _runtimeType = compilation.GetTypeByMetadataName("System.Type");
+ _children = new HashSet(children.Select(child => child.OriginalDefinition), SymbolEqualityComparer.Default);
+ for (var type = owner; type is not null && type.SpecialType != SpecialType.System_Object; type = type.BaseType)
+ _ownerTypes.Add(type.OriginalDefinition);
+ foreach (var contract in owner.AllInterfaces) _ownerTypes.Add(contract.OriginalDefinition);
+
+ // Resolve contracts to symbols, never match a model/type name at runtime. These APIs
+ // operate on numeric values, shapes, tensor storage or value-only initialization. An
+ // arbitrary external receiver is NOT assumed to be independent of the layer graph.
+ foreach (string metadataName in new[]
+ {
+ "AiDotNet.Tensors.LinearAlgebra.Tensor`1", "AiDotNet.Tensors.LinearAlgebra.TensorBase`1",
+ "AiDotNet.Tensors.LinearAlgebra.Vector`1",
+ "AiDotNet.Tensors.LinearAlgebra.Matrix`1", "AiDotNet.Tensors.LinearAlgebra.TensorShape",
+ "AiDotNet.Tensors.LinearAlgebra.WeightRegistry", "AiDotNet.Tensors.Interfaces.INumericOperations`1",
+ "AiDotNet.Tensors.Interfaces.IVectorizedOperations`1", "AiDotNet.Tensors.Engines.IEngine",
+ "AiDotNet.Tensors.Engines.AiDotNetEngine", "AiDotNet.Tensors.Helpers.SimdRandom",
+ "AiDotNet.Tensors.Helpers.MathHelper", "AiDotNet.Helpers.MathHelper",
+ "AiDotNet.Initialization.IInitializationStrategy`1", "System.Math",
+ "System.Threading.Interlocked", "System.Runtime.CompilerServices.Unsafe",
+ "System.MemoryExtensions", "System.Buffers.ArrayPool`1", "System.Collections.Generic.List`1",
+ "System.Span`1", "System.ReadOnlySpan`1", "System.Memory`1", "System.ReadOnlyMemory`1",
+ "System.Nullable`1", "System.Array"
+ })
+ {
+ if (compilation.GetTypeByMetadataName(metadataName) is { } contract)
+ _valueContracts.Add(contract.OriginalDefinition);
+ }
+ foreach (string metadataName in new[]
+ {
+ "System.ArgumentException", "System.ArgumentNullException", "System.ArgumentOutOfRangeException",
+ "System.InvalidOperationException", "System.NotSupportedException", "System.OverflowException"
+ })
+ if (compilation.GetTypeByMetadataName(metadataName) is { } exception)
+ _exceptionContracts.Add(exception);
+ }
+
+ internal bool VisitMethod(IMethodSymbol method, ulong knownNullParameters = 0)
+ {
+ method = method.OriginalDefinition;
+ if (!_independent) return false;
+ if (_visited.TryGetValue(method, out var contexts) && contexts.Contains(knownNullParameters)) return true;
+ if (_methodContexts++ >= MaximumMethods || method.IsAbstract || method.IsExtern || method.Parameters.Length > 64)
+ return _independent = false;
+ if (contexts is null) _visited.Add(method, contexts = new HashSet());
+ contexts.Add(knownNullParameters);
+
+ // GetType cannot construct children and has no source body in the compilation.
+ if (method.ContainingType.SpecialType == SpecialType.System_Object
+ && method.Name == nameof(object.GetType) && method.Parameters.Length == 0)
+ return true;
+
+ if (method.DeclaringSyntaxReferences.Length != 1) return _independent = false;
+ var syntax = method.DeclaringSyntaxReferences[0].GetSyntax();
+ SyntaxNode? body = syntax switch
+ {
+ MethodDeclarationSyntax declaration => (SyntaxNode?)declaration.Body ?? declaration.ExpressionBody?.Expression,
+ ConstructorDeclarationSyntax constructor => (SyntaxNode?)constructor.Body ?? constructor.ExpressionBody?.Expression,
+ AccessorDeclarationSyntax accessor => (SyntaxNode?)accessor.Body ?? accessor.ExpressionBody?.Expression,
+ PropertyDeclarationSyntax property => property.ExpressionBody?.Expression,
+ ArrowExpressionClauseSyntax arrow => arrow.Expression,
+ _ => null
+ };
+ // An auto-property's compiler-generated accessor only accesses its backing field.
+ if (body is null && syntax is AccessorDeclarationSyntax { Body: null, ExpressionBody: null } accessorSyntax
+ && accessorSyntax.Parent?.Parent is PropertyDeclarationSyntax { ExpressionBody: null })
+ return true;
+ if (body is null) return _independent = false;
+ var operation = _compilation.GetSemanticModel(body.SyntaxTree).GetOperation(body);
+ if (operation is null) return _independent = false;
+ var previousMethod = _currentMethod;
+ ulong previousNullParameters = _knownNullParameters;
+ _currentMethod = method;
+ _knownNullParameters = knownNullParameters;
+ try { Visit(operation); }
+ finally
+ {
+ _currentMethod = previousMethod;
+ _knownNullParameters = previousNullParameters;
+ }
+ return _independent;
+ }
+
+ public override void Visit(IOperation? operation)
+ {
+ if (operation is null || !_independent) return;
+ // A null argument is a call-site fact, not general dataflow. Any write or ref escape
+ // invalidates this proof instead of incorrectly pruning a later conditional callback.
+ if (operation is IAssignmentOperation assignment && ContainsKnownNullParameter(assignment.Target)
+ || operation is ISimpleAssignmentOperation { IsRef: true }
+ || operation is IVariableDeclaratorOperation { Symbol.RefKind: not RefKind.None }
+ || operation is IArgumentOperation { Parameter.RefKind: not RefKind.None } argument
+ && ContainsKnownNullParameter(argument.Value))
+ _independent = false;
+
+ // Operators and conversions can execute arbitrary user code without an invocation node.
+ // Only the same resolved value contracts may supply those methods; unknown/dynamic
+ // operations and implicit disposal remain outside this narrowly bounded proof.
+ IMethodSymbol? operatorMethod = operation switch
+ {
+ IBinaryOperation binary => binary.OperatorMethod,
+ IUnaryOperation unary => unary.OperatorMethod,
+ IConversionOperation conversion => conversion.OperatorMethod,
+ IIncrementOrDecrementOperation increment => increment.OperatorMethod,
+ ICompoundAssignmentOperation compound => compound.OperatorMethod,
+ _ => null
+ };
+ if (operatorMethod is not null && !IsValueContract(operatorMethod.ContainingType)
+ && !IsRuntimeTypeEquality(operation, operatorMethod)) _independent = false;
+ if (operation is ICompoundAssignmentOperation compoundAssignment
+ && (!IsValueConversion(compoundAssignment.InConversion) || !IsValueConversion(compoundAssignment.OutConversion)))
+ _independent = false;
+ if (operation.Type?.TypeKind == TypeKind.Dynamic
+ || operation is IDynamicObjectCreationOperation or IDynamicIndexerAccessOperation
+ or IDynamicMemberReferenceOperation or ITypeParameterObjectCreationOperation
+ or IUsingOperation or IUsingDeclarationOperation or IAwaitOperation
+ or IForEachLoopOperation or IEventAssignmentOperation or IEventReferenceOperation
+ or ISpreadOperation or IInterpolatedStringHandlerCreationOperation)
+ _independent = false;
+ if (operation is ICollectionExpressionOperation { Type: not IArrayTypeSymbol }) _independent = false;
+ if (operation is IInterpolationOperation interpolation && !IsPrimitiveFormatting(interpolation.Expression.Type))
+ _independent = false;
+ if (_independent) base.Visit(operation);
+ }
+
+ private static bool IsPrimitiveFormatting(ITypeSymbol? type) =>
+ type is not null && (type.TypeKind == TypeKind.Enum || IsPrimitiveValueType(type.SpecialType));
+
+ private static bool IsPrimitiveValueType(SpecialType type) => type is
+ SpecialType.System_Boolean or SpecialType.System_Char or SpecialType.System_String
+ or SpecialType.System_SByte or SpecialType.System_Byte or SpecialType.System_Int16
+ or SpecialType.System_UInt16 or SpecialType.System_Int32 or SpecialType.System_UInt32
+ or SpecialType.System_Int64 or SpecialType.System_UInt64 or SpecialType.System_IntPtr
+ or SpecialType.System_UIntPtr or SpecialType.System_Single or SpecialType.System_Double
+ or SpecialType.System_Decimal or SpecialType.System_Void;
+
+ private bool IsValueConversion(CommonConversion conversion) =>
+ conversion.MethodSymbol is null || IsValueContract(conversion.MethodSymbol.ContainingType);
+
+ private bool IsRuntimeTypeEquality(IOperation operation, IMethodSymbol method) =>
+ SymbolEqualityComparer.Default.Equals(method.ContainingType, _runtimeType)
+ && operation is IBinaryOperation
+ {
+ OperatorKind: BinaryOperatorKind.Equals or BinaryOperatorKind.NotEquals,
+ LeftOperand: ITypeOfOperation,
+ RightOperand: ITypeOfOperation
+ };
+
+ private bool ContainsKnownNullParameter(IOperation operation) =>
+ operation is IParameterReferenceOperation && IsKnownNull(operation)
+ || operation.ChildOperations.Any(ContainsKnownNullParameter);
+
+ public override void VisitObjectCreation(IObjectCreationOperation operation)
+ {
+ if (operation.Constructor is not { } constructor
+ || !IsValueContract(constructor.ContainingType) && !_exceptionContracts.Contains(constructor.ContainingType)
+ || !ArgumentsAreValues(operation.Arguments))
+ _independent = false;
+ base.VisitObjectCreation(operation);
+ }
+
+ public override void VisitFieldReference(IFieldReferenceOperation operation)
+ {
+ // Even reading a child is rejected: it could be aliased into a later mutation.
+ if (_children.Contains(operation.Field.OriginalDefinition)) _independent = false;
+ base.VisitFieldReference(operation);
+ }
+
+ public override void VisitInvocation(IInvocationOperation operation)
+ {
+ var method = operation.TargetMethod;
+ if (method.MethodKind is MethodKind.DelegateInvoke or MethodKind.LocalFunction)
+ {
+ _independent = false;
+ return;
+ }
+ if (operation.Instance is IInstanceReferenceOperation { ReferenceKind: InstanceReferenceKind.ContainingTypeInstance })
+ {
+ bool explicitBase = operation.Syntax is InvocationExpressionSyntax
+ { Expression: MemberAccessExpressionSyntax { Expression: BaseExpressionSyntax } };
+ if (!explicitBase && !method.IsSealed && (method.IsVirtual || method.IsOverride || method.IsAbstract))
+ _independent = false;
+ else
+ VisitMethod(method, NullArguments(operation));
+ }
+ else if (method.IsStatic && _ownerTypes.Contains(method.ContainingType.OriginalDefinition))
+ VisitMethod(method, NullArguments(operation));
+ else if (method.ContainingType.SpecialType == SpecialType.System_Object
+ && method.IsStatic && method.Name == nameof(object.ReferenceEquals))
+ {
+ // Object identity is a framework intrinsic, not a callback into either operand.
+ }
+ else if (!IsValueContract(method.ContainingType) || !ArgumentsAreValues(operation.Arguments))
+ _independent = false;
+ // Calls on external tensors/strategies operate only on their typed arguments. A strategy
+ // secretly capturing the owner to build its graph is not a supported structure contract.
+ // Owner/child/delegate arguments are rejected by the ordinary descendant walk below.
+ base.VisitInvocation(operation);
+ }
+
+ public override void VisitPropertyReference(IPropertyReferenceOperation operation)
+ {
+ if (operation.Instance is IInstanceReferenceOperation { ReferenceKind: InstanceReferenceKind.ContainingTypeInstance }
+ || operation.Property.IsStatic && _ownerTypes.Contains(operation.Property.ContainingType.OriginalDefinition))
+ {
+ var property = operation.Property;
+ if (!property.IsSealed && (property.IsVirtual || property.IsOverride || property.IsAbstract))
+ _independent = false;
+ else
+ {
+ if (property.GetMethod is { } getter) VisitMethod(getter);
+ // Inspect both accessors even for a read: ++, deconstruction and ref patterns
+ // must never hide a structural setter behind a non-assignment parent node.
+ if (property.SetMethod is { } setter) VisitMethod(setter);
+ }
+ }
+ else if (!IsRuntimeTypeName(operation) && !IsValueContract(operation.Property.ContainingType))
+ _independent = false;
+ base.VisitPropertyReference(operation);
+ }
+
+ private bool IsRuntimeTypeName(IPropertyReferenceOperation operation) =>
+ SymbolEqualityComparer.Default.Equals(operation.Property.OriginalDefinition, _memberName)
+ && operation.Instance is IInvocationOperation
+ {
+ TargetMethod.ContainingType.SpecialType: SpecialType.System_Object,
+ TargetMethod.Name: nameof(object.GetType),
+ Arguments.Length: 0
+ };
+
+ private bool IsValueContract(ITypeSymbol? type)
+ {
+ if (type is null || type.TypeKind == TypeKind.Error) return false;
+ if (_ownerTypes.Contains(type.OriginalDefinition)) return false;
+ if (type is IArrayTypeSymbol array) return IsValueContract(array.ElementType);
+ if (type.TypeKind is TypeKind.Enum or TypeKind.TypeParameter) return true;
+ if (IsPrimitiveValueType(type.SpecialType)) return true;
+ return type is INamedTypeSymbol named && _valueContracts.Contains(named.OriginalDefinition)
+ && named.TypeArguments.All(IsValueContract);
+ }
+
+ private bool ArgumentsAreValues(IEnumerable arguments)
+ {
+ foreach (var argument in arguments)
+ {
+ if (IsKnownNull(argument.Value)) continue;
+ IOperation value = argument.Value;
+ while (value is IConversionOperation conversion) value = conversion.Operand;
+ if (!IsValueContract(value.Type)) return false;
+ }
+ return true;
+ }
+
+ private ulong NullArguments(IInvocationOperation operation)
+ {
+ ulong mask = 0;
+ foreach (var argument in operation.Arguments)
+ if (argument.Parameter is { Ordinal: < 64 } parameter && IsKnownNull(argument.Value))
+ mask |= 1UL << parameter.Ordinal;
+ return mask;
+ }
+
+ private bool IsKnownNull(IOperation operation)
+ {
+ if (operation.ConstantValue is { HasValue: true, Value: null }) return true;
+ if (operation is IConversionOperation conversion) return IsKnownNull(conversion.Operand);
+ return operation is IParameterReferenceOperation reference && reference.Parameter.Ordinal < 64
+ && SymbolEqualityComparer.Default.Equals(reference.Parameter.ContainingSymbol.OriginalDefinition, _currentMethod)
+ && (_knownNullParameters & (1UL << reference.Parameter.Ordinal)) != 0;
+ }
+
+ public override void VisitConditionalAccess(IConditionalAccessOperation operation)
+ {
+ // The shared tensor allocator's optional callback is null at this call site. Track
+ // that explicit/default constant per method context, never suppress unknown callbacks.
+ if (IsKnownNull(operation.Operation)) Visit(operation.Operation);
+ else base.VisitConditionalAccess(operation);
+ }
+
+ public override void VisitInstanceReference(IInstanceReferenceOperation operation)
+ {
+ if (operation.ReferenceKind == InstanceReferenceKind.ContainingTypeInstance)
+ {
+ bool receiver = operation.Parent switch
+ {
+ IInvocationOperation call => call.Instance == operation,
+ IFieldReferenceOperation field => field.Instance == operation,
+ IPropertyReferenceOperation property => property.Instance == operation,
+ _ => false
+ };
+ if (!receiver) _independent = false;
+ }
+ base.VisitInstanceReference(operation);
+ }
+
+ public override void VisitAnonymousFunction(IAnonymousFunctionOperation operation) => _independent = false;
+ public override void VisitDelegateCreation(IDelegateCreationOperation operation) => _independent = false;
+ public override void VisitDynamicInvocation(IDynamicInvocationOperation operation) => _independent = false;
+ public override void VisitFunctionPointerInvocation(IFunctionPointerInvocationOperation operation) => _independent = false;
+ public override void VisitInvalid(IInvalidOperation operation) => _independent = false;
+ }
+}
diff --git a/src/AiDotNet.Generators/ModelParameterGenerator.cs b/src/AiDotNet.Generators/ModelParameterGenerator.cs
index fc9a46b8f8..8202d21ec4 100644
--- a/src/AiDotNet.Generators/ModelParameterGenerator.cs
+++ b/src/AiDotNet.Generators/ModelParameterGenerator.cs
@@ -375,8 +375,18 @@ and not ParameterMemberSemanticModel.Kind.External
var kind = ComponentKindFor(memberType, elem, isDeclaredSlot: member.IsAbstract);
if (kind == "one")
{
+ // A nullable-annotated component may legitimately be absent (a detector
+ // without a neck). A non-optional accessor reports a null component as
+ // ShapeDeferred with no count, which makes the WHOLE model's layout
+ // unresolved and every parameter read throw -- the same regression the
+ // "adapt" branch below documents for absent conditioners. Mark it optional
+ // so absence is the resolved, parameter-free fact it is; a present
+ // component is unaffected.
+ bool absentIsResolved = memberType.NullableAnnotation == NullableAnnotation.Annotated;
components.Add((member.Name,
- $"new ComponentAccessorParameterSource<{elem}>(() => {member.Name})",
+ absentIsResolved
+ ? $"new ComponentAccessorParameterSource<{elem}>(() => {member.Name}, optional: true)"
+ : $"new ComponentAccessorParameterSource<{elem}>(() => {member.Name})",
RoleExpression(classification.Kind),
AvailabilityExpression(member, classification.Kind)));
continue;
diff --git a/src/AiDotNet.Generators/TestScaffoldGenerator.cs b/src/AiDotNet.Generators/TestScaffoldGenerator.cs
index 1ae8f195ec..f560744c30 100644
--- a/src/AiDotNet.Generators/TestScaffoldGenerator.cs
+++ b/src/AiDotNet.Generators/TestScaffoldGenerator.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
@@ -470,6 +470,12 @@ public class TestScaffoldGenerator : IIncrementalGenerator
// at full paper scale. The model defaults stay 16B and fully user-customizable; only the default-gate test defers.
// KimiVLThinking is the same 16B MoE backbone with a reasoning head — same OOM profile, same deferral.
"KimiVL", "KimiVLThinking",
+ // CascadeRCNN (Cai & Vasconcelos 2018) at paper scale: 1000 proposals through three 12544x1024 stages on
+ // ResNet-50/FPN, every stage on the tape. Measured alone on the 26-test class: 38.6 GB in double (killed
+ // the ~16 GB shard-C runner). The bin-major RoIAlign cut it to 27.2 GB; float cut the double run to
+ // 20.7 GB but broke Detect_ControlledPositiveHead; under a 12 GB GC hard limit two tests still exhaust
+ // the heap. The live set exceeds the runner, so it runs at paper scale in the nightly heavy lane.
+ "CascadeRCNN",
// MiniGPTv2 (Chen et al. 2023): LLaMA-2-backbone VLM. Already in Fp32, but the LLaMA-2 decoder weights
// are ~28 GB even at fp32, so the live J-M run OOMs it (NamedLayerActivations). Float is insufficient
// for a 7B-class backbone; defer to the nightly HeavyTimeout lane. Paper defaults (LLaMA-2 scale) intact.
@@ -3057,7 +3063,8 @@ private static void Execute(
bool canConstruct = (model.HasParameterlessConstructor
|| model.HasArchitectureOnlyConstructor
- || model.HasVectorOnlyConstructor) &&
+ || model.HasVectorOnlyConstructor
+ || model.HasOptionsOnlyConstructor) &&
IsCompatibleWithFamily(model, family.Value);
// Don't emit a runtime-throwing NotImplementedException stub
@@ -3081,9 +3088,11 @@ private static void Execute(
// together is what made this class of gap unreadable in the first place.
bool hasCtor = model.HasParameterlessConstructor
|| model.HasArchitectureOnlyConstructor
- || model.HasVectorOnlyConstructor;
+ || model.HasVectorOnlyConstructor
+ || model.HasOptionsOnlyConstructor;
string reason = !hasCtor
- ? "it has no supported parameterless, architecture-only, or vector-only constructor, so the "
+ ? "it has no supported parameterless, architecture-only, vector-only, or options-only "
+ + "constructor, so the "
+ "generated fixture has no way to build it"
: $"it resolves to test family {family.Value}, whose fixture requires an "
+ $"interface this type does not implement (see IsCompatibleWithFamily); the "
@@ -3148,6 +3157,34 @@ private static void Execute(
///
/// Processes a single model type symbol, extracting metadata and checking for test coverage.
///
+ ///
+ /// Whether a type can be instantiated with new T(): it has a public constructor taking
+ /// no arguments, or one whose parameters all have defaults, or it declares none at all and so
+ /// carries the implicit public parameterless constructor.
+ ///
+ private static bool IsConstructibleWithNoArguments(INamedTypeSymbol type)
+ {
+ if (type.IsAbstract || type.IsStatic)
+ return false;
+
+ foreach (var ctor in type.InstanceConstructors)
+ {
+ if (ctor.DeclaredAccessibility != Accessibility.Public)
+ continue;
+
+ bool callable = true;
+ foreach (var p in ctor.Parameters)
+ {
+ if (!p.HasExplicitDefaultValue) { callable = false; break; }
+ }
+
+ if (callable)
+ return true;
+ }
+
+ return false;
+ }
+
private static void ProcessModelSymbol(
INamedTypeSymbol modelClass,
INamedTypeSymbol? domainAttrSymbol,
@@ -3317,6 +3354,10 @@ private static void ProcessModelSymbol(
bool extendsMultiLabel = false, extendsFinancialNLP = false;
bool extendsRiskModel = false, extendsPortfolioOptimizer = false;
bool extendsTransformerNER = false, extendsSpanBasedNER = false, extendsSequenceLabelingNER = false;
+ // Computer-vision detection / OCR. These derive from ModelBase, Tensor>
+ // rather than NeuralNetworkBase, so without their own families they fell through to
+ // NeuralNetwork and were rejected for not implementing INeuralNetworkModel.
+ bool extendsObjectDetector = false, extendsTextDetector = false, extendsOcr = false;
var baseType = modelClass.BaseType;
while (baseType is not null)
@@ -3386,6 +3427,12 @@ private static void ProcessModelSymbol(
extendsDocumentNN = true;
else if (baseName.StartsWith("VisionLanguageModelBase", System.StringComparison.Ordinal))
extendsVisionLanguage = true;
+ else if (baseName.StartsWith("ObjectDetectorBase", System.StringComparison.Ordinal))
+ extendsObjectDetector = true;
+ else if (baseName.StartsWith("TextDetectorBase", System.StringComparison.Ordinal))
+ extendsTextDetector = true;
+ else if (baseName.StartsWith("OCRBase", System.StringComparison.Ordinal))
+ extendsOcr = true;
else if (baseName.StartsWith("SegmentationModelBase", System.StringComparison.Ordinal) ||
baseName.EndsWith("SegmentationBase", System.StringComparison.Ordinal))
extendsSegmentation = true;
@@ -3438,7 +3485,9 @@ private static void ProcessModelSymbol(
bool hasParameterlessCtor = false;
bool hasArchitectureOnlyCtor = false;
bool hasVectorOnlyCtor = false;
+ bool hasOptionsOnlyCtor = false;
string? architectureParamTypeName = null;
+ string? optionsOnlyParamTypeName = null;
foreach (var ctor in modelClass.InstanceConstructors)
{
if (ctor.DeclaredAccessibility != Accessibility.Public)
@@ -3490,6 +3539,35 @@ private static void ProcessModelSymbol(
hasVectorOnlyCtor = true;
}
+ // Options-only: the model's single required argument is its own options object, and
+ // that object can itself be built with no arguments (#2137). The detection-family
+ // models are the bulk of this: YOLOv8/v10/v11, DETR, RTDETR, DINO and CascadeRCNN all
+ // take ObjectDetectionOptions and nothing else, and that type declares no
+ // constructor at all, so `new YOLOv8(new ObjectDetectionOptions())`
+ // already compiles. The same holds for TextDetectionOptions and OCROptions.
+ //
+ // Both halves are required. The name check keeps this to types that are genuinely a
+ // model's configuration bag rather than any default-constructible dependency, and the
+ // constructor check is what makes the emitted expression compile -- a name ending in
+ // "Options" proves nothing on its own.
+ if (!firstParam.HasExplicitDefaultValue
+ && restOptional
+ && firstParam.Type is INamedTypeSymbol optionsType
+ && StripBacktick(optionsType.Name).EndsWith("Options", System.StringComparison.Ordinal)
+ && IsConstructibleWithNoArguments(optionsType))
+ {
+ hasOptionsOnlyCtor = true;
+ string optionsTypeName = optionsType.ToDisplayString();
+ if (optionsType.IsGenericType)
+ {
+ var unbound = optionsType.ConstructedFrom.ToDisplayString();
+ int tick = unbound.IndexOf('<');
+ if (tick > 0) unbound = unbound.Substring(0, tick);
+ optionsTypeName = unbound + "";
+ }
+ optionsOnlyParamTypeName = optionsTypeName;
+ }
+
// Check if the first parameter type IS exactly NeuralNetworkArchitecture.
// Derived types (CodeSynthesisArchitecture, etc.) have incompatible constructors
// and need manual test classes — they stay as NotImplementedException.
@@ -3553,6 +3631,12 @@ private static void ProcessModelSymbol(
HasParameterlessConstructor = hasParameterlessCtor,
HasArchitectureOnlyConstructor = hasArchitectureOnlyCtor,
HasVectorOnlyConstructor = hasVectorOnlyCtor,
+ HasOptionsOnlyConstructor = hasOptionsOnlyCtor,
+ ImplementsDetectionTraining = domainAttrSymbol?.ContainingAssembly.GetTypeByMetadataName(
+ "AiDotNet.Interfaces.IDetectionTrainingModel`1") is INamedTypeSymbol detectionTrainingInterface
+ && modelClass.AllInterfaces.Any(iface => SymbolEqualityComparer.Default.Equals(
+ iface.OriginalDefinition, detectionTrainingInterface)),
+ OptionsOnlyParamTypeName = optionsOnlyParamTypeName,
InheritsFromExcludedBase = InheritsFromAnyExcludedBase(modelClass),
RequestsFloatScaffold = HasFloatScaffoldAttribute(modelClass),
ArchitectureParamTypeName = architectureParamTypeName,
@@ -3563,6 +3647,9 @@ private static void ProcessModelSymbol(
ExtendsDocumentNeuralNetworkBase = extendsDocumentNN,
ExtendsVisionLanguageModelBase = extendsVisionLanguage,
ExtendsSegmentationModelBase = extendsSegmentation,
+ ExtendsObjectDetectorBase = extendsObjectDetector,
+ ExtendsTextDetectorBase = extendsTextDetector,
+ ExtendsOCRBase = extendsOcr,
ExtendsVideoNeuralNetworkBase = extendsVideoNN,
ExtendsTtsModelBase = extendsTts,
ExtendsFinancialModelBase = extendsFinancial,
@@ -4005,6 +4092,20 @@ private static bool ImplementsIFullModel(INamedTypeSymbol type)
if (model.ExtendsVisionLanguageModelBase)
return TestFamily.VisionLanguage;
+ // Priority 10a: Object detection (YOLO, DETR/DINO/RT-DETR, Faster/Cascade R-CNN).
+ // Checked ahead of Segmentation because instance-segmentation detectors carry masks on
+ // their detections but are still detectors: their invariant set is the box/NMS one.
+ if (model.ExtendsObjectDetectorBase)
+ return TestFamily.ObjectDetection;
+
+ // Priority 10b: Text detection (CRAFT, DBNet, EAST).
+ if (model.ExtendsTextDetectorBase)
+ return TestFamily.TextDetection;
+
+ // Priority 10c: Text recognition / OCR (CRNN, TrOCR).
+ if (model.ExtendsOCRBase)
+ return TestFamily.OCR;
+
// Priority 11: Segmentation
if (model.ExtendsSegmentationModelBase)
return TestFamily.Segmentation;
@@ -11067,6 +11168,28 @@ private static void EmitGeneratedTestClass(
"taskType: AiDotNet.Enums.NeuralNetworkTaskType.Regression, " +
"inputHeight: 32, inputWidth: 32, inputDepth: 3, outputSize: 3))";
}
+ else if (model.HasOptionsOnlyConstructor
+ && model.TypeParameterCount == 1
+ && model.OptionsOnlyParamTypeName is not null)
+ {
+ // The model's one required argument is its own options object, and that object is
+ // constructible with no arguments, so the fixture builds the model from it (#2137).
+ // The detection families additionally pin InputSize to the fixture's 64x64 image:
+ // Detect resizes every image to InputSize, and at the 640x640 default a CPU fixture
+ // spends minutes per call - and DINO/RT-DETR run dense attention over every pyramid
+ // token, which does not fit at all (#2171). Only the working resolution changes;
+ // architecture and widths stay at their defaults. The OCR family likewise pins the
+ // decoding budget: an untrained autoregressive recognizer (TrOCR) almost never emits
+ // its end token, so every Predict runs to MaxSequenceLength (100 by default) - about
+ // six seconds per call on a CPU fixture. Sixteen steps exercise the same decoder.
+ bool pinInputSize = family == TestFamily.ObjectDetection || family == TestFamily.TextDetection;
+ bool pinDecodeLength = family == TestFamily.OCR;
+ constructorExpr = pinInputSize
+ ? $"new {typeName}(new {model.OptionsOnlyParamTypeName} {{ InputSize = new[] {{ 64, 64 }} }})"
+ : pinDecodeLength
+ ? $"new {typeName}(new {model.OptionsOnlyParamTypeName} {{ MaxSequenceLength = 16 }})"
+ : $"new {typeName}(new {model.OptionsOnlyParamTypeName}())";
+ }
else if (model.HasVectorOnlyConstructor && model.TypeParameterCount == 1)
{
// A coefficient-backed regression model is only meaningful when its coefficient width
@@ -11787,7 +11910,18 @@ private static void EmitGeneratedTestClass(
bool isVisionModel = (model.Domains.Contains(1) || model.Domains.Contains(11))
&& !model.ExtendsForecastingModelBase;
bool isAudioModel = model.Domains.Contains(3); // Audio=3 (was incorrectly 4)
- if (model.ClassName == "StableVideoSR")
+ if (IsTensorModelFamily(family))
+ {
+ // Detection and OCR fixtures declare InputShape only -- see IsTensorModelFamily. The
+ // OCR base already defaults to a wide, short text crop, so only the detection families
+ // need a shape here; both stay a multiple of 32 so the feature-pyramid strides divide
+ // evenly.
+ if (family != TestFamily.OCR)
+ {
+ sb.AppendLine(" protected override int[] InputShape => new[] { 1, 3, 64, 64 };");
+ }
+ }
+ else if (model.ClassName == "StableVideoSR")
{
// Keep this in lockstep with the bounded four-level constructor above. An 8x8 input is
// the minimum geometry that still traverses every spatial and four-frame temporal stage,
@@ -15072,7 +15206,12 @@ model.ClassName is "Bark" or "BarkModel" or "FishSpeech" or "Llasa" or "MegaTTS3
// paper-scale — single-forward tests (DifferentInputs / Clone / Metadata) run at full fidelity.
// Emitted after the InputShape chain so it applies regardless of family branch; the set is
// disjoint from every other iteration override above, so it cannot double-emit.
- if (HeavyTrainingTimeoutClassNames.Contains(model.ClassName))
+ // The detection / OCR bases declare none of the properties this block overrides, and the
+ // set is keyed by SIMPLE class name -- CRAFT, DBNet, EAST, CRNN and TrOCR each name TWO
+ // distinct models (one under ComputerVision, one under Document/OCR), so an entry added for
+ // the Document namesake fires on the ComputerVision one too. Skip the block for these
+ // families rather than widening their bases with knobs they have no invariant for.
+ if (!IsTensorModelFamily(family) && HeavyTrainingTimeoutClassNames.Contains(model.ClassName))
{
// Training_ShouldReduceLoss runs TrainingIterations*3 steps; a deep model's Adam moments
// overshoot for the first few steps (Mask2Former: 5.07 -> 7.76 over 3 steps) then descend,
@@ -15408,6 +15547,36 @@ model.ClassName is "Mamba2LanguageModel" or "Zamba2LanguageModel" or "RemoteCLIP
{
sb.AppendLine(factoryBody);
}
+ if (family == TestFamily.TextDetection && model.HasOptionsOnlyConstructor)
+ {
+ // The shared positive invariant supplies an explicit bounded profile and controls
+ // real head weights. Keep the ordinary fixture/default architecture unchanged.
+ sb.AppendLine();
+ sb.AppendLine(" protected override AiDotNet.ComputerVision.Detection.TextDetection.TextDetectorBase CreatePositiveTextDetector(");
+ sb.AppendLine(" AiDotNet.ComputerVision.Detection.TextDetection.TextDetectionOptions options)");
+ sb.AppendLine($" => new {typeName}(options);");
+ }
+ if (family == TestFamily.ObjectDetection && model.HasOptionsOnlyConstructor)
+ {
+ // Positive object fixtures use actual model heads with an explicit bounded profile;
+ // the existing random/default fixture remains responsible for empty-safe invariants.
+ sb.AppendLine();
+ sb.AppendLine(" protected override AiDotNet.ComputerVision.Detection.ObjectDetection.ObjectDetectorBase CreatePositiveObjectDetector(");
+ sb.AppendLine(" AiDotNet.Models.Options.ObjectDetectionOptions options)");
+ sb.AppendLine($" => new {typeName}(options);");
+ }
+ if (family == TestFamily.ObjectDetection && model.ImplementsDetectionTraining)
+ {
+ // Emit only for the actual typed capability. Unsupported detector families do not
+ // inherit a returning/no-op test that would falsely count semantic training as covered.
+ sb.AppendLine();
+ sb.AppendLine(" [Xunit.Fact(Timeout = 180000)]");
+ sb.AppendLine(" public async System.Threading.Tasks.Task TrainDetections_ShouldUseSemanticTargetsAndUpdateBothHeads()");
+ sb.AppendLine(" {");
+ sb.AppendLine(" await System.Threading.Tasks.Task.Yield();");
+ sb.AppendLine(" VerifySemanticDetectionTraining();");
+ sb.AppendLine(" }");
+ }
if (model.HasVectorOnlyConstructor)
{
string featureWidthConstructor = constructorExpr
@@ -15612,6 +15781,24 @@ private static string DropDuplicateOverrides(string source)
/// Verifies that the model's actual interfaces are compatible with the resolved test family.
/// Prevents generating code that won't compile (e.g., casting to wrong interface).
///
+ ///
+ /// True for the Tensor -> Tensor computer-vision families, whose fixtures derive from
+ /// DetectionModelTestBase rather than NeuralNetworkModelTestBase.
+ ///
+ ///
+ /// Those bases deliberately declare a much smaller surface: no OutputShape (a detector's
+ /// raw head output has no shape contract worth asserting -- the meaningful contract is the
+ /// decoded DetectionResult, which the family base tests directly) and none of the
+ /// many-iteration convergence knobs (MoreDataShortIterations,
+ /// MemorizationTaskLossThreshold and friends). Emitting an override for a member
+ /// the base does not declare is CS0115, so every emission site that assumes the neural-network
+ /// base has to consult this first.
+ ///
+ private static bool IsTensorModelFamily(TestFamily family)
+ => family == TestFamily.ObjectDetection
+ || family == TestFamily.TextDetection
+ || family == TestFamily.OCR;
+
private static bool IsCompatibleWithFamily(ModelTestInfo model, TestFamily family)
{
switch (family)
@@ -15659,6 +15846,14 @@ private static bool IsCompatibleWithFamily(ModelTestInfo model, TestFamily famil
case TestFamily.GaussianProcess:
return model.ImplementsGaussianProcess;
+ // Detection and OCR families are Tensor -> Tensor IFullModel, NOT INeuralNetworkModel:
+ // they derive from ModelBase and hold their layers as discrete fields rather than a
+ // layer collection, so they expose no Layers/GetArchitecture surface to test against.
+ case TestFamily.ObjectDetection:
+ case TestFamily.TextDetection:
+ case TestFamily.OCR:
+ return model.UsesTensorInput;
+
// Matrix/Vector families require IFullModel, Vector>
case TestFamily.Regression:
case TestFamily.NonLinearRegression:
@@ -17848,6 +18043,21 @@ private class ModelTestInfo
///
public bool HasVectorOnlyConstructor { get; set; }
+ ///
+ /// The model's only required constructor argument is its own options object, and that
+ /// object is itself constructible with no arguments (#2137).
+ ///
+ public bool HasOptionsOnlyConstructor { get; set; }
+
+ /// Implements the framework's resolved semantic detection-training interface.
+ public bool ImplementsDetectionTraining { get; set; }
+
+ ///
+ /// The options type to instantiate for , already
+ /// closed over double when generic.
+ ///
+ public string? OptionsOnlyParamTypeName { get; set; }
+
///
/// The fully-qualified display name of the architecture parameter type (e.g.,
/// "AiDotNet.ProgramSynthesis.Models.CodeSynthesisArchitecture<double>").
@@ -17879,6 +18089,15 @@ private class ModelTestInfo
public bool ExtendsDocumentNeuralNetworkBase { get; set; }
public bool ExtendsVisionLanguageModelBase { get; set; }
public bool ExtendsSegmentationModelBase { get; set; }
+
+ /// True when the model derives from ObjectDetectorBase<T>.
+ public bool ExtendsObjectDetectorBase { get; set; }
+
+ /// True when the model derives from TextDetectorBase<T>.
+ public bool ExtendsTextDetectorBase { get; set; }
+
+ /// True when the model derives from OCRBase<T>.
+ public bool ExtendsOCRBase { get; set; }
public bool ExtendsVideoNeuralNetworkBase { get; set; }
public bool ExtendsLatentDiffusionModelBase { get; set; }
public bool ExtendsTtsModelBase { get; set; }
@@ -17977,6 +18196,9 @@ private enum TestFamily
Classification,
ProbabilisticClassifier,
Clustering,
+ ObjectDetection,
+ TextDetection,
+ OCR,
NeuralNetwork
}
@@ -18725,6 +18947,9 @@ private static string GetBaseClassName(TestFamily family)
case TestFamily.DocumentNN: return "DocumentNNModelTestBase";
case TestFamily.VisionLanguage: return "VisionLanguageTestBase";
case TestFamily.Segmentation: return "SegmentationTestBase";
+ case TestFamily.ObjectDetection: return "ObjectDetectionTestBase";
+ case TestFamily.TextDetection: return "TextDetectionTestBase";
+ case TestFamily.OCR: return "OCRTestBase";
case TestFamily.VideoNN: return "VideoNNModelTestBase";
case TestFamily.TTS: return "TTSModelTestBase";
case TestFamily.Financial: return "FinancialModelTestBase";
@@ -18856,6 +19081,10 @@ private static string GetReturnTypeCode(TestFamily family)
case TestFamily.SequenceLabelingNER:
case TestFamily.NeuralNetwork:
return "INeuralNetworkModel";
+ case TestFamily.ObjectDetection:
+ case TestFamily.TextDetection:
+ case TestFamily.OCR:
+ return "IFullModel, Tensor>";
case TestFamily.ReinforcementLearning:
return "IFullModel, Vector>";
case TestFamily.MultiLabelClassifier:
diff --git a/src/AiDotNet.Generators/TrainableParameterGenerator.cs b/src/AiDotNet.Generators/TrainableParameterGenerator.cs
index a12b1c69d2..beec021d18 100644
--- a/src/AiDotNet.Generators/TrainableParameterGenerator.cs
+++ b/src/AiDotNet.Generators/TrainableParameterGenerator.cs
@@ -644,7 +644,7 @@ or ParameterMemberSemanticModel.Kind.Scratch
// Generate the partial class source
var unguardableAxes = new List();
var source = GenerateSource(
- classSymbol, paramFields, gradientFields, subLayerFields, bufferFields,
+ compilation, classSymbol, paramFields, gradientFields, subLayerFields, bufferFields,
useRuntimeParameterRegistry, useConventionalTensorEnumerator,
emitParameterFreeContract,
suppressGeneratedParameterAccessors, unguardableAxes);
@@ -676,6 +676,7 @@ private static bool IsIdentifierOrMemberNamed(ExpressionSyntax expression, strin
};
private static string GenerateSource(
+ Compilation compilation,
INamedTypeSymbol classSymbol,
List paramFields,
Dictionary gradientFields,
@@ -1561,6 +1562,20 @@ void EmitFieldAssign(ParameterFieldInfo pf, string indexExpr, string idxLabel)
sb.AppendLine(" /// Auto-generated: this layer owns child-module structure.");
sb.AppendLine(" [global::System.CodeDom.Compiler.GeneratedCode(\"AiDotNet.Generators.TrainableParameterGenerator\", \"1.0.0\")]");
sb.AppendLine(" protected override bool HasDeclaredSubLayerStructure => true;");
+ var optionalChildren = subLayerFields
+ .Select(child => classSymbol.GetMembers(child.Name).OfType().SingleOrDefault())
+ .ToArray();
+ bool childIndependentInitializer = subLayerFields.All(child => !child.IsCollection && child.InputShape is null)
+ && optionalChildren.All(child => child is not null)
+ && LayerStructureInitializationAnalysis.IsChildIndependent(
+ compilation, classSymbol, optionalChildren.OfType().ToArray());
+ if (childIndependentInitializer)
+ {
+ sb.AppendLine();
+ sb.AppendLine(" /// Only this exact initializer is proven independent of child structure.");
+ sb.AppendLine(" [global::System.CodeDom.Compiler.GeneratedCode(\"AiDotNet.Generators.TrainableParameterGenerator\", \"1.0.0\")]");
+ sb.AppendLine($" protected override bool NeedsDeclaredSubLayerInitialization => GetType() != typeof({classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)});");
+ }
sb.AppendLine();
sb.AppendLine(" private bool _subLayersRegistered;");
sb.AppendLine();
diff --git a/src/AiModelBuilder.Detection.cs b/src/AiModelBuilder.Detection.cs
new file mode 100644
index 0000000000..c236d6d844
--- /dev/null
+++ b/src/AiModelBuilder.Detection.cs
@@ -0,0 +1,37 @@
+using AiDotNet.ComputerVision.Detection;
+
+namespace AiDotNet;
+
+/// Explicit semantic detection training through the fluent model-builder facade.
+public static class DetectionBuilderExtensions
+{
+ /// Runs one detection-task update on the configured capable model.
+ ///
+ /// Unlike raw tensor Train, this API deliberately selects the configured model's assignment and
+ /// classification/box objective. Unsupported model families fail explicitly; there is no MSE fallback.
+ /// Input images must already have the preprocessing expected by the model's Predict method.
+ ///
+ public static IAiModelBuilder, Tensor> TrainDetections(
+ this IAiModelBuilder, Tensor> builder,
+ Tensor input, DetectionTrainingBatch targets)
+ {
+ if (builder is null) throw new ArgumentNullException(nameof(builder));
+ if (input is null) throw new ArgumentNullException(nameof(input));
+ if (targets is null) throw new ArgumentNullException(nameof(targets));
+ if (builder is not AiModelBuilder, Tensor> facade)
+ throw new NotSupportedException("This operation requires the AiModelBuilder facade.");
+ if (facade.ConfiguredModel is null)
+ throw new InvalidOperationException("Configure a detection model before training it.");
+ if (facade.ConfiguredModel is not IDetectionTrainingModel model)
+ throw new NotSupportedException("The configured model does not implement semantic detection training.");
+ model.TrainDetections(input, targets);
+ return builder;
+ }
+
+ /// Explicitly adapts the COCO loader's normalized, zero-padded xywh labels before training.
+ /// This conversion is never selected by guessing the shape passed to raw Train.
+ public static IAiModelBuilder, Tensor> TrainCocoDetections(
+ this IAiModelBuilder, Tensor> builder,
+ Tensor input, Tensor paddedCocoLabels) =>
+ builder.TrainDetections(input, DetectionTrainingBatch.FromPaddedCoco(paddedCocoLabels));
+}
diff --git a/src/ComputerVision/CvParameterModule.cs b/src/ComputerVision/CvParameterModule.cs
new file mode 100644
index 0000000000..831cc57d78
--- /dev/null
+++ b/src/ComputerVision/CvParameterModule.cs
@@ -0,0 +1,252 @@
+using AiDotNet.Models.Parameters;
+
+namespace AiDotNet.ComputerVision;
+
+///
+/// Base for the hand-rolled building blocks of the computer-vision detection and OCR models - an
+/// encoder layer, a cross-attention block, a detection head - that own weights but are neither a
+/// LayerBase nor a ModelBase.
+///
+///
+///
+/// The parameter generator registers a model field only when its type is a parameter source, a layer
+/// or layer collection, or follows the EnumerateLayers() convention. A field typed as a plain
+/// helper class matched none of those, so everything behind it was invisible: missing from
+/// GetParameters(), from Serialize, from the rebuild-and-reload DeepCopy, and from
+/// training. For DETR that was the entire encoder and decoder.
+///
+///
+/// Deriving from this class makes a block a parameter source in its own right, so the generator picks
+/// it up, and the block exposes its weights as LIVE chunks - the very tensor instances its forward pass
+/// reads. That matters for training: the autodiff tape keys gradients by reference, so an optimizer
+/// can only update a weight if it is handed that exact instance.
+///
+///
+/// A derived block declares, in a fixed order, the child components it owns
+/// () and any raw weight tensors it holds directly
+/// (). Every child must itself expose live chunks; a child that can
+/// only produce a copy is rejected at first use, because silently training a copy would leave the real
+/// weight untouched.
+///
+///
+/// The numeric type of the weights.
+internal abstract class CvParameterModule : IParameterSource, IParameterChunkSource, IParameterLayoutSource
+{
+ ///
+ /// The child components this block owns, in a fixed order. Null entries (an optional component
+ /// the configuration did not build) are skipped.
+ ///
+ protected abstract IEnumerable?> ParameterChildren();
+
+ ///
+ /// Raw weight tensors this block holds directly (a learnable query embedding, a norm's scale and
+ /// shift), in a fixed order. They are exposed and restored in place.
+ ///
+ protected virtual IEnumerable> OwnParameterTensors() => Array.Empty>();
+
+ ///
+ ///
+ /// Follows the same own-then-child order and relative IDs as the value/chunk surfaces. Child
+ /// schemas are queried directly: counting or describing the module must not run a forward pass
+ /// or materialize a lazy child merely to infer its shape from values.
+ ///
+ public IReadOnlyList GetParameterLayout()
+ {
+ var slots = new List();
+ int own = 0;
+ foreach (var tensor in OwnParameterTensors())
+ {
+ slots.Add(new ParameterSlotDescriptor(
+ $"w{own}", ParameterSlotRole.Trainable,
+ tensor.Length == 0 ? ParameterReadiness.ParameterFree : ParameterReadiness.Materialized,
+ tensor.Length, shape: tensor.Shape.ToArray(), elementType: typeof(T).FullName));
+ own++;
+ }
+
+ int index = 0;
+ foreach (var child in Children())
+ {
+ IReadOnlyList childSlots = child switch
+ {
+ IParameterLayoutSource layout => layout.GetParameterLayout(),
+ IParameterManifestProvider manifest => manifest.ParameterLayout.Slots,
+ _ => throw new InvalidOperationException(
+ $"{GetType().Name} child #{index} ({child.GetType().Name}) must expose a parameter "
+ + "layout alongside its live chunks so the registry can validate the same state.")
+ };
+ string prefix = ParameterStableId.IndexSegment(index);
+ foreach (var slot in childSlots)
+ {
+ string id = slot.StableId == "$" ? prefix : prefix + "/" + slot.StableId;
+ slots.Add(new ParameterSlotDescriptor(
+ id, slot.Role, slot.Readiness, slot.ParameterCount,
+ shape: slot.Shape, elementType: slot.ElementType,
+ updatePolicy: slot.UpdatePolicy, persistence: slot.Persistence,
+ ownership: slot.Ownership, availability: slot.Availability,
+ materializedParameterCount: slot.MaterializedParameterCount));
+ }
+ index++;
+ }
+ return slots;
+ }
+
+ ///
+ public long ParameterCount
+ {
+ get
+ {
+ long total = 0;
+ foreach (var tensor in OwnParameterTensors())
+ {
+ total += tensor.Length;
+ }
+
+ foreach (var child in Children())
+ {
+ total += child.ParameterCount;
+ }
+
+ return total;
+ }
+ }
+
+ ///
+ public Vector GetParameters()
+ {
+ var result = new Vector(checked((int)ParameterCount));
+ int offset = 0;
+ foreach (var tensor in OwnParameterTensors())
+ {
+ for (int i = 0; i < tensor.Length; i++)
+ {
+ result[offset++] = tensor[i];
+ }
+ }
+
+ foreach (var child in Children())
+ {
+ var values = child.GetParameters();
+ for (int i = 0; i < values.Length; i++)
+ {
+ result[offset++] = values[i];
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ public void SetParameters(Vector parameters)
+ {
+ if (parameters is null)
+ {
+ throw new ArgumentNullException(nameof(parameters));
+ }
+
+ long expected = ParameterCount;
+ if (parameters.Length != expected)
+ {
+ throw new ArgumentException(
+ $"{GetType().Name} expects {expected} parameter values but received {parameters.Length}.",
+ nameof(parameters));
+ }
+
+ int offset = 0;
+ foreach (var tensor in OwnParameterTensors())
+ {
+ // Written through, never replaced: the forward pass keeps reading this instance.
+ for (int i = 0; i < tensor.Length; i++)
+ {
+ tensor[i] = parameters[offset++];
+ }
+ }
+
+ foreach (var child in Children())
+ {
+ int count = checked((int)child.ParameterCount);
+ var slice = new Vector(count);
+ for (int i = 0; i < count; i++)
+ {
+ slice[i] = parameters[offset++];
+ }
+
+ child.SetParameters(slice);
+ }
+ }
+
+ ///
+ public IEnumerable> GetParameterStateChunks()
+ {
+ int own = 0;
+ foreach (var tensor in OwnParameterTensors())
+ {
+ if (tensor.Length > 0)
+ {
+ yield return new ParameterChunk($"w{own}", ParameterSlotRole.Trainable, tensor);
+ }
+
+ own++;
+ }
+
+ int index = 0;
+ foreach (var child in Children())
+ {
+ if (child is not IParameterChunkSource chunked)
+ {
+ throw new InvalidOperationException(
+ $"{GetType().Name} child #{index} ({child.GetType().Name}) exposes parameters only as a "
+ + "copy. It must implement IParameterChunkSource so training updates the live weight.");
+ }
+
+ string prefix = ParameterStableId.IndexSegment(index);
+ foreach (var chunk in chunked.GetParameterStateChunks())
+ {
+ string id = chunk.StableId == "$" ? prefix : prefix + "/" + chunk.StableId;
+ yield return new ParameterChunk(id, chunk.Role, chunk.Tensor, chunk.SourceTensor, chunk.IsWritableInPlace);
+ }
+
+ index++;
+ }
+ }
+
+ private IEnumerable> Children()
+ {
+ foreach (var child in ParameterChildren())
+ {
+ if (child is not null)
+ {
+ yield return child;
+ }
+ }
+ }
+}
+
+///
+/// A whose children and own tensors are supplied by delegates.
+///
+///
+/// For public building blocks (the region proposal network, for one) that cannot derive from the
+/// internal : they hold one of these and forward
+/// and to it.
+///
+/// The numeric type of the weights.
+internal sealed class DelegatingCvParameterModule : CvParameterModule
+{
+ private readonly Func?>> _children;
+ private readonly Func>>? _own;
+
+ /// Creates a module over the given children and, optionally, raw tensors.
+ public DelegatingCvParameterModule(
+ Func?>> children,
+ Func>>? own = null)
+ {
+ _children = children ?? throw new ArgumentNullException(nameof(children));
+ _own = own;
+ }
+
+ ///
+ protected override IEnumerable?> ParameterChildren() => _children();
+
+ ///
+ protected override IEnumerable> OwnParameterTensors() => _own?.Invoke() ?? Array.Empty>();
+}
diff --git a/src/ComputerVision/CvTensorOps.cs b/src/ComputerVision/CvTensorOps.cs
new file mode 100644
index 0000000000..4e96aa8bb6
--- /dev/null
+++ b/src/ComputerVision/CvTensorOps.cs
@@ -0,0 +1,710 @@
+using AiDotNet.Tensors.Engines;
+using AiDotNet.Tensors.Helpers;
+using AiDotNet.Tensors.Interfaces;
+using AiDotNet.Tensors.LinearAlgebra;
+
+namespace AiDotNet.ComputerVision;
+
+///
+/// Tape-visible tensor operations shared by the computer-vision detection and OCR models.
+///
+///
+///
+/// These models were written with hand-rolled scalar loops: each helper read elements out one at a
+/// time and wrote a freshly allocated tensor. Arithmetically fine, but every such loop severs the
+/// autodiff tape, so any trainable layer upstream of it silently received no gradient.
+///
+///
+/// Every operation here is composed from engine primitives that record themselves on the tape, and
+/// each reproduces the EXACT semantics of the loop it replaces - including the non-standard ones.
+/// uses the asymmetric source mapping
+/// src = dst * in / out, not the half-pixel convention of a generic interpolate, and
+/// keeps the partial edge window rather than dropping it. Swapping in a
+/// convenient engine op with a different convention would have shifted every feature map.
+///
+///
+/// Resampling and padding are expressed as index selection with precomputed integer indices. A
+/// selection is an exact copy, so a remap built from it matches the scalar loop bit for bit, and its
+/// backward pass is a scatter-add, which is the correct gradient of a gather.
+///
+///
+/// The numeric type of the tensors.
+internal static class CvTensorOps
+{
+ private static IEngine Engine => AiDotNetEngine.Current;
+
+ private static INumericOperations NumOps => MathHelper.GetNumericOperations();
+
+ ///
+ /// Nearest-neighbour resize of an NCHW tensor using src = min(dst * in / out, in - 1)
+ /// on each spatial axis (integer division). This is the convention the neck and head
+ /// ResizeToMatch helpers used.
+ ///
+ public static Tensor ResizeNearest(Tensor x, int targetH, int targetW)
+ {
+ int srcH = x.Shape[2];
+ int srcW = x.Shape[3];
+ if (srcH == targetH && srcW == targetW)
+ {
+ return x;
+ }
+
+ var rows = new int[targetH];
+ for (int h = 0; h < targetH; h++)
+ {
+ rows[h] = Math.Min((int)((long)h * srcH / targetH), srcH - 1);
+ }
+
+ var cols = new int[targetW];
+ for (int w = 0; w < targetW; w++)
+ {
+ cols[w] = Math.Min((int)((long)w * srcW / targetW), srcW - 1);
+ }
+
+ return Select(Select(x, rows, 2), cols, 3);
+ }
+
+ ///
+ /// Nearest-neighbour 2x upsample (each source pixel becomes a 2x2 block).
+ ///
+ public static Tensor Upsample2xNearest(Tensor x)
+ => ResizeNearest(x, x.Shape[2] * 2, x.Shape[3] * 2);
+
+ ///
+ /// Bilinear resize of an NCHW tensor with the ASYMMETRIC source mapping
+ /// src = dst * in / out (no half-pixel offset), clamping the upper neighbour to the last
+ /// row or column. Separable: interpolate along width, then along height, which evaluates
+ /// wy0*(wx0*v00 + wx1*v01) + wy1*(wx0*v10 + wx1*v11) in the same order as the loop it
+ /// replaces.
+ ///
+ public static Tensor ResizeBilinearAsymmetric(Tensor x, int targetH, int targetW)
+ {
+ int srcH = x.Shape[2];
+ int srcW = x.Shape[3];
+
+ var (x0, x1, wx0, wx1) = BilinearTaps(srcW, targetW);
+ var (y0, y1, wy0, wy1) = BilinearTaps(srcH, targetH);
+
+ // Along width: [N, C, srcH, targetW].
+ var left = Select(x, x0, 3);
+ var right = Select(x, x1, 3);
+ var alongW = Engine.TensorAdd(
+ Engine.TensorMultiply(left, Broadcast(wx0, Dims(left), 3)),
+ Engine.TensorMultiply(right, Broadcast(wx1, Dims(right), 3)));
+
+ // Along height: [N, C, targetH, targetW].
+ var top = Select(alongW, y0, 2);
+ var bottom = Select(alongW, y1, 2);
+ return Engine.TensorAdd(
+ Engine.TensorMultiply(Broadcast(wy0, Dims(top), 2), top),
+ Engine.TensorMultiply(Broadcast(wy1, Dims(bottom), 2), bottom));
+ }
+
+ ///
+ /// 2x2, stride-2 max pooling in CEIL mode: an odd-sized input keeps its partial last window,
+ /// whose maximum is taken over the in-bounds cells only.
+ ///
+ ///
+ /// Implemented by replicating the last row and column when the extent is odd, then pooling in
+ /// floor mode. A replicated cell duplicates a value already inside the same window, so it can
+ /// never change that window's maximum - which is exactly "max over the in-bounds cells".
+ ///
+ public static Tensor MaxPool2x2Ceil(Tensor x)
+ {
+ int h = x.Shape[2];
+ int w = x.Shape[3];
+ var padded = x;
+ if (h % 2 == 1)
+ {
+ padded = Select(padded, ClampedRange(0, h + 1, h), 2);
+ }
+
+ if (w % 2 == 1)
+ {
+ padded = Select(padded, ClampedRange(0, w + 1, w), 3);
+ }
+
+ return Engine.MaxPool2DWithIndices(padded, new[] { 2, 2 }, new[] { 2, 2 }, out _);
+ }
+
+ ///
+ /// Max pooling with window and stride both equal to (kernelH, kernelW), in FLOOR mode:
+ /// trailing rows or columns that do not fill a whole window are dropped.
+ ///
+ public static Tensor MaxPoolFloor(Tensor x, int kernelH, int kernelW)
+ => Engine.MaxPool2DWithIndices(x, new[] { kernelH, kernelW }, new[] { kernelH, kernelW }, out _);
+
+ ///
+ /// Stride-1 "same" max pooling with a square window of odd size :
+ /// the output has the input's spatial size and each window's maximum is taken over the cells that
+ /// fall inside the image (out-of-bounds positions are ignored, not treated as zero).
+ ///
+ ///
+ /// Out-of-bounds positions are filled by clamping their index to the nearest edge. The clamped
+ /// cell always lies inside the same window, so it duplicates an in-bounds candidate and cannot
+ /// change the maximum.
+ ///
+ public static Tensor MaxPoolSame(Tensor x, int kernelSize)
+ => MaxPoolPadded(x, kernelSize, 1, kernelSize / 2);
+
+ ///
+ /// Max pooling with a square window, a stride and symmetric padding, where padded positions are
+ /// ignored rather than treated as zero - PyTorch's nn.MaxPool2d(kernel, stride, padding)
+ /// in floor mode.
+ ///
+ ///
+ /// Out-of-bounds positions are filled by clamping their index to the nearest edge. While
+ /// is smaller than (which PyTorch itself
+ /// requires), every window reaches at least one in-bounds cell on the side it overhangs, and the
+ /// clamped cell IS that edge cell - a duplicate candidate that cannot change the maximum.
+ ///
+ public static Tensor MaxPoolPadded(Tensor x, int kernelSize, int stride, int padding)
+ {
+ if (padding < 0 || padding >= kernelSize)
+ {
+ throw new ArgumentOutOfRangeException(nameof(padding), padding,
+ $"Padding must lie in [0, kernelSize) = [0, {kernelSize}).");
+ }
+
+ int h = x.Shape[2];
+ int w = x.Shape[3];
+ int outH = (h + 2 * padding - kernelSize) / stride + 1;
+ int outW = (w + 2 * padding - kernelSize) / stride + 1;
+
+ // Only the cells some window reads: from -padding to the last window's far edge.
+ int endH = (outH - 1) * stride - padding + kernelSize;
+ int endW = (outW - 1) * stride - padding + kernelSize;
+ var padded = x;
+ if (padding > 0 || endH != h)
+ {
+ padded = Select(padded, ClampedRange(-padding, endH, h), 2);
+ }
+
+ if (padding > 0 || endW != w)
+ {
+ padded = Select(padded, ClampedRange(-padding, endW, w), 3);
+ }
+
+ return Engine.MaxPool2DWithIndices(padded, new[] { kernelSize, kernelSize }, new[] { stride, stride }, out _);
+ }
+
+ ///
+ /// Normalises each channel of an NCHW tensor with the statistics of the CURRENT batch
+ /// (biased variance, no affine parameters): (x - mean_c) / sqrt(var_c + eps).
+ ///
+ public static Tensor BatchStatisticsNorm(Tensor x, double epsilon)
+ {
+ var axes = new[] { 0, 2, 3 };
+ var mean = Engine.ReduceMean(x, axes, true);
+ var centered = Engine.TensorSubtract(x, Engine.TensorBroadcastTo(mean, Dims(x)));
+ var variance = Engine.ReduceMean(Engine.TensorMultiply(centered, centered), axes, true);
+ var std = Engine.TensorSqrt(Engine.TensorAddScalar(variance, NumOps.FromDouble(epsilon)));
+ return Engine.TensorDivide(centered, Engine.TensorBroadcastTo(std, Dims(x)));
+ }
+
+ ///
+ /// Concatenates NCHW tensors along the channel axis.
+ ///
+ public static Tensor ConcatChannels(Tensor first, Tensor second)
+ => Engine.TensorConcatenate(new[] { first, second }, 1);
+
+ ///
+ /// Flattens the spatial axes of an NCHW tensor into a token sequence [N, H*W, C], in
+ /// row-major spatial order.
+ ///
+ public static Tensor FlattenSpatial(Tensor x)
+ {
+ int n = x.Shape[0], c = x.Shape[1], h = x.Shape[2], w = x.Shape[3];
+ return Engine.Reshape(Engine.TensorPermute(x, new[] { 0, 2, 3, 1 }), new[] { n, h * w, c });
+ }
+
+ ///
+ /// Inverse of : [N, H*W, C] back to [N, C, H, W].
+ ///
+ public static Tensor UnflattenSpatial(Tensor tokens, int height, int width)
+ {
+ int n = tokens.Shape[0], c = tokens.Shape[2];
+ return Engine.TensorPermute(Engine.Reshape(tokens, new[] { n, height, width, c }), new[] { 0, 3, 1, 2 });
+ }
+
+ ///
+ /// Layer normalisation over the last axis with learnable scale and shift:
+ /// gamma * (x - mean) / sqrt(var + eps) + beta, biased variance.
+ ///
+ public static Tensor LayerNormLastAxis(Tensor x, Tensor gamma, Tensor beta, double epsilon)
+ => Engine.LayerNorm(x, gamma, beta, epsilon, out _, out _);
+
+ ///
+ /// Multi-head scaled dot-product attention over [N, L, D] sequences. Heads are the
+ /// contiguous D / numHeads slices of the model dimension, softmax runs over the keys,
+ /// and the heads are concatenated back in order. No projections: the caller applies those.
+ ///
+ /// Queries [N, Lq, D].
+ /// Keys [N, Lk, D].
+ /// Values [N, Lk, D].
+ /// Number of heads; must divide D.
+ /// Score scale, normally 1 / sqrt(D / numHeads).
+ /// When true, query i attends only to keys j <= i.
+ /// Optional additive bias on the scaled scores, broadcastable to
+ /// [N, H, Lq, Lk] - for example .
+ /// The attended values [N, Lq, D].
+ public static Tensor MultiHeadAttention(
+ Tensor query, Tensor key, Tensor value, int numHeads, double scale, bool causal = false,
+ Tensor? scoreBias = null)
+ {
+ int n = query.Shape[0], lq = query.Shape[1], d = query.Shape[2], lk = key.Shape[1];
+ int headDim = d / numHeads;
+
+ var q = SplitHeads(query, numHeads, headDim);
+ var k = SplitHeads(key, numHeads, headDim);
+ var v = SplitHeads(value, numHeads, headDim);
+
+ var scores = Engine.TensorMultiplyScalar(
+ Engine.TensorMatMul(q, Engine.TensorPermute(k, new[] { 0, 1, 3, 2 })), NumOps.FromDouble(scale));
+
+ if (scoreBias is not null)
+ {
+ scores = Engine.TensorAdd(scores, Engine.TensorBroadcastTo(scoreBias, Dims(scores)));
+ }
+
+ if (causal)
+ {
+ // Additive mask: a large negative score gives the masked key an attention weight that
+ // underflows to exactly zero after the softmax, matching a loop that skips j > i.
+ var mask = new Tensor(new[] { 1, 1, lq, lk });
+ var blocked = NumOps.FromDouble(-1e30);
+ for (int i = 0; i < lq; i++)
+ {
+ for (int j = i + 1; j < lk; j++)
+ {
+ mask[(i * lk) + j] = blocked;
+ }
+ }
+
+ scores = Engine.TensorAdd(scores, Engine.TensorBroadcastTo(mask, Dims(scores)));
+ }
+
+ var weights = Engine.Softmax(scores, -1);
+ var attended = Engine.TensorMatMul(weights, v); // [N, H, Lq, hd]
+ return Engine.Reshape(Engine.TensorPermute(attended, new[] { 0, 2, 1, 3 }), new[] { n, lq, d });
+ }
+
+ private static Tensor SplitHeads(Tensor x, int numHeads, int headDim)
+ {
+ int n = x.Shape[0], l = x.Shape[1];
+ return Engine.TensorPermute(Engine.Reshape(x, new[] { n, l, numHeads, headDim }), new[] { 0, 2, 1, 3 });
+ }
+
+ ///
+ /// Concatenates every head's raw output into one tensor. When all outputs share a leading
+ /// (per-image) dimension N, each is flattened to [N, -1] and the result is
+ /// [N, total]; when they do not - a two-stage detector's per-RoI heads beside its per-image
+ /// RPN maps - everything is flattened into [1, total]. A single output is returned unchanged.
+ ///
+ public static Tensor ConcatenateOutputs(IReadOnlyList> outputs)
+ {
+ if (outputs.Count == 0)
+ {
+ return new Tensor(new[] { 1, 0 });
+ }
+
+ if (outputs.Count == 1)
+ {
+ return outputs[0];
+ }
+
+ bool sharedLeading = true;
+ for (int i = 1; i < outputs.Count && sharedLeading; i++)
+ {
+ sharedLeading = outputs[i].Shape[0] == outputs[0].Shape[0] && outputs[i].Shape[0] > 0;
+ }
+
+ var flat = new Tensor[outputs.Count];
+ for (int i = 0; i < outputs.Count; i++)
+ {
+ int leading = sharedLeading ? outputs[i].Shape[0] : 1;
+ flat[i] = Engine.Reshape(outputs[i], new[] { leading, outputs[i].Length / leading });
+ }
+
+ return Engine.TensorConcatenate(flat, 1);
+ }
+
+ ///
+ /// Builds a Swin relative-position bias [1, H, L, L] from a learnable table
+ /// [R, H] and an index map index[i, j] into its rows. The lookup is a gather, so the
+ /// table receives gradients.
+ ///
+ public static Tensor RelativePositionBias(Tensor table, int[,] index)
+ {
+ int lq = index.GetLength(0), lk = index.GetLength(1), heads = table.Shape[1];
+ var flat = new int[lq * lk];
+ for (int i = 0; i < lq; i++)
+ {
+ for (int j = 0; j < lk; j++)
+ {
+ flat[(i * lk) + j] = index[i, j];
+ }
+ }
+
+ var gathered = Select(table, flat, 0); // [L*L, H]
+ return Engine.Reshape(Engine.TensorPermute(gathered, new[] { 1, 0 }), new[] { 1, heads, lq, lk });
+ }
+
+ ///
+ /// Rolls a [N, H, W, C] map by along both spatial axes with
+ /// wrap-around: out[i, j] = x[(i - shift) mod H, (j - shift) mod W] (Swin's cyclic shift).
+ ///
+ public static Tensor CyclicShift(Tensor x, int shift)
+ => Engine.TensorRoll(x, new[] { shift, shift }, new[] { 1, 2 });
+
+ ///
+ /// Partitions a [N, H, W, C] map into non-overlapping
+ /// squares, zero-padding the bottom and right edges up to a multiple of the window size.
+ /// Returns [N * nH * nW, windowSize^2, C] with windows in row-major order per image and
+ /// tokens in row-major order per window.
+ ///
+ public static (Tensor Windows, int WindowsH, int WindowsW) WindowPartition(Tensor x, int windowSize)
+ {
+ int n = x.Shape[0], h = x.Shape[1], w = x.Shape[2], c = x.Shape[3];
+ int padH = (windowSize - (h % windowSize)) % windowSize;
+ int padW = (windowSize - (w % windowSize)) % windowSize;
+
+ var padded = ZeroPadBottomRight(x, padH, padW);
+
+ int wh = (h + padH) / windowSize, ww = (w + padW) / windowSize;
+ var blocks = Engine.Reshape(padded, new[] { n, wh, windowSize, ww, windowSize, c });
+ var ordered = Engine.TensorPermute(blocks, new[] { 0, 1, 3, 2, 4, 5 }); // [N, wh, ww, ws, ws, C]
+ return (Engine.Reshape(ordered, new[] { n * wh * ww, windowSize * windowSize, c }), wh, ww);
+ }
+
+ ///
+ /// Inverse of : reassembles windows into a [N, H, W, C] map
+ /// and drops the padding.
+ ///
+ public static Tensor WindowReverse(
+ Tensor windows, int windowsH, int windowsW, int batch, int height, int width, int windowSize)
+ {
+ int c = windows.Shape[2];
+ var blocks = Engine.Reshape(windows, new[] { batch, windowsH, windowsW, windowSize, windowSize, c });
+ var ordered = Engine.TensorPermute(blocks, new[] { 0, 1, 3, 2, 4, 5 }); // [N, wh, ws, ww, ws, C]
+ var full = Engine.Reshape(ordered, new[] { batch, windowsH * windowSize, windowsW * windowSize, c });
+ if (full.Shape[1] == height && full.Shape[2] == width)
+ {
+ return full;
+ }
+
+ return Engine.TensorSlice(full, new[] { 0, 0, 0, 0 }, new[] { batch, height, width, c });
+ }
+
+ ///
+ /// Applies a row-wise layer (a linear map, typically) independently to every position of a
+ /// [..., features] tensor by folding the leading axes into one batch axis and unfolding the
+ /// result. Replaces the copy-one-row, forward, copy-back loops, which were slow and severed the tape.
+ ///
+ public static Tensor Tokenwise(Tensor x, Func, Tensor> rowwise)
+ {
+ int rank = x.Shape.Length;
+ if (rank <= 2)
+ {
+ return rowwise(x);
+ }
+
+ int rows = 1;
+ for (int d = 0; d < rank - 1; d++)
+ {
+ rows *= x.Shape[d];
+ }
+
+ var result = rowwise(Engine.Reshape(x, new[] { rows, x.Shape[rank - 1] }));
+ var outShape = new int[rank];
+ for (int d = 0; d < rank - 1; d++)
+ {
+ outShape[d] = x.Shape[d];
+ }
+
+ outShape[rank - 1] = result.Shape[1];
+ return Engine.Reshape(result, outShape);
+ }
+
+ ///
+ /// Swin patch merging on a [N, H, W, C] map: zero-pads odd sides to even, then
+ /// concatenates each 2x2 quad's tokens along channels in the order (r0,c0), (r0,c1), (r1,c0),
+ /// (r1,c1), giving [N, (H/2)*(W/2), 4C] with quads in row-major order.
+ ///
+ public static Tensor PatchMerge2x2(Tensor x)
+ {
+ int n = x.Shape[0], h = x.Shape[1], w = x.Shape[2], c = x.Shape[3];
+ var padded = ZeroPadBottomRight(x, h & 1, w & 1);
+ int newH = (h + (h & 1)) / 2, newW = (w + (w & 1)) / 2;
+ var quads = Engine.Reshape(padded, new[] { n, newH, 2, newW, 2, c });
+ var ordered = Engine.TensorPermute(quads, new[] { 0, 1, 3, 2, 4, 5 }); // [N, newH, newW, 2, 2, C]
+ return Engine.Reshape(ordered, new[] { n, newH * newW, 4 * c });
+ }
+
+ ///
+ /// Zero-pads a [N, H, W, C] map with rows at the bottom and
+ /// columns at the right, by concatenating constant zero blocks (which the
+ /// tape treats as constants, so the gradient passes straight through to the original cells).
+ ///
+ public static Tensor ZeroPadBottomRight(Tensor x, int padH, int padW)
+ {
+ int n = x.Shape[0], h = x.Shape[1], w = x.Shape[2], c = x.Shape[3];
+ var padded = x;
+ if (padH > 0)
+ {
+ padded = Engine.TensorConcatenate(new[] { padded, new Tensor(new[] { n, padH, w, c }) }, 1);
+ }
+
+ if (padW > 0)
+ {
+ padded = Engine.TensorConcatenate(new[] { padded, new Tensor(new[] { n, h + padH, padW, c }) }, 2);
+ }
+
+ return padded;
+ }
+
+ ///
+ /// RoIAlign: pools each region of interest into an outputSize x outputSize grid by
+ /// averaging samplingRatio^2 bilinear samples per bin, over an NCHW feature map.
+ ///
+ ///
+ ///
+ /// The boxes are treated as constants - as in standard RoIAlign, the gradient flows into the
+ /// FEATURES, not the box coordinates - so the whole operation is a fixed sparse linear map of the
+ /// feature map. It is built as one gather of the four bilinear corners of every sample, a
+ /// multiply by the precomputed corner weights (each already divided by the bin's in-bounds sample
+ /// count), and a sum. Samples that fall outside the map contribute nothing and are not counted; a
+ /// bin with no in-bounds samples is zero.
+ ///
+ ///
+ /// Feature map [N, C, H, W].
+ /// Per-RoI (x1, y1, x2, y2) in image coordinates, length 4 * R.
+ /// Per-RoI image index into , length R.
+ /// Image-to-feature-map scale.
+ /// Pooled grid side.
+ /// Samples per bin side.
+ /// Pooled features [R, C, outputSize, outputSize].
+ public static Tensor RoIAlign(
+ Tensor features, double[] boxes, int[] batchIndices, double spatialScale, int outputSize, int samplingRatio)
+ {
+ int n = features.Shape[0], c = features.Shape[1], h = features.Shape[2], w = features.Shape[3];
+ int rois = batchIndices.Length;
+ int side = outputSize * samplingRatio;
+
+ if (rois == 0)
+ {
+ return new Tensor(new[] { 0, c, outputSize, outputSize });
+ }
+
+ // Bilinear sampling through the engine's GridSample (align_corners = false, zero padding;
+ // NCHW in and out - the IEngine summary says NHWC, but the engine reads [N, C, H, W]): one grid point per sample, so the op stores [rois * side * side, C] values and
+ // its backward is the engine's native GridSample gradient. The earlier formulation gathered
+ // all four bilinear taps of every sample as separate rows and broadcast a weight per row -
+ // three [rois * bins * 4 * samples, C] tensors, several GB per call at a thousand proposals.
+ //
+ // Exactness against the per-sample loop: a sample with y in [h - 1, h) read its upper tap
+ // clamped to row h - 1, i.e. the edge value; clamping the coordinate to h - 1 reproduces that
+ // under zero padding. A sample outside [0, h) x [0, w) contributes nothing and is excluded
+ // from its bin's average; it is pointed at a valid pixel and given weight zero.
+ var gridValues = new T[rois * side * side * 2];
+ var maskValues = new T[rois * side * side];
+ var zero = NumOps.Zero;
+ for (int r = 0; r < rois; r++)
+ {
+ double x1 = boxes[(4 * r) + 0] * spatialScale, y1 = boxes[(4 * r) + 1] * spatialScale;
+ double x2 = boxes[(4 * r) + 2] * spatialScale, y2 = boxes[(4 * r) + 3] * spatialScale;
+ double binW = (x2 - x1) / outputSize, binH = (y2 - y1) / outputSize;
+
+ for (int ph = 0; ph < outputSize; ph++)
+ {
+ for (int pw = 0; pw < outputSize; pw++)
+ {
+ double startY = y1 + (ph * binH), startX = x1 + (pw * binW);
+ int count = 0;
+ for (int iy = 0; iy < samplingRatio; iy++)
+ {
+ for (int ix = 0; ix < samplingRatio; ix++)
+ {
+ double y = startY + ((iy + 0.5) * binH / samplingRatio);
+ double x = startX + ((ix + 0.5) * binW / samplingRatio);
+ if (y >= 0 && y < h && x >= 0 && x < w)
+ {
+ count++;
+ }
+ }
+ }
+
+ for (int iy = 0; iy < samplingRatio; iy++)
+ {
+ for (int ix = 0; ix < samplingRatio; ix++)
+ {
+ double y = startY + ((iy + 0.5) * binH / samplingRatio);
+ double x = startX + ((ix + 0.5) * binW / samplingRatio);
+ // Bin-major order: each bin's samplingRatio^2 points are contiguous, so the pooled bin
+ // is one [1, s*s] x [s*s, C] product below rather than a mask broadcast to every channel.
+ int point = ((((((r * outputSize) + ph) * outputSize) + pw) * samplingRatio) + iy) * samplingRatio + ix;
+ bool valid = y >= 0 && y < h && x >= 0 && x < w;
+ double sy = valid ? Math.Min(y, h - 1) : 0;
+ double sx = valid ? Math.Min(x, w - 1) : 0;
+
+ // Pixel coordinate p to normalised g under align_corners = false.
+ gridValues[(2 * point) + 0] = NumOps.FromDouble(((2 * sx) + 1) / w - 1);
+ gridValues[(2 * point) + 1] = NumOps.FromDouble(((2 * sy) + 1) / h - 1);
+ maskValues[point] = valid ? NumOps.FromDouble(1.0 / count) : zero;
+ }
+ }
+ }
+ }
+ }
+
+ var grid = new Tensor(new[] { rois * side, side, 2 }, new Vector(gridValues));
+ var sampled = SampleByBatch(features, grid, batchIndices, side); // [rois * side, side, C]
+ // The bin average as a batched product. Broadcasting the weights to every channel and multiplying
+ // materialised two more tensors the size of the samples, and the tape held all three, per stage: about
+ // 400 MB each in double at a thousand proposals, which is what killed CascadeRCNN's CI runner.
+ int bins = rois * outputSize * outputSize, perBin = samplingRatio * samplingRatio;
+ var weights = new Tensor(new[] { bins, 1, perBin }, new Vector(maskValues));
+ var pooled = Engine.BatchMatMul(weights, Engine.Reshape(sampled, new[] { bins, perBin, c })); // [bins, 1, C]
+ return Engine.TensorPermute(Engine.Reshape(pooled, new[] { rois, outputSize, outputSize, c }), new[] { 0, 3, 1, 2 });
+ }
+
+ ///
+ /// Bilinearly samples one image [1, C, H, W] at a grid [1, rows, cols, 2], returning
+ /// [rows, cols, C].
+ ///
+ private static Tensor SampleImage(Tensor image, Tensor grid, int rows, int cols, int channels)
+ {
+ var sampled = Engine.GridSample(image, grid); // [1, C, rows, cols]
+ return Engine.TensorPermute(Engine.Reshape(sampled, new[] { channels, rows, cols }), new[] { 1, 2, 0 });
+ }
+
+ ///
+ /// Samples each RoI's grid rows (side rows per RoI) from the image its batch index names.
+ ///
+ private static Tensor SampleByBatch(Tensor features, Tensor grid, int[] batchIndices, int side)
+ {
+ int n = features.Shape[0], c = features.Shape[1];
+ int rois = batchIndices.Length;
+ if (n == 1)
+ {
+ return SampleImage(features, Engine.Reshape(grid, new[] { 1, rois * side, side, 2 }), rois * side, side, c);
+ }
+
+ var parts = new List>();
+ var order = new List();
+ for (int b = 0; b < n; b++)
+ {
+ var members = new List();
+ for (int r = 0; r < rois; r++)
+ {
+ if (batchIndices[r] == b)
+ {
+ members.Add(r);
+ }
+ }
+
+ if (members.Count == 0)
+ {
+ continue;
+ }
+
+ var rows = new int[members.Count * side];
+ for (int m = 0; m < members.Count; m++)
+ {
+ for (int k = 0; k < side; k++)
+ {
+ rows[(m * side) + k] = (members[m] * side) + k;
+ }
+ }
+
+ var image = Engine.TensorNarrow(features, 0, b, 1);
+ var imageGrid = Engine.Reshape(Select(grid, rows, 0), new[] { 1, rows.Length, side, 2 });
+ parts.Add(SampleImage(image, imageGrid, rows.Length, side, c));
+ order.AddRange(members);
+ }
+
+ var stacked = parts.Count == 1 ? parts[0] : Engine.TensorConcatenate(parts.ToArray(), 0);
+
+ // stacked holds each RoI's side rows in `order`; put them back in RoI order.
+ var positionOf = new int[rois];
+ for (int k = 0; k < order.Count; k++)
+ {
+ positionOf[order[k]] = k;
+ }
+
+ var back = new int[rois * side];
+ for (int r = 0; r < rois; r++)
+ {
+ for (int k = 0; k < side; k++)
+ {
+ back[(r * side) + k] = (positionOf[r] * side) + k;
+ }
+ }
+
+ return Select(stacked, back, 0);
+ }
+
+ ///
+ /// Gathers slices of along at the given indices.
+ ///
+ public static Tensor Select(Tensor x, int[] indices, int axis)
+ => Engine.TensorGather(x, new Tensor(new[] { indices.Length }, new Vector(indices)), axis);
+
+ /// Indices start .. end-1 clamped into [0, extent-1].
+ private static int[] ClampedRange(int start, int end, int extent)
+ {
+ var result = new int[end - start];
+ for (int i = 0; i < result.Length; i++)
+ {
+ result[i] = Math.Min(Math.Max(start + i, 0), extent - 1);
+ }
+
+ return result;
+ }
+
+ private static (int[] Lo, int[] Hi, T[] WeightLo, T[] WeightHi) BilinearTaps(int src, int dst)
+ {
+ var lo = new int[dst];
+ var hi = new int[dst];
+ var wLo = new T[dst];
+ var wHi = new T[dst];
+ for (int i = 0; i < dst; i++)
+ {
+ double s = (double)i / dst * src;
+ int i0 = (int)Math.Floor(s);
+ lo[i] = i0;
+ hi[i] = Math.Min(i0 + 1, src - 1);
+ double frac = s - i0;
+ wHi[i] = NumOps.FromDouble(frac);
+ wLo[i] = NumOps.FromDouble(1.0 - frac);
+ }
+
+ return (lo, hi, wLo, wHi);
+ }
+
+ ///
+ /// Expands a per-position weight vector along to the full target shape.
+ ///
+ private static Tensor Broadcast(T[] weights, int[] dims, int axis)
+ {
+ var viewShape = new int[dims.Length];
+ for (int d = 0; d < dims.Length; d++)
+ {
+ viewShape[d] = d == axis ? weights.Length : 1;
+ }
+
+ var view = new Tensor(viewShape, new Vector(weights));
+ return Engine.TensorBroadcastTo(view, dims);
+ }
+
+ private static int[] Dims(Tensor t)
+ {
+ var dims = new int[t.Shape.Length];
+ for (int i = 0; i < dims.Length; i++)
+ {
+ dims[i] = t.Shape[i];
+ }
+
+ return dims;
+ }
+}
diff --git a/src/ComputerVision/Detection/Backbones/BackboneLayerShims.cs b/src/ComputerVision/Detection/Backbones/BackboneLayerShims.cs
index 151376a6bc..ef7016a577 100644
--- a/src/ComputerVision/Detection/Backbones/BackboneLayerShims.cs
+++ b/src/ComputerVision/Detection/Backbones/BackboneLayerShims.cs
@@ -1,4 +1,5 @@
using System.IO;
+using AiDotNet.Models.Parameters;
using AiDotNet.NeuralNetworks.Layers;
using AiDotNet.Tensors;
using AiDotNet.Tensors.Helpers;
@@ -15,7 +16,7 @@ namespace AiDotNet.ComputerVision.Detection.Backbones;
/// written against the pre-lazy parallel-Conv2D contract. Post-#1209 it is a 30-line
/// adapter, not a parallel implementation.
///
-internal class Conv2D
+internal class Conv2D : IParameterSource, IParameterChunkSource, IParameterLayoutSource
{
private readonly ConvolutionalLayer _layer;
private readonly int _inChannels;
@@ -79,6 +80,55 @@ public Tensor Forward(Tensor input)
public long GetParameterCount() => _layer.ParameterCount;
+ ///
+ /// Describes the same underlying state as the live chunks without initializing lazy weights.
+ public IReadOnlyList GetParameterLayout() => _layer.GetParameterLayout();
+
+ // The shim implements IParameterSource by delegating to the layer it wraps. Without this
+ // the wrapped weights were invisible to ModelBase's parameter registry: a detection or OCR
+ // model built from these shims reported only its backbone and neck from GetParameters(), so
+ // every head weight was missing from the flat parameter vector -- and therefore from
+ // Serialize/Deserialize, and therefore from the rebuild-and-reload DeepCopy, which handed
+ // back a copy whose head had been re-initialised from scratch.
+ //
+ // The wrapped layer is lazy: it resolves its input depth on first Forward(). Before that
+ // resolution it honestly reports zero parameters rather than throwing, so registration at
+ // construction is safe and the count fills in once shapes are known.
+ ///
+ public long ParameterCount => _layer.IsShapeResolved ? _layer.ParameterCount : 0L;
+
+ ///
+ public Vector GetParameters() =>
+ _layer.IsShapeResolved ? _layer.GetParameters() : new Vector(0);
+
+ ///
+ public void SetParameters(Vector parameters)
+ {
+ if (parameters.Length == 0)
+ {
+ return;
+ }
+
+ _layer.SetParameters(parameters);
+ }
+
+ ///
+ ///
+ /// Forwards the wrapped layer's own chunks, which are the live tensors its forward pass reads, so
+ /// a trainer handed these chunks updates the real weights. Before the lazy layer has resolved its
+ /// shape it owns nothing yet and yields nothing.
+ ///
+ public IEnumerable> GetParameterStateChunks()
+ {
+ if (!_layer.IsShapeResolved)
+ {
+ return Array.Empty>();
+ }
+
+ return ((AiDotNet.Models.Parameters.IParameterChunkSource)_layer).GetParameterStateChunks();
+ }
+
+
public void WriteParameters(BinaryWriter writer) =>
BackboneSerialization.WriteLayerParameters(writer, _layer);
@@ -118,7 +168,7 @@ public Tensor Bias
}
/// Thin adapter around for legacy detection-head call sites.
-internal class Dense
+internal class Dense : IParameterSource, IParameterChunkSource, IParameterLayoutSource
{
private readonly DenseLayer _layer;
private readonly int _inDim;
@@ -137,6 +187,38 @@ public Dense(int inDim, int outDim)
_layer = new DenseLayer(outDim, (Interfaces.IActivationFunction?)null);
}
+ ///
+ /// Applies this linear layer independently to every position of a sequence: input
+ /// [..., inDim], output [..., outDim].
+ ///
+ ///
+ /// The detection and OCR blocks used to do this one position at a time - copy a row into a fresh
+ /// [1, inDim] tensor, run Forward, copy the result back - which is both slow and invisible to
+ /// the autodiff tape. Folding the leading axes into one batch dimension gives the same per-row result
+ /// (a linear map treats rows independently) through engine reshapes the tape records.
+ ///
+ public Tensor ForwardTokens(Tensor input)
+ {
+ int rank = input.Shape.Length;
+ if (rank <= 2)
+ {
+ return Forward(input);
+ }
+
+ var engine = AiDotNet.Tensors.Engines.AiDotNetEngine.Current;
+ int rows = 1;
+ var outShape = new int[rank];
+ for (int d = 0; d < rank - 1; d++)
+ {
+ rows *= input.Shape[d];
+ outShape[d] = input.Shape[d];
+ }
+
+ outShape[rank - 1] = _outDim;
+ var flat = engine.Reshape(input, new[] { rows, input.Shape[rank - 1] });
+ return engine.Reshape(Forward(flat), outShape);
+ }
+
public Tensor Forward(Tensor input)
{
// Validate runtime input feature size against the shim's
@@ -163,6 +245,54 @@ public Tensor Forward(Tensor input)
public long GetParameterCount() => _layer.ParameterCount;
+ ///
+ public IReadOnlyList GetParameterLayout() => _layer.GetParameterLayout();
+
+ // The shim implements IParameterSource by delegating to the layer it wraps. Without this
+ // the wrapped weights were invisible to ModelBase's parameter registry: a detection or OCR
+ // model built from these shims reported only its backbone and neck from GetParameters(), so
+ // every head weight was missing from the flat parameter vector -- and therefore from
+ // Serialize/Deserialize, and therefore from the rebuild-and-reload DeepCopy, which handed
+ // back a copy whose head had been re-initialised from scratch.
+ //
+ // The wrapped layer is lazy: it resolves its input depth on first Forward(). Before that
+ // resolution it honestly reports zero parameters rather than throwing, so registration at
+ // construction is safe and the count fills in once shapes are known.
+ ///
+ public long ParameterCount => _layer.IsShapeResolved ? _layer.ParameterCount : 0L;
+
+ ///
+ public Vector GetParameters() =>
+ _layer.IsShapeResolved ? _layer.GetParameters() : new Vector(0);
+
+ ///
+ public void SetParameters(Vector parameters)
+ {
+ if (parameters.Length == 0)
+ {
+ return;
+ }
+
+ _layer.SetParameters(parameters);
+ }
+
+ ///
+ ///
+ /// Forwards the wrapped layer's own chunks, which are the live tensors its forward pass reads, so
+ /// a trainer handed these chunks updates the real weights. Before the lazy layer has resolved its
+ /// shape it owns nothing yet and yields nothing.
+ ///
+ public IEnumerable> GetParameterStateChunks()
+ {
+ if (!_layer.IsShapeResolved)
+ {
+ return Array.Empty>();
+ }
+
+ return ((AiDotNet.Models.Parameters.IParameterChunkSource)_layer).GetParameterStateChunks();
+ }
+
+
public void WriteParameters(BinaryWriter writer) =>
BackboneSerialization.WriteLayerParameters(writer, _layer);
@@ -206,7 +336,7 @@ public Tensor Bias
}
/// Thin adapter around .
-internal class MultiHeadSelfAttention
+internal class MultiHeadSelfAttention : IParameterSource, IParameterChunkSource, IParameterLayoutSource
{
private readonly MultiHeadAttentionLayer _layer;
private readonly int _dim;
@@ -231,9 +361,217 @@ public MultiHeadSelfAttention(int dim, int numHeads)
public long GetParameterCount() => _layer.ParameterCount;
+ ///
+ public IReadOnlyList GetParameterLayout() => _layer.GetParameterLayout();
+
+ // The shim implements IParameterSource by delegating to the layer it wraps. Without this
+ // the wrapped weights were invisible to ModelBase's parameter registry: a detection or OCR
+ // model built from these shims reported only its backbone and neck from GetParameters(), so
+ // every head weight was missing from the flat parameter vector -- and therefore from
+ // Serialize/Deserialize, and therefore from the rebuild-and-reload DeepCopy, which handed
+ // back a copy whose head had been re-initialised from scratch.
+ //
+ // The wrapped layer is lazy: it resolves its input depth on first Forward(). Before that
+ // resolution it honestly reports zero parameters rather than throwing, so registration at
+ // construction is safe and the count fills in once shapes are known.
+ ///
+ public long ParameterCount => _layer.IsShapeResolved ? _layer.ParameterCount : 0L;
+
+ ///
+ public Vector GetParameters() =>
+ _layer.IsShapeResolved ? _layer.GetParameters() : new Vector(0);
+
+ ///
+ public void SetParameters(Vector parameters)
+ {
+ if (parameters.Length == 0)
+ {
+ return;
+ }
+
+ _layer.SetParameters(parameters);
+ }
+
+ ///
+ ///
+ /// Forwards the wrapped layer's own chunks, which are the live tensors its forward pass reads, so
+ /// a trainer handed these chunks updates the real weights. Before the lazy layer has resolved its
+ /// shape it owns nothing yet and yields nothing.
+ ///
+ public IEnumerable> GetParameterStateChunks()
+ {
+ if (!_layer.IsShapeResolved)
+ {
+ return Array.Empty>();
+ }
+
+ return ((AiDotNet.Models.Parameters.IParameterChunkSource)_layer).GetParameterStateChunks();
+ }
+
+
public void WriteParameters(BinaryWriter writer) =>
BackboneSerialization.WriteLayerParameters(writer, _layer);
public void ReadParameters(BinaryReader reader) =>
BackboneSerialization.ReadLayerParameters(reader, _layer);
}
+
+///
+/// Adapter around for detection heads: 2-D batch
+/// normalisation over the channel axis of an NCHW tensor, with learnable scale and shift and running
+/// statistics for inference.
+///
+///
+/// Batch statistics are used while the owning model is in training mode and the running statistics
+/// otherwise, so the owner must forward . The running statistics are not
+/// trainable, but they are part of the model and are saved and restored with it.
+///
+internal class BatchNorm2D : IParameterSource, IParameterChunkSource, IParameterLayoutSource
+{
+ private readonly BatchNormalizationLayer _layer;
+ private readonly int _channels;
+
+ public BatchNorm2D(int channels)
+ {
+ if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels));
+ _channels = channels;
+ _layer = new BatchNormalizationLayer(channels);
+ }
+
+ public Tensor Forward(Tensor input)
+ {
+ if (input.Shape.Length != 4 || input.Shape[1] != _channels)
+ {
+ throw new ArgumentException(
+ $"BatchNorm2D expects NCHW input with {_channels} channels; got [{string.Join(",", input.Shape)}].",
+ nameof(input));
+ }
+
+ return _layer.Forward(input);
+ }
+
+ public void SetTrainingMode(bool training) => _layer.SetTrainingMode(training);
+
+ public long GetParameterCount() => _layer.ParameterCount;
+
+ ///
+ public IReadOnlyList GetParameterLayout() => _layer.GetParameterLayout();
+
+ ///
+ public long ParameterCount => _layer.ParameterCount;
+
+ ///
+ public Vector GetParameters() => _layer.GetParameters();
+
+ ///
+ public void SetParameters(Vector parameters)
+ {
+ if (parameters.Length == 0)
+ {
+ return;
+ }
+
+ _layer.SetParameters(parameters);
+ }
+
+ ///
+ public IEnumerable> GetParameterStateChunks()
+ => ((AiDotNet.Models.Parameters.IParameterChunkSource)_layer).GetParameterStateChunks();
+
+ public void WriteParameters(BinaryWriter writer)
+ {
+ BackboneSerialization.WriteLayerParameters(writer, _layer);
+ var ops = MathHelper.GetNumericOperations();
+ foreach (var statistic in new[] { _layer.GetRunningMean(), _layer.GetRunningVariance() })
+ {
+ writer.Write(statistic.Length);
+ for (int i = 0; i < statistic.Length; i++)
+ {
+ writer.Write(ops.ToDouble(statistic[i]));
+ }
+ }
+ }
+
+ public void ReadParameters(BinaryReader reader)
+ {
+ BackboneSerialization.ReadLayerParameters(reader, _layer);
+ var ops = MathHelper.GetNumericOperations();
+ foreach (var statistic in new[] { _layer.GetRunningMean(), _layer.GetRunningVariance() })
+ {
+ int length = reader.ReadInt32();
+ if (length != statistic.Length)
+ {
+ throw new InvalidDataException(
+ $"BatchNorm2D running statistic has {length} values on the wire; the layer has {statistic.Length}.");
+ }
+
+ for (int i = 0; i < length; i++)
+ {
+ statistic[i] = ops.FromDouble(reader.ReadDouble());
+ }
+ }
+ }
+}
+
+///
+/// Adapter around for detection heads: a transposed 2-D
+/// convolution with no activation (the layer's own default is ReLU, so identity is passed explicitly).
+///
+internal class ConvTranspose2D : IParameterSource, IParameterChunkSource, IParameterLayoutSource
+{
+ private readonly DeconvolutionalLayer _layer;
+ private readonly int _inChannels;
+
+ public ConvTranspose2D(int inChannels, int outChannels, int kernelSize, int stride)
+ {
+ if (inChannels <= 0) throw new ArgumentOutOfRangeException(nameof(inChannels));
+ if (outChannels <= 0) throw new ArgumentOutOfRangeException(nameof(outChannels));
+ _inChannels = inChannels;
+ _layer = new DeconvolutionalLayer(outChannels, kernelSize, stride, padding: 0,
+ activationFunction: new AiDotNet.ActivationFunctions.IdentityActivation());
+ }
+
+ public Tensor Forward(Tensor input)
+ {
+ if (input.Shape.Length != 4 || input.Shape[1] != _inChannels)
+ {
+ throw new ArgumentException(
+ $"ConvTranspose2D expects NCHW input with {_inChannels} channels; got [{string.Join(",", input.Shape)}].",
+ nameof(input));
+ }
+
+ return _layer.Forward(input);
+ }
+
+ public long GetParameterCount() => _layer.IsShapeResolved ? _layer.ParameterCount : 0L;
+
+ ///
+ public IReadOnlyList GetParameterLayout() => _layer.GetParameterLayout();
+
+ ///
+ public long ParameterCount => GetParameterCount();
+
+ ///
+ public Vector GetParameters() => _layer.IsShapeResolved ? _layer.GetParameters() : new Vector(0);
+
+ ///
+ public void SetParameters(Vector parameters)
+ {
+ if (parameters.Length == 0)
+ {
+ return;
+ }
+
+ _layer.SetParameters(parameters);
+ }
+
+ ///
+ public IEnumerable> GetParameterStateChunks()
+ => _layer.IsShapeResolved
+ ? ((AiDotNet.Models.Parameters.IParameterChunkSource)_layer).GetParameterStateChunks()
+ : Array.Empty>();
+
+ public void WriteParameters(BinaryWriter writer) => BackboneSerialization.WriteLayerParameters(writer, _layer);
+
+ public void ReadParameters(BinaryReader reader) => BackboneSerialization.ReadLayerParameters(reader, _layer);
+}
diff --git a/src/ComputerVision/Detection/Backbones/BackboneOps.cs b/src/ComputerVision/Detection/Backbones/BackboneOps.cs
index 248f28651d..d16eef82ea 100644
--- a/src/ComputerVision/Detection/Backbones/BackboneOps.cs
+++ b/src/ComputerVision/Detection/Backbones/BackboneOps.cs
@@ -4,47 +4,12 @@
namespace AiDotNet.ComputerVision.Detection.Backbones;
///
-/// Shared CPU-side tensor primitives reused by every detection backbone
-/// (ResNet stem ReLU + MaxPool, EfficientNet swish, etc.). Replaces the
-/// duplicated nested loops that lived in each backbone before
-/// BackboneBase was deleted.
+/// Shared tensor primitives reused by the detection backbones. Every op here must go through the
+/// engine so the gradient tape records it; the ResNet stem's max pool, which used to live here as
+/// an element loop, is now .
///
internal static class BackboneOps
{
- private static readonly INumericOperations Ops = MathHelper.GetNumericOperations();
-
- public static Tensor MaxPool2D(Tensor x, int kernelSize, int stride, int padding)
- {
- int batch = x.Shape[0];
- int channels = x.Shape[1];
- int height = x.Shape[2];
- int width = x.Shape[3];
- int outH = (height + 2 * padding - kernelSize) / stride + 1;
- int outW = (width + 2 * padding - kernelSize) / stride + 1;
- var output = new Tensor(new[] { batch, channels, outH, outW });
-
- for (int n = 0; n < batch; n++)
- for (int c = 0; c < channels; c++)
- for (int oh = 0; oh < outH; oh++)
- for (int ow = 0; ow < outW; ow++)
- {
- double maxVal = double.NegativeInfinity;
- for (int kh = 0; kh < kernelSize; kh++)
- for (int kw = 0; kw < kernelSize; kw++)
- {
- int ih = oh * stride - padding + kh;
- int iw = ow * stride - padding + kw;
- if (ih >= 0 && ih < height && iw >= 0 && iw < width)
- {
- double v = Ops.ToDouble(x[n, c, ih, iw]);
- if (v > maxVal) maxVal = v;
- }
- }
- output[n, c, oh, ow] = Ops.FromDouble(maxVal == double.NegativeInfinity ? 0 : maxVal);
- }
- return output;
- }
-
///
/// Element-wise residual addition (a + b in-place into a fresh tensor of a's shape).
/// Validates BOTH length and rank-by-rank shape so a same-element-count but
@@ -63,10 +28,9 @@ public static Tensor AddResidual(Tensor a, Tensor b)
$"BackboneOps.AddResidual shape mismatch at axis {axis}: " +
$"[{string.Join(",", a._shape)}] vs [{string.Join(",", b._shape)}].");
}
- var result = new Tensor(a._shape);
- for (int i = 0; i < a.Length; i++)
- result[i] = Ops.Add(a[i], b[i]);
- return result;
+ // Engine add, not an element loop: a residual skip that drops to scalars severs the gradient
+ // for every layer before it, which in a deep backbone is nearly all of them.
+ return AiDotNetEngine.Current.TensorAdd(a, b);
}
// ApplyReLU / ApplySiLU / ApplySwish removed — backbones (ResNet, CSPDarknet,
diff --git a/src/ComputerVision/Detection/Backbones/EfficientNet.cs b/src/ComputerVision/Detection/Backbones/EfficientNet.cs
index fac29a1646..24b9c27304 100644
--- a/src/ComputerVision/Detection/Backbones/EfficientNet.cs
+++ b/src/ComputerVision/Detection/Backbones/EfficientNet.cs
@@ -1,3 +1,4 @@
+using AiDotNet.Tensors.Engines;
using System.IO;
using AiDotNet.ActivationFunctions;
using AiDotNet.Attributes;
@@ -410,19 +411,12 @@ public Tensor Forward(Tensor input)
int height = input.Shape[2];
int width = input.Shape[3];
- // Global average pool → [batch, channels]
- var squeezed = new Tensor(new[] { batch, channels });
- for (int n = 0; n < batch; n++)
- {
- for (int c = 0; c < channels; c++)
- {
- double sum = 0;
- for (int h = 0; h < height; h++)
- for (int w = 0; w < width; w++)
- sum += _numOps.ToDouble(input[n, c, h, w]);
- squeezed[n, c] = _numOps.FromDouble(sum / (height * width));
- }
- }
+ var engine = AiDotNetEngine.Current;
+
+ // Global average pool -> [batch, channels]. Engine ops throughout: the pooled loop and the
+ // per-channel rescale loop below each severed the tape, so neither the SE block nor anything
+ // upstream of it in the MBConv block trained.
+ var squeezed = engine.ReduceMean(input, new[] { 2, 3 }, false);
var excited = _fc1.Forward(squeezed);
excited = _activation.Activate(excited);
@@ -432,19 +426,11 @@ public Tensor Forward(Tensor input)
// be in [0,1] to act as a multiplicative attention mask.
excited = ApplySigmoid(excited);
- var output = new Tensor(input._shape);
- for (int n = 0; n < batch; n++)
- {
- for (int c = 0; c < channels; c++)
- {
- T scale = excited[n, c];
- for (int h = 0; h < height; h++)
- for (int w = 0; w < width; w++)
- output[n, c, h, w] = _numOps.Multiply(input[n, c, h, w], scale);
- }
- }
-
- return output;
+ // Per-channel gate: broadcast [batch, channels] over the spatial axes.
+ var gate = engine.TensorBroadcastTo(
+ engine.Reshape(excited, new[] { batch, channels, 1, 1 }),
+ new[] { batch, channels, height, width });
+ return engine.TensorMultiply(input, gate);
}
public long GetParameterCount() => _fc1.ParameterCount + _fc2.ParameterCount;
@@ -463,14 +449,14 @@ public void ReadParameters(BinaryReader reader)
// ApplySwish moved to BackboneOps.ApplySwish — was duplicated 3 times in this file.
- private Tensor ApplySigmoid(Tensor x)
- {
- var result = new Tensor(x._shape);
- for (int i = 0; i < x.Length; i++)
- {
- double val = _numOps.ToDouble(x[i]);
- result[i] = _numOps.FromDouble(1.0 / (1.0 + Math.Exp(-val)));
- }
- return result;
- }
+ ///
+ /// Elementwise Sigmoid, delegated to the engine.
+ ///
+ ///
+ /// This was a scalar loop that read each element out to double and wrote a fresh
+ /// tensor. Arithmetically identical, but it severed the autodiff tape: the gradient chain
+ /// stopped here, so every trainable layer UPSTREAM of this call received no gradient and
+ /// silently never trained. The engine op records itself on the tape.
+ ///
+ private Tensor ApplySigmoid(Tensor x) => AiDotNetEngine.Current.Sigmoid(x);
}
diff --git a/src/ComputerVision/Detection/Backbones/ResNet.cs b/src/ComputerVision/Detection/Backbones/ResNet.cs
index 42b5a2446a..575b4c7a8a 100644
--- a/src/ComputerVision/Detection/Backbones/ResNet.cs
+++ b/src/ComputerVision/Detection/Backbones/ResNet.cs
@@ -148,7 +148,7 @@ public List> ExtractFeatures(Tensor input)
var features = new List>();
var x = _conv1.Forward(input);
x = _activation.Activate(x);
- x = BackboneOps.MaxPool2D(x, kernelSize: 3, stride: 2, padding: 1);
+ x = CvTensorOps.MaxPoolPadded(x, kernelSize: 3, stride: 2, padding: 1);
for (int i = 0; i < _stages.Count; i++)
{
x = _stages[i].Forward(x);
@@ -183,7 +183,7 @@ public override Dictionary> GetNamedLayerActivations(Tensor
var activations = new Dictionary>();
var x = _conv1.Forward(input);
x = _activation.Activate(x);
- x = BackboneOps.MaxPool2D(x, kernelSize: 3, stride: 2, padding: 1);
+ x = CvTensorOps.MaxPoolPadded(x, kernelSize: 3, stride: 2, padding: 1);
activations["Stem"] = x.Clone();
for (int i = 0; i < _stages.Count; i++)
{
diff --git a/src/ComputerVision/Detection/Backbones/SwinTransformer.cs b/src/ComputerVision/Detection/Backbones/SwinTransformer.cs
index a2c02a6a26..fe977052df 100644
--- a/src/ComputerVision/Detection/Backbones/SwinTransformer.cs
+++ b/src/ComputerVision/Detection/Backbones/SwinTransformer.cs
@@ -181,16 +181,7 @@ private Tensor ReshapeToFeatureMap(Tensor x, int height, int width, int st
nameof(height));
}
- var featureMap = new Tensor(new[] { batch, dim, height, width });
- for (int n = 0; n < batch; n++)
- for (int h = 0; h < height; h++)
- for (int w = 0; w < width; w++)
- {
- int seqIdx = h * width + w;
- for (int c = 0; c < dim; c++)
- featureMap[n, c, h, w] = x[n, seqIdx, c];
- }
- return featureMap;
+ return CvTensorOps.UnflattenSpatial(x, height, width);
}
// Most-square factor pair (H >= W) of seqLen, or (0, 0) if seqLen <= 0.
@@ -296,6 +287,28 @@ public override void Train(Tensor input, Tensor expectedOutput) =>
public override IFullModel, Tensor> WithParameters(Vector parameters) =>
throw new NotSupportedException(
$"{GetType().Name}: WithParameters(Vector) is unsupported on backbones.");
+
+ ///
+ /// Registers the Swin weights that are not layers: every block's two layer norms and its
+ /// relative-position bias table.
+ ///
+ ///
+ /// The generated registration follows the EnumerateLayers() convention, which yields only
+ /// LayerBase instances, so these tensors were outside the parameter registry - never saved,
+ /// cloned, or trained. They are exposed as live tensors so a tape-based step updates the real ones.
+ ///
+ protected override void RegisterComponents()
+ {
+ base.RegisterComponents();
+ // A method group, not a lambda over _stages: the registration scan links every member named in
+ // this call to the registered source, and _stages would then be classified as trainable state
+ // the state registry must persist -- which it cannot for List> (ADN0062), and must
+ // not, since this source and the generated layer registration already own every tensor in it.
+ RegisterParameterComponent(new AiDotNet.Models.Parameters.TensorListParameterSource(StageParameterTensors));
+ }
+
+ private List> StageParameterTensors()
+ => _stages.SelectMany(stage => stage.ExtraParameterTensors()).ToList();
}
///
@@ -335,26 +348,7 @@ public PatchEmbeddingBlock(int patchSize, int embedDim)
_proj = new ConvolutionalLayer(outputDepth: embedDim, kernelSize: patchSize, stride: patchSize, padding: 0);
}
- public Tensor Forward(Tensor input)
- {
- var x = _proj.Forward(input);
- int batch = x.Shape[0];
- int channels = x.Shape[1];
- int height = x.Shape[2];
- int width = x.Shape[3];
- int numPatches = height * width;
-
- var sequence = new Tensor(new[] { batch, numPatches, channels });
- for (int n = 0; n < batch; n++)
- for (int h = 0; h < height; h++)
- for (int w = 0; w < width; w++)
- {
- int seqIdx = h * width + w;
- for (int c = 0; c < channels; c++)
- sequence[n, seqIdx, c] = x[n, c, h, w];
- }
- return sequence;
- }
+ public Tensor Forward(Tensor input) => CvTensorOps.FlattenSpatial(_proj.Forward(input));
public long GetParameterCount() => _proj.ParameterCount;
@@ -470,6 +464,14 @@ public void ReadParameters(BinaryReader reader)
foreach (var block in _blocks) block.ReadParameters(reader);
if (_patchMerge is not null) _patchMerge.ReadParameters(reader);
}
+
+ /// Every block's , in order.
+ internal IEnumerable> ExtraParameterTensors()
+ {
+ foreach (var block in _blocks)
+ foreach (var tensor in block.ExtraParameterTensors())
+ yield return tensor;
+ }
}
///
@@ -625,249 +627,40 @@ private Tensor WindowAttention(Tensor x, int h, int w)
}
private Tensor ReshapeToSpatial(Tensor x, int batch, int h, int w, int c)
- {
- var spatial = new Tensor(new[] { batch, h, w, c });
- for (int b = 0; b < batch; b++)
- for (int i = 0; i < h; i++)
- for (int j = 0; j < w; j++)
- {
- int seqIdx = i * w + j;
- for (int d = 0; d < c; d++) spatial[b, i, j, d] = x[b, seqIdx, d];
- }
- return spatial;
- }
+ => AiDotNetEngine.Current.Reshape(x, new[] { batch, h, w, c });
private Tensor ReshapeToSequence(Tensor spatial)
- {
- int batch = spatial.Shape[0];
- int h = spatial.Shape[1];
- int w = spatial.Shape[2];
- int c = spatial.Shape[3];
- var seq = new Tensor(new[] { batch, h * w, c });
- for (int b = 0; b < batch; b++)
- for (int i = 0; i < h; i++)
- for (int j = 0; j < w; j++)
- {
- int seqIdx = i * w + j;
- for (int d = 0; d < c; d++) seq[b, seqIdx, d] = spatial[b, i, j, d];
- }
- return seq;
- }
-
- private Tensor CyclicShift(Tensor x, int shift)
- {
- int batch = x.Shape[0];
- int h = x.Shape[1];
- int w = x.Shape[2];
- int c = x.Shape[3];
- var shifted = new Tensor(x._shape);
- for (int b = 0; b < batch; b++)
- for (int i = 0; i < h; i++)
- for (int j = 0; j < w; j++)
- {
- int srcI = (i - shift % h + h) % h;
- int srcJ = (j - shift % w + w) % w;
- for (int d = 0; d < c; d++) shifted[b, i, j, d] = x[b, srcI, srcJ, d];
- }
- return shifted;
- }
+ => AiDotNetEngine.Current.Reshape(
+ spatial, new[] { spatial.Shape[0], spatial.Shape[1] * spatial.Shape[2], spatial.Shape[3] });
- private (Tensor windows, int numWindowsH, int numWindowsW) WindowPartition(Tensor x)
- {
- int batch = x.Shape[0];
- int h = x.Shape[1];
- int w = x.Shape[2];
- int c = x.Shape[3];
+ private Tensor CyclicShift(Tensor x, int shift) => CvTensorOps.CyclicShift(x, shift);
- int padH = (_windowSize - h % _windowSize) % _windowSize;
- int padW = (_windowSize - w % _windowSize) % _windowSize;
- int paddedH = h + padH;
- int paddedW = w + padW;
-
- Tensor padded;
- if (padH > 0 || padW > 0)
- {
- padded = new Tensor(new[] { batch, paddedH, paddedW, c });
- for (int b = 0; b < batch; b++)
- for (int i = 0; i < paddedH; i++)
- for (int j = 0; j < paddedW; j++)
- for (int d = 0; d < c; d++)
- padded[b, i, j, d] = (i < h && j < w) ? x[b, i, j, d] : _numOps.FromDouble(0.0);
- }
- else
- {
- padded = x;
- paddedH = h;
- paddedW = w;
- }
-
- int numWindowsH = paddedH / _windowSize;
- int numWindowsW = paddedW / _windowSize;
- int numWindows = numWindowsH * numWindowsW;
- int windowArea = _windowSize * _windowSize;
-
- var windows = new Tensor(new[] { batch * numWindows, windowArea, c });
- for (int b = 0; b < batch; b++)
- for (int wh = 0; wh < numWindowsH; wh++)
- for (int ww = 0; ww < numWindowsW; ww++)
- {
- int windowIdx = b * numWindows + wh * numWindowsW + ww;
- int startH = wh * _windowSize;
- int startW = ww * _windowSize;
- for (int i = 0; i < _windowSize; i++)
- for (int j = 0; j < _windowSize; j++)
- {
- int tokenIdx = i * _windowSize + j;
- for (int d = 0; d < c; d++)
- windows[windowIdx, tokenIdx, d] = padded[b, startH + i, startW + j, d];
- }
- }
- return (windows, numWindowsH, numWindowsW);
- }
+ private (Tensor Windows, int NumWindowsH, int NumWindowsW) WindowPartition(Tensor x)
+ => CvTensorOps.WindowPartition(x, _windowSize);
private Tensor WindowReverse(Tensor windows, int numWindowsH, int numWindowsW, int batch, int h, int w)
- {
- int numWindows = numWindowsH * numWindowsW;
- int c = windows.Shape[2];
-
- var spatial = new Tensor(new[] { batch, h, w, c });
- for (int b = 0; b < batch; b++)
- for (int wh = 0; wh < numWindowsH; wh++)
- for (int ww = 0; ww < numWindowsW; ww++)
- {
- int windowIdx = b * numWindows + wh * numWindowsW + ww;
- int startH = wh * _windowSize;
- int startW = ww * _windowSize;
- for (int i = 0; i < _windowSize; i++)
- for (int j = 0; j < _windowSize; j++)
- {
- int outH = startH + i;
- int outW = startW + j;
- if (outH < h && outW < w)
- {
- int tokenIdx = i * _windowSize + j;
- for (int d = 0; d < c; d++)
- spatial[b, outH, outW, d] = windows[windowIdx, tokenIdx, d];
- }
- }
- }
- return spatial;
- }
+ => CvTensorOps.WindowReverse(windows, numWindowsH, numWindowsW, batch, h, w, _windowSize);
private Tensor WindowedSelfAttention(Tensor windows)
{
- int numWindows = windows.Shape[0];
- int windowArea = windows.Shape[1];
+ var engine = AiDotNetEngine.Current;
int c = windows.Shape[2];
- var qkv = new Tensor(new[] { numWindows, windowArea, 3 * c });
- for (int wIdx = 0; wIdx < numWindows; wIdx++)
- {
- for (int t = 0; t < windowArea; t++)
- {
- var tokenIn = new Tensor(new[] { 1, c });
- for (int d = 0; d < c; d++) tokenIn[0, d] = windows[wIdx, t, d];
- var tokenQkv = _qkvProj.Forward(tokenIn);
- for (int d = 0; d < 3 * c; d++) qkv[wIdx, t, d] = tokenQkv[0, d];
- }
- }
+ // One fused projection, then split into Q, K and V along the feature axis.
+ var qkv = CvTensorOps.Tokenwise(windows, _qkvProj.Forward);
+ var q = engine.TensorNarrow(qkv, 2, 0, c);
+ var k = engine.TensorNarrow(qkv, 2, c, c);
+ var v = engine.TensorNarrow(qkv, 2, 2 * c, c);
- var output = new Tensor(new[] { numWindows, windowArea, c });
- for (int wIdx = 0; wIdx < numWindows; wIdx++)
- {
- var attnScores = new double[_numHeads, windowArea, windowArea];
- for (int head = 0; head < _numHeads; head++)
- {
- int headOffset = head * _headDim;
- for (int i = 0; i < windowArea; i++)
- for (int j = 0; j < windowArea; j++)
- {
- double score = 0;
- for (int d = 0; d < _headDim; d++)
- {
- double q = _numOps.ToDouble(qkv[wIdx, i, headOffset + d]);
- double k = _numOps.ToDouble(qkv[wIdx, j, c + headOffset + d]);
- score += q * k;
- }
- score *= _scale;
- int biasIdx = _relativePositionIndex[i, j];
- score += _numOps.ToDouble(_relativePositionBiasTable[biasIdx, head]);
- attnScores[head, i, j] = score;
- }
- }
-
- var attnProbs = new double[_numHeads, windowArea, windowArea];
- for (int head = 0; head < _numHeads; head++)
- {
- for (int i = 0; i < windowArea; i++)
- {
- double maxScore = double.NegativeInfinity;
- for (int j = 0; j < windowArea; j++)
- if (attnScores[head, i, j] > maxScore) maxScore = attnScores[head, i, j];
-
- double sumExp = 0;
- for (int j = 0; j < windowArea; j++)
- {
- attnProbs[head, i, j] = Math.Exp(attnScores[head, i, j] - maxScore);
- sumExp += attnProbs[head, i, j];
- }
- for (int j = 0; j < windowArea; j++) attnProbs[head, i, j] /= sumExp;
- }
- }
-
- var attnOut = new double[windowArea, c];
- for (int head = 0; head < _numHeads; head++)
- {
- int headOffset = head * _headDim;
- int vOffset = 2 * c + headOffset;
- for (int i = 0; i < windowArea; i++)
- for (int d = 0; d < _headDim; d++)
- {
- double val = 0;
- for (int j = 0; j < windowArea; j++)
- val += attnProbs[head, i, j] * _numOps.ToDouble(qkv[wIdx, j, vOffset + d]);
- attnOut[i, headOffset + d] = val;
- }
- }
-
- for (int t = 0; t < windowArea; t++)
- {
- var tokenIn = new Tensor(new[] { 1, c });
- for (int d = 0; d < c; d++) tokenIn[0, d] = _numOps.FromDouble(attnOut[t, d]);
- var tokenOut = _outProj.Forward(tokenIn);
- for (int d = 0; d < c; d++) output[wIdx, t, d] = tokenOut[0, d];
- }
- }
- return output;
+ // The learnable relative-position bias table is gathered by the fixed index map, so it now
+ // receives a gradient; the scalar loop read it out as doubles and it never trained.
+ var bias = CvTensorOps.RelativePositionBias(_relativePositionBiasTable, _relativePositionIndex);
+ var attended = CvTensorOps.MultiHeadAttention(q, k, v, _numHeads, _scale, scoreBias: bias);
+ return CvTensorOps.Tokenwise(attended, _outProj.Forward);
}
private Tensor ApplyMLP(Tensor x)
- {
- int batch = x.Shape[0];
- int seqLen = x.Shape[1];
- var result = new Tensor(x._shape);
-
- for (int b = 0; b < batch; b++)
- {
- for (int s = 0; s < seqLen; s++)
- {
- var tokenIn = new Tensor(new[] { 1, _dim });
- for (int d = 0; d < _dim; d++) tokenIn[0, d] = x[b, s, d];
-
- var hidden = _mlpFc1.Forward(tokenIn);
- for (int d = 0; d < hidden.Shape[1]; d++)
- {
- double val = _numOps.ToDouble(hidden[0, d]);
- double gelu = 0.5 * val * (1 + Math.Tanh(Math.Sqrt(2 / Math.PI) * (val + 0.044715 * val * val * val)));
- hidden[0, d] = _numOps.FromDouble(gelu);
- }
-
- var tokenOut = _mlpFc2.Forward(hidden);
- for (int d = 0; d < _dim; d++) result[b, s, d] = tokenOut[0, d];
- }
- }
- return result;
- }
+ => CvTensorOps.Tokenwise(x, rows => _mlpFc2.Forward(AiDotNetEngine.Current.GELU(_mlpFc1.Forward(rows))));
private Tensor AddTensors(Tensor a, Tensor b) =>
AiDotNetEngine.Current.TensorAdd(a, b);
@@ -916,6 +709,17 @@ public void ReadParameters(BinaryReader reader)
BackboneSerialization.ReadLayerParameters(reader, _mlpFc1);
BackboneSerialization.ReadLayerParameters(reader, _mlpFc2);
}
+
+ ///
+ /// Weights this block owns outside its layers: both norms' scale
+ /// and shift, and the relative-position bias table. Live tensors, in a fixed order.
+ ///
+ internal IEnumerable> ExtraParameterTensors()
+ {
+ foreach (var tensor in _norm1.ParameterTensors()) yield return tensor;
+ foreach (var tensor in _norm2.ParameterTensors()) yield return tensor;
+ yield return _relativePositionBiasTable;
+ }
}
///
@@ -943,39 +747,7 @@ public SwinLayerNorm(int dim, double eps = 1e-6)
}
}
- public Tensor Forward(Tensor x)
- {
- int batch = x.Shape[0];
- int seqLen = x.Shape[1];
- int dim = x.Shape[2];
- var result = new Tensor(x._shape);
-
- for (int b = 0; b < batch; b++)
- for (int s = 0; s < seqLen; s++)
- {
- double mean = 0;
- for (int d = 0; d < dim; d++) mean += _numOps.ToDouble(x[b, s, d]);
- mean /= dim;
-
- double variance = 0;
- for (int d = 0; d < dim; d++)
- {
- double diff = _numOps.ToDouble(x[b, s, d]) - mean;
- variance += diff * diff;
- }
- variance /= dim;
-
- double std = Math.Sqrt(variance + _eps);
- for (int d = 0; d < dim; d++)
- {
- double normalized = (_numOps.ToDouble(x[b, s, d]) - mean) / std;
- double gamma = _numOps.ToDouble(_gamma[d]);
- double beta = _numOps.ToDouble(_beta[d]);
- result[b, s, d] = _numOps.FromDouble(gamma * normalized + beta);
- }
- }
- return result;
- }
+ public Tensor Forward(Tensor x) => CvTensorOps.LayerNormLastAxis(x, _gamma, _beta, _eps);
public long GetParameterCount() => 2 * _dim;
@@ -994,6 +766,13 @@ public void ReadParameters(BinaryReader reader)
for (int i = 0; i < _dim; i++) _gamma[i] = _numOps.FromDouble(reader.ReadDouble());
for (int i = 0; i < _dim; i++) _beta[i] = _numOps.FromDouble(reader.ReadDouble());
}
+
+ /// The learnable scale and shift, as live tensors.
+ internal IEnumerable> ParameterTensors()
+ {
+ yield return _gamma;
+ yield return _beta;
+ }
}
///
@@ -1049,50 +828,10 @@ public Tensor Forward(Tensor input, int? inputHeight, int? inputWidth)
$"Cannot infer spatial dimensions from sequence length {seqLen} for patch merging.");
}
- // Pad odd H/W up to the next even size (zeros), so the 2×2 merge always has full quads.
- int hPad = h + (h & 1);
- int wPad = w + (w & 1);
- Tensor src = input;
- if (hPad != h || wPad != w)
- {
- var padded = new Tensor(new[] { batch, hPad * wPad, dim });
- for (int n = 0; n < batch; n++)
- for (int i = 0; i < h; i++)
- for (int j = 0; j < w; j++)
- {
- int srcIdx = i * w + j;
- int dstIdx = i * wPad + j;
- for (int d = 0; d < dim; d++)
- padded[n, dstIdx, d] = input[n, srcIdx, d];
- }
- src = padded;
- }
-
- int newH = hPad / 2;
- int newW = wPad / 2;
- int newSeqLen = newH * newW;
- var merged = new Tensor(new[] { batch, newSeqLen, dim * 4 });
-
- for (int n = 0; n < batch; n++)
- {
- for (int i = 0; i < newH; i++)
- for (int j = 0; j < newW; j++)
- {
- int newIdx = i * newW + j;
- int idx0 = (2 * i) * wPad + (2 * j);
- int idx1 = (2 * i) * wPad + (2 * j + 1);
- int idx2 = (2 * i + 1) * wPad + (2 * j);
- int idx3 = (2 * i + 1) * wPad + (2 * j + 1);
- for (int d = 0; d < dim; d++)
- {
- merged[n, newIdx, d] = src[n, idx0, d];
- merged[n, newIdx, dim + d] = src[n, idx1, d];
- merged[n, newIdx, 2 * dim + d] = src[n, idx2, d];
- merged[n, newIdx, 3 * dim + d] = src[n, idx3, d];
- }
- }
- }
-
+ // Zero-pad odd H/W to even and gather each 2x2 quad into one token (engine ops, so the tape
+ // survives the merge and the stages before it keep receiving gradient).
+ var spatial = AiDotNetEngine.Current.Reshape(input, new[] { batch, h, w, dim });
+ var merged = CvTensorOps.PatchMerge2x2(spatial);
return _reduction.Forward(merged);
}
diff --git a/src/ComputerVision/Detection/DetectionTrainingBatch.cs b/src/ComputerVision/Detection/DetectionTrainingBatch.cs
new file mode 100644
index 0000000000..a01accf67d
--- /dev/null
+++ b/src/ComputerVision/Detection/DetectionTrainingBatch.cs
@@ -0,0 +1,120 @@
+namespace AiDotNet.ComputerVision.Detection;
+
+/// Owns an immutable, unpadded target list for every image in a training batch.
+/// The detector's numeric type.
+public sealed class DetectionTrainingBatch
+{
+ private readonly IReadOnlyList>[] _images;
+
+ /// Copies the supplied lists. Empty images are valid; an empty batch is not.
+ public DetectionTrainingBatch(IEnumerable>> images)
+ {
+ if (images is null) throw new ArgumentNullException(nameof(images));
+ var owned = new List>>();
+ int total = 0;
+ foreach (var image in images)
+ {
+ if (image is null) throw new ArgumentException("Each image must have a target list, even when empty.", nameof(images));
+ var targets = image.ToArray();
+ if (targets.Any(target => target is null))
+ throw new ArgumentException("Target lists cannot contain null entries.", nameof(images));
+ total = checked(total + targets.Length);
+ owned.Add(Array.AsReadOnly(targets));
+ }
+ if (owned.Count == 0) throw new ArgumentException("A training batch must contain at least one image.", nameof(images));
+ _images = owned.ToArray();
+ TargetCount = total;
+ }
+
+ /// Gets the number of images, including images with no objects.
+ public int ImageCount => _images.Length;
+ /// Gets the total foreground target count across all images.
+ public int TargetCount { get; }
+ /// Gets the immutable targets for one image.
+ public IReadOnlyList> this[int imageIndex] => _images[imageIndex];
+
+ ///
+ /// Converts the COCO loader's [batch, objects, 5] normalized top-left xywh labels.
+ /// A completely zero row is padding; all other rows must describe a valid foreground box.
+ ///
+ public static DetectionTrainingBatch FromPaddedCoco(Tensor labels)
+ {
+ ValidatePaddedShape(labels, exactWidth: true);
+ var ops = MathHelper.GetNumericOperations();
+ var images = new List