diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c45ee73..fbae4a9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,22 +40,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - name: Checkout pinned MajSimai
+ - name: Checkout MajSimai release tag
uses: actions/checkout@v4
with:
repository: TeamMajdata/MajSimai
- ref: fdb2a3e39d8997a0abbf8b4679062d854473cc77
+ # Keep this tag aligned with the NuGet version in MajRadar.csproj.
+ ref: refs/tags/2.2.3
path: external/MajSimai
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- - name: Restore with pinned MajSimai
+ - name: Restore with MajSimai release tag
run: >-
dotnet restore MajRadar.slnx
-p:MajSimaiProject="$GITHUB_WORKSPACE/external/MajSimai/MajSimai.csproj"
- - name: Test with pinned MajSimai
+ - name: Test with MajSimai release tag
run: >-
dotnet test MajRadar.slnx --no-restore -c Release
-p:MajSimaiProject="$GITHUB_WORKSPACE/external/MajSimai/MajSimai.csproj"
diff --git a/Documentation~/FallbackGeometry.md b/Documentation~/FallbackGeometry.md
new file mode 100644
index 0000000..5c10977
--- /dev/null
+++ b/Documentation~/FallbackGeometry.md
@@ -0,0 +1,69 @@
+# Fallback extended-Slide geometry
+
+`new RadarRuntime()` and `new MajSimaiChartAdapter()` select
+`DefaultExtendedSlideBarCountProvider.Instance` when the optional provider is
+null. An explicitly supplied provider is authoritative, including its exceptions
+and non-positive results. Standard Slides continue using the existing lookup
+table and never call the extended-Slide provider.
+
+## Source and maintenance
+
+The geometry snapshot is adapted from TeamMajdata/MajdataPlay commit
+`acd295d3f878f53ecd412d150936a465ed2e3d28` (GPL-3.0):
+
+https://github.com/TeamMajdata/MajdataPlay/tree/acd295d3f878f53ecd412d150936a465ed2e3d28/Assets/Scripts/Scenes/Game/Misc/Parsing
+
+`Runtime/MajSimaiAdapter/FallbackGeometry/` retains the pure `System.Numerics`
+path parser, circles, path constructor and segments. Namespaces and top-level
+visibility are changed to keep these implementation types internal. No Unity
+types, assets, rendering or judgement-area lookup are needed.
+
+The provider follows `SlideDataBuilder.BuildArrowData(path).Length - 2` but only
+counts placements: it does not allocate the arrow coordinate array. It preserves
+the exact segment transition order, floating-point operations, default spacing,
+`SmoothAlign` and `ForceAlign` rules. A total-length/divisor approximation would
+change counts. Each call owns its path because alignment mutates segment spacing.
+Invalid or non-advancing geometry throws and is converted into the existing
+structured adaptation error at the public analysis boundary.
+
+The provider is synchronous, like the host-provider interface. Cancellation does
+not interrupt a call midway; hosts should bound input sizes and analysis
+concurrency. The fallback adds no process-wide result cache.
+
+Persisted analysis cache keys should include the MajRadar and MajSimai package
+versions plus `DefaultExtendedSlideBarCountProvider.GeometryVersion`. When
+updating this snapshot, change the geometry version and review the geometry
+regressions against the upstream change. An injected host provider should
+use its own geometry version instead.
+
+## Focused regression tests
+
+`DefaultExtendedSlideBarCountProviderTests` keeps 13 representative Play counts
+covering A/B/C nodes, clockwise/counterclockwise orbits, repeated circles,
+tangent transfers, and both alignment markers. Default selection, explicit
+provider precedence, provider failures, malformed geometry and concurrent path
+ownership are tested separately. Run them with `dotnet test MajRadar.slnx`.
+
+The previous 5,757-row snapshot and its generator were removed: the broad grid
+duplicated the same path rules and encoded macOS floating-point boundary choices
+as universal integer answers. .NET trigonometric operations use the native C
+runtime and may differ across operating systems and architectures:
+
+https://learn.microsoft.com/en-us/dotnet/api/system.math.sin#remarks
+
+Four SmoothAlign paths (`8Q69K4`, `4P39K1`, `8Q69K8`, `1Q39K4`) end within a few
+ULPs of a segment boundary or endpoint. Play's retained strict comparisons may
+therefore include one additional arrow sample. Their tests explicitly allow
+the two observed macOS/Ubuntu counts; other representative cases still require
+exact values. Concurrency tests compare with sequential results from the same
+process. The runtime algorithm and `GeometryVersion` are unchanged.
+
+The fallback follows the pinned Play algorithm on the running platform; it does
+not promise bit-identical arrow counts across all platforms. Making those counts
+platform-independent would require a shared numerical policy in Play and MajRadar,
+not a test-only change.
+
+MajSimai 2.2.3 is required for end-to-end parsing: version 2.2.2 misclassifies
+some extended paths containing A/B/C, even when the geometry provider is correct.
+CI checks out the `2.2.3` release tag (`refs/tags/2.2.3`), aligned with the
+NuGet dependency in `MajRadar.csproj`. This tag includes the same parser fix.
diff --git a/Documentation~/MajdataPlay.md b/Documentation~/MajdataPlay.md
index 60f5860..6426569 100644
--- a/Documentation~/MajdataPlay.md
+++ b/Documentation~/MajdataPlay.md
@@ -6,7 +6,10 @@ MajSimai assembly; the NuGet package is not installed into the Unity project.
Extended `K` Slides are resolved through `IExtendedSlideBarCountProvider`. The
sample provider calls Play's existing `SlideCodeParser` and `SlideDataBuilder`,
-so MajRadar does not copy or depend on gameplay geometry.
+so Play's current gameplay geometry always takes precedence over MajRadar's
+built-in fallback. Hosts without a provider use a pinned Unity-free geometry
+copy; see [FallbackGeometry.md](FallbackGeometry.md). An injected provider's
+failure remains a failure and is never retried using the fallback.
The sample `ChartRadarService` is a static Play-only facade. It owns one
thread-safe `RadarRuntime` with the single supported provider, while each UI
diff --git a/MajRadar.csproj b/MajRadar.csproj
index ed9aac4..9bd538d 100644
--- a/MajRadar.csproj
+++ b/MajRadar.csproj
@@ -21,7 +21,7 @@
-
+
diff --git a/README.md b/README.md
index 7cf53f1..0f8002b 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,9 @@ RadarResult result = await _runtime.ParseAndAnalyzeAsync(
### Slidecode 依赖
-扩展Slidecode的长度算法需要依赖Play自身的Parser。
+未注入 provider 时,MajRadar 使用内置的 `DefaultExtendedSlideBarCountProvider`,
+它来自固定版本的 Play 纯 .NET 几何实现,后端无需安装 Unity。
+Play 仍应显式注入自己的 provider,以游戏当前使用的几何为准:
```csharp
internal sealed class PlayExtendedSlideBarCountProvider
@@ -72,8 +74,14 @@ internal sealed class PlayExtendedSlideBarCountProvider
}
```
provider只接受SlideCode,例如 `1P6K7`,并且必须返回正数 arrow/bar count。
-provider 抛出的异常或非正结果会变成结构化适配失败。未提供 provider 时,普通谱面仍可
-正常分析;遇到 `K` Slide 会返回错误
+provider 抛出的异常或非正结果会变成结构化适配失败,不会再次尝试备用实现。
+只有构造时未提供 provider(或传入 `null`)才选择内置实现;普通 Slide 不受影响。
+备用实现无共享的单谱状态,可并发调用。数据库缓存版本应包含
+`DefaultExtendedSlideBarCountProvider.GeometryVersion`;宿主 provider 使用自己的版本标识。
+几何来源和对照测试见 [备用几何说明](Documentation~/FallbackGeometry.md)。
+
+NuGet 依赖最低 MajSimai 2.2.3,其中修复了 `1A3P9K5` 等带 A/B/C 路径的
+Slidecode 被误识别为 Touch 的问题。
### 取消与选歌状态
@@ -110,6 +118,8 @@ else if (!result.IsCancelled)
- `RawValues` 和 `Scores` 始终保持公开形状:六个雷达轴加
`fitted_constant`;不可用项为 `null`。
- 只有七维 raw 全部成功时才生成 `FittedConstant` 和映射后的 score。
+- 拟合输出在模块内部限位;上下限由 `RegressionBetaModel` 的
+ `MinimumFittedConstant` / `MaximumFittedConstant` 常量配置,当前为 0~18。
- `partial` 会保留已完成的特征,但不会生成拟合定数。
@@ -196,8 +206,10 @@ continues to own files, song metadata, chart type, artwork, and audio offsets.
### Extended Slide dependency
-Extended `K` Slides need gameplay geometry supplied by the host. MajRadar keeps
-the dependency narrow and does not reference MajdataPlay:
+When no provider is supplied, extended `K` Slides use the built-in
+`DefaultExtendedSlideBarCountProvider`, a pinned, Unity-free copy of Play's
+geometry. Play should still inject its own provider so its current gameplay
+geometry remains authoritative:
```csharp
internal sealed class PlayExtendedSlideBarCountProvider
@@ -213,8 +225,16 @@ internal sealed class PlayExtendedSlideBarCountProvider
The provider receives a normalized SlideCode such as `1P6K7`. It must return a
positive arrow/bar count. Provider exceptions and non-positive results become a
-structured adaptation failure. Without a provider, ordinary charts still work,
-while a chart containing a `K` Slide returns an error instead of guessing.
+structured adaptation failure; they never trigger a retry through the fallback.
+The fallback is selected only when the constructor receives no provider or
+`null`. It creates a fresh path per call and can be shared concurrently.
+Include `DefaultExtendedSlideBarCountProvider.GeometryVersion` in persistent
+cache identities, or your host provider's own version when overriding it.
+See [fallback geometry](Documentation~/FallbackGeometry.md) for provenance and
+the focused geometry and provider regression tests.
+
+NuGet requires MajSimai 2.2.3 or newer. That release fixes Slidecodes containing
+A/B/C path commands, such as `1A3P9K5`, being misclassified as Touch notes.
Ordinary Slides, feature parameters, regression coefficients, and score mapping
are built in and are not dependency-injected.
@@ -266,6 +286,8 @@ else if (!result.IsCancelled)
axes plus `fitted_constant`; unavailable entries are `null`.
- `FittedConstant` and mapped scores are produced only when all seven raw
features succeed.
+- Predictions are clamped inside the module using `MinimumFittedConstant` and
+ `MaximumFittedConstant` in `RegressionBetaModel` (currently 0 and 18).
- `partial` preserves completed feature results but does not produce a fitted
constant.
- `fitted_constant` is identity-mapped and is not on the radar axes' 0-250
diff --git a/Runtime/MajSimaiAdapter/DefaultExtendedSlideBarCountProvider.cs b/Runtime/MajSimaiAdapter/DefaultExtendedSlideBarCountProvider.cs
new file mode 100644
index 0000000..1e638e5
--- /dev/null
+++ b/Runtime/MajSimaiAdapter/DefaultExtendedSlideBarCountProvider.cs
@@ -0,0 +1,74 @@
+using System;
+using MajRadar.MajSimaiAdapter.FallbackGeometry;
+using MajRadar.MajSimaiAdapter.FallbackGeometry.Slide;
+
+namespace MajRadar.MajSimaiAdapter;
+
+///
+/// Unity-free fallback for hosts without their own extended-Slide geometry.
+/// Counts arrows using a pinned copy of MajdataPlay's path and alignment rules.
+/// Each call creates its own path, so the shared instance is thread-safe.
+///
+public sealed class DefaultExtendedSlideBarCountProvider : IExtendedSlideBarCountProvider
+{
+ /// Include this identifier in persistent analysis cache versions.
+ public const string GeometryVersion = "play-acd295d-count-v1";
+
+ public static DefaultExtendedSlideBarCountProvider Instance { get; } = new();
+
+ public int ResolveBarCount(string slideCode)
+ {
+ if (slideCode is null)
+ throw new ArgumentNullException(nameof(slideCode));
+ if (slideCode.Length < 3 || slideCode[0] is < '1' or > '8' ||
+ slideCode[^2] != 'K' || slideCode[^1] is < '1' or > '8')
+ throw new ArgumentException("Expected a normalized extended Slide code, such as 1P6K7.", nameof(slideCode));
+
+ var path = SlideCodeParser.Parse(slideCode);
+ var totalLength = path.GetPathLength();
+ if (!IsFinite(totalLength) || totalLength <= 0)
+ throw new ArgumentException("Extended Slide path must have a finite positive length.", nameof(slideCode));
+
+ var currentLength = 0.0;
+ var segmentIndex = 0;
+ var samples = 0;
+ while (currentLength < totalLength)
+ {
+ samples = checked(samples + 1);
+ var segment = path.Segments[segmentIndex];
+ var nextLength = currentLength + segment.ArrowDistance;
+
+ // Keep the single-segment transition and floating-point operation order
+ // from Play's BuildArrowData. Rounding the total length is not equivalent.
+ if (segmentIndex < path.Segments.Length - 1 &&
+ nextLength >= path.AccumulatedLengths[segmentIndex])
+ {
+ var nextSegment = path.Segments[segmentIndex + 1];
+ if (nextSegment.ParseMarker == SlideParseMarker.SmoothAlign)
+ {
+ var delta = path.AccumulatedLengths[segmentIndex + 1] - currentLength;
+ var n = Math.Round(delta / SlideGeo.DefaultDistance);
+ nextSegment.SetArrowDistance(delta / n);
+ nextLength = currentLength + delta / n;
+ }
+ if (segment.ParseMarker == SlideParseMarker.ForceAlign)
+ nextLength = path.AccumulatedLengths[segmentIndex] + nextSegment.ArrowDistance;
+ segmentIndex++;
+ }
+
+ // Invalid geometry must fail rather than stall a server worker forever.
+ if (!IsFinite(nextLength) || nextLength <= currentLength)
+ throw new ArgumentException("Extended Slide arrow placement must advance by a finite positive distance.", nameof(slideCode));
+ currentLength = nextLength;
+ }
+
+ // BuildArrowData includes the starting sample and appends the endpoint.
+ // Its Length - 2 therefore equals the number of loop samples minus one.
+ var count = samples - 1;
+ if (count <= 0)
+ throw new ArgumentException("Extended Slide path has no arrows.", nameof(slideCode));
+ return count;
+ }
+
+ private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
+}
diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/ComplexCircle.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/ComplexCircle.cs
new file mode 100644
index 0000000..14854af
--- /dev/null
+++ b/Runtime/MajSimaiAdapter/FallbackGeometry/ComplexCircle.cs
@@ -0,0 +1,21 @@
+// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28.
+// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests.
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using System.Text;
+
+namespace MajRadar.MajSimaiAdapter.FallbackGeometry
+{
+ internal readonly struct ComplexCircle
+ {
+ public readonly Complex Center;
+ public readonly double Radius;
+
+ public ComplexCircle(Complex center, double radius)
+ {
+ Center = center;
+ Radius = radius;
+ }
+ }
+}
diff --git a/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/ParametricSlidePath.cs b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/ParametricSlidePath.cs
new file mode 100644
index 0000000..70fe5bf
--- /dev/null
+++ b/Runtime/MajSimaiAdapter/FallbackGeometry/Slide/ParametricSlidePath.cs
@@ -0,0 +1,177 @@
+// Adapted from TeamMajdata/MajdataPlay, commit acd295d3f878f53ecd412d150936a465ed2e3d28.
+// GPL-3.0; see Documentation~/FallbackGeometry.md for provenance and parity tests.
+using MajRadar.MajSimaiAdapter.FallbackGeometry.Slide.Segments;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using System.Text;
+
+namespace MajRadar.MajSimaiAdapter.FallbackGeometry.Slide
+{
+
+ ///
+ ///
+ ///
+ public readonly double[] AccumulatedLengths;
+
+ public ParametricSlidePath(IEnumerable pathSegments)
+ {
+ Segments = pathSegments.ToArray();
+ if (Segments.Length == 0)
+ {
+ throw new ArgumentException("At least one path segment is required.");
+ }
+
+ var lengths = Segments.Select(s => s.GetSegmentLength());
+ var sum = 0.0;
+ AccumulatedLengths = lengths.Select(x => (sum += x)).ToArray();
+ Fractions = AccumulatedLengths.Select(x => x / sum).ToArray();
+ }
+
+ ///
+ ///
获取 t 位置所在的片段
+ ///
+ /// 0 ~ 1 (both inclusive)
+ /// 这个点在返回片段上的 t 值,0 ~ 1 (both inclusive)
+ /// 片段对象
+ public PathSegment GetSegmentAt(double t, out double segmentT)
+ {
+ if (t <= 0.0)
+ {
+ segmentT = 0.0;
+ return Segments[0];
+ }
+
+ if (t >= 1.0)
+ {
+ segmentT = 1.0;
+ return Segments[^1];
+ }
+
+ // The index of the specified value in the specified array, if value is found;
+ // otherwise, a negative number.
+ //
+ // If value is not found and value is less than one or more elements in array,
+ // the negative number returned is the bitwise complement of the index of the first element
+ // that is larger than value.
+ //
+ // If value is not found and value is greater than all elements in array,
+ // the negative number returned is the bitwise complement of (the index of the last element plus 1).
+ var idx = Array.BinarySearch(Fractions, t);
+
+ if (idx < 0)
+ {
+ // t 不在 Fractions 里,此时 idx 的按位取反是 第一个比 t 大的元素 的索引
+ idx = ~idx;
+ }
+ // 如果 idx >= 0 说明 t 在 Fractions 里,此时 idx 就是 t 的索引
+ // 所以无论怎样都有 Fractions[idx-1] < t && Fractions[idx] >= t
+ // Note: Fractions[i] 是 Segments[i] 的终点
+
+ if (idx >= Segments.Length)
+ {
+ // t 超出了最后一段的终点,按理来说这不可能,因为越界情况已经处理掉了
+ // 但可能会有舍入误差什么的……
+ segmentT = 1.0;
+ return Segments[^1];
+ }
+
+ if (idx == 0)
+ {
+ segmentT = t / Fractions[0];
+ return Segments[0];
+ }
+
+ segmentT = (t - Fractions[idx - 1]) / (Fractions[idx] - Fractions[idx - 1]);
+ return Segments[idx];
+ }
+
+ ///
+ ///
+ ///
+ public static ComplexCircle GetCircle(int idx)
+ {
+ if (idx == 0)
+ {
+ return new ComplexCircle(Complex.Zero, CenterRadius);
+ }
+
+ if (idx == 9)
+ {
+ return new ComplexCircle(Complex.Zero, MainRadius);
+ }
+
+ var radian = Math.PI * (3.0 / 4.0 - idx / 4.0);
+ var center = Complex.FromPolarCoordinates(PPQQRadius, radian);
+ return new ComplexCircle(center, PPQQRadius);
+ }
+
+ ///
+ /// 计算指定点进入指定圆的切线
+ ///
+ public static double CalcTangentAngle(Complex point, ComplexCircle circle, bool isCcw)
+ {
+ var hypot = point - circle.Center;
+ var deltaRad = Math.Acos(circle.Radius / hypot.Magnitude);
+ var tanRad = hypot.Phase + (isCcw ? deltaRad : -deltaRad);
+ return Math.IEEERemainder(tanRad, Math.PI * 2.0);
+ }
+
+ ///
+ ///
ppqq 圆到判定线大圆的转移路径(slidecode 专用)
+ ///
Note: idx is 1-based, not 0-based
+ ///
+ /// CircleStruct TransferCircle, double TransferStartAngle, double TransferEndAngle
+ public static (ComplexCircle, double, double) TransferOutData(int idx, bool isCcw)
+ {
+ var ppqqRad = Math.PI * (3.0 / 4.0 - idx / 4.0);
+ double startRad, endRad;
+ if (isCcw)
+ {
+ startRad = ppqqRad - PPQQTransferRadian;
+ endRad = ppqqRad + EdgeTransferRadian;
+ }
+ else
+ {
+ startRad = ppqqRad + PPQQTransferRadian;
+ endRad = ppqqRad - EdgeTransferRadian;
+ }
+ var d = MainRadius - TransferRadius;
+ var center = Complex.FromPolarCoordinates(d, endRad);
+ return (
+ new ComplexCircle(center, TransferRadius),
+ Math.IEEERemainder(startRad, Math.PI * 2),
+ Math.IEEERemainder(endRad, Math.PI * 2)
+ );
+ }
+ }
+}
diff --git a/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs b/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs
index dbede2f..6430ab3 100644
--- a/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs
+++ b/Runtime/MajSimaiAdapter/IExtendedSlideBarCountProvider.cs
@@ -3,6 +3,9 @@ namespace MajRadar.MajSimaiAdapter;
///
/// Supplies the gameplay arrow count for a normalized extended K Slide code.
/// Standard Slides continue to use MajRadar's fixed reference table.
+/// When no provider is supplied, the adapter uses
+/// . An explicit provider is
+/// authoritative: its errors are reported without retrying the fallback.
/// Implementations used by a shared RadarRuntime must be thread-safe and
/// must not access Unity main-thread-only objects.
///
diff --git a/Runtime/MajSimaiAdapter/MajSimaiChartAdapter.cs b/Runtime/MajSimaiAdapter/MajSimaiChartAdapter.cs
index 3cdc6cb..b0184d7 100644
--- a/Runtime/MajSimaiAdapter/MajSimaiChartAdapter.cs
+++ b/Runtime/MajSimaiAdapter/MajSimaiChartAdapter.cs
@@ -27,7 +27,7 @@ public sealed class MajSimaiChartAdapter
public MajSimaiChartAdapter(IExtendedSlideBarCountProvider? extendedSlides = null)
{
- _slidePaths = new SlidePathResolver(extendedSlides);
+ _slidePaths = new SlidePathResolver(extendedSlides ?? DefaultExtendedSlideBarCountProvider.Instance);
}
public async Task ParseAndAdaptAsync(
diff --git a/Runtime/Regression/RegressionBetaModel.cs b/Runtime/Regression/RegressionBetaModel.cs
index e64b2f7..e57132f 100644
--- a/Runtime/Regression/RegressionBetaModel.cs
+++ b/Runtime/Regression/RegressionBetaModel.cs
@@ -6,6 +6,10 @@ namespace MajRadar.Regression;
public sealed class RegressionBetaModel
{
+ // Output limits can be tuned independently of the fitted coefficients.
+ internal const double MinimumFittedConstant = 0;
+ internal const double MaximumFittedConstant = 18;
+
public IReadOnlyList InputFeatures => RadarFeatureNames.ModelInputOrder;
public double Predict(IReadOnlyList rawValues)
@@ -32,7 +36,7 @@ public double Predict(IReadOnlyList rawValues)
result += coefficients[coefficientIndex++] * normalized[left] * normalized[right];
if (coefficientIndex != coefficients.Length || double.IsNaN(result) || double.IsInfinity(result))
throw new InvalidOperationException("Regression prediction is invalid.");
- return result;
+ return Math.Clamp(result, MinimumFittedConstant, MaximumFittedConstant);
}
public double Predict(IReadOnlyDictionary rawFeatures)
diff --git a/Runtime/Runtime/RadarRuntime.cs b/Runtime/Runtime/RadarRuntime.cs
index 2ad6233..e7fa82e 100644
--- a/Runtime/Runtime/RadarRuntime.cs
+++ b/Runtime/Runtime/RadarRuntime.cs
@@ -81,6 +81,7 @@ FittedConstant is not null && MappingVersion is not null &&
/// Unity-free public entry point for Play and standalone callers. Instances do
/// not retain chart or result state and may be reused concurrently when the
/// injected extended-Slide provider is thread-safe.
+/// Without an injected provider, the built-in Play geometry snapshot is used.
///
public sealed class RadarRuntime
{
diff --git a/Samples~/MajdataPlay/README.md b/Samples~/MajdataPlay/README.md
index d59a643..cf9978f 100644
--- a/Samples~/MajdataPlay/README.md
+++ b/Samples~/MajdataPlay/README.md
@@ -6,7 +6,21 @@ wires the single Play geometry implementation directly into one reusable
```csharp
var snapshot = await ChartRadarService.AnalyzeAsync(chart, cancellationToken);
+
+var noteScore = snapshot.GetScore(RadarOutputDimension.Note);
+var trickyRaw = snapshot.GetRawValue(RadarOutputDimension.SlideTricky);
```
Play remains responsible for selection generation, cancellation ownership,
logging, UI updates, and caching the lightweight `ChartRadarSnapshot`.
+
+The explicitly injected Play provider takes precedence over MajRadar's built-in
+fallback. A host without Play geometry can use `new RadarRuntime()` directly;
+the fallback is selected only when no provider is supplied. Exceptions or
+non-positive results from an injected provider remain structured failures.
+
+Existing string access remains available for dynamic UI code:
+
+```csharp
+var noteScore = snapshot.Scores["note"];
+```
diff --git a/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs b/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs
index e183d12..246abdf 100644
--- a/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs
+++ b/Tests~/MajRadar.Tests/AnalysisAndRegressionTests.cs
@@ -284,7 +284,7 @@ public async Task UngroupedNoHeadSlideContributesToIntensityButNotSlideGroupFeat
[Theory]
[InlineData("(0){4}1,E", "non-finite or non-positive")]
- [InlineData("(120){4}1K5[4:1],E", "Extended K Slides")]
+ [InlineData("(120){4}1P0Q0K5[4:1],E", "orbit type mismatch")]
public async Task AdaptationFailuresReturnDataWithoutLeakingExceptions(
string inote,
string expectedError)
@@ -360,14 +360,19 @@ public void FeatureFailuresAreIsolatedInsideRadarAnalyzer()
[Theory]
[MemberData(nameof(RegressionVectors))]
- public void EmbeddedRegressionMatchesFrozenPythonVectors(double[] raw, double expected)
+ public void EmbeddedRegressionMatchesPythonVectorsWithinOutputLimits(double[] raw, double expected)
{
var actual = new RegressionBetaModel().Predict(raw);
- Assert.Equal(expected, actual, 9);
+ Assert.Equal(Math.Clamp(expected,
+ RegressionBetaModel.MinimumFittedConstant,
+ RegressionBetaModel.MaximumFittedConstant), actual, 9);
}
public static IEnumerable