diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..961c0fe
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,61 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - dev
+ # publish.yml calls both jobs on the commit being published.
+ workflow_call:
+
+permissions:
+ contents: read
+
+jobs:
+ test-and-pack:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore
+ run: dotnet restore MajRadar.slnx
+
+ - name: Test default dependencies
+ run: dotnet test MajRadar.slnx --no-restore -c Release
+
+ - name: Verify package build
+ run: dotnet pack MajRadar.csproj --no-restore -c Release -o artifacts
+
+ - name: Upload CI package
+ uses: actions/upload-artifact@v4
+ with:
+ name: MajRadar-nuget
+ path: artifacts/*.nupkg
+ if-no-files-found: error
+
+ play-majsimai-pin:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Checkout pinned MajSimai
+ uses: actions/checkout@v4
+ with:
+ repository: TeamMajdata/MajSimai
+ ref: fdb2a3e39d8997a0abbf8b4679062d854473cc77
+ path: external/MajSimai
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore with pinned MajSimai
+ run: >-
+ dotnet restore MajRadar.slnx
+ -p:MajSimaiProject="$GITHUB_WORKSPACE/external/MajSimai/MajSimai.csproj"
+
+ - name: Test with pinned MajSimai
+ run: >-
+ dotnet test MajRadar.slnx --no-restore -c Release
+ -p:MajSimaiProject="$GITHUB_WORKSPACE/external/MajSimai/MajSimai.csproj"
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..d00e434
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,86 @@
+name: Publish NuGet
+
+on:
+ push:
+ # main pushes publish preview packages; v* tags select an explicit version.
+ branches:
+ - main
+ tags:
+ - "v*"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ uses: ./.github/workflows/ci.yml
+
+ publish:
+ # Both CI jobs must pass before this job starts.
+ needs: validate
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+
+ - id: version
+ name: Select package version
+ shell: bash
+ run: |
+ if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
+ value="${GITHUB_REF_NAME#v}"
+ else
+ prefix="$(dotnet msbuild MajRadar.csproj -nologo -getProperty:VersionPrefix)"
+ if [[ -z "$prefix" ]]; then
+ echo "::error::VersionPrefix is empty. Set it in MajRadar.csproj or Directory.Build.props."
+ exit 1
+ fi
+ value="${prefix}-ci.${GITHUB_RUN_NUMBER}"
+ fi
+ if [[ -z "$value" ]]; then
+ echo "::error::The version tag must contain a NuGet version after v."
+ exit 1
+ fi
+ echo "value=$value" >> "$GITHUB_OUTPUT"
+
+ - name: Restore
+ run: dotnet restore MajRadar.slnx
+
+ - name: Build release package
+ env:
+ PACKAGE_VERSION: ${{ steps.version.outputs.value }}
+ run: >-
+ dotnet pack MajRadar.csproj --no-restore -c Release -o artifacts
+ -p:PackageVersion="$PACKAGE_VERSION"
+
+ - name: Upload versioned package
+ uses: actions/upload-artifact@v4
+ with:
+ name: MajRadar-${{ steps.version.outputs.value }}
+ path: artifacts/*.nupkg
+ if-no-files-found: error
+
+ # Repository variable NUGET_USER is your nuget.org username, not email.
+ # Register this repository and publish.yml in NuGet Trusted Publishing.
+ - name: NuGet login
+ uses: NuGet/login@v1
+ id: nuget-login
+ with:
+ user: ${{ vars.NUGET_USER }}
+
+ - name: Publish package
+ env:
+ NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }}
+ # An existing version is skipped on rerun; it is never overwritten.
+ run: >-
+ dotnet nuget push "artifacts/*.nupkg"
+ --api-key "$NUGET_API_KEY"
+ --source https://api.nuget.org/v3/index.json
+ --skip-duplicate
diff --git a/.gitignore b/.gitignore
index 3c335ae..d21159a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,4 @@
-# This .gitignore file should be placed at the root of your Unity project directory
-#
-# Get latest from https://github.com/github/gitignore/blob/main/Unity.gitignore
-#
-# Recommended: add any editor/OS/tool-specific ignore rules from the Global/ templates as needed.
-# See: https://github.com/github/gitignore/tree/main/Global
-#
+# Unity-generated directories
.utmp/
/[Ll]ibrary/
/[Tt]emp/
@@ -15,38 +9,19 @@
/[Uu]ser[Ss]ettings/
*.log
-# By default unity supports Blender asset imports, *.blend1 blender files do not need to be commited to version control.
*.blend1
*.blend1.meta
-
-# MemoryCaptures can get excessive in size.
-# They also could contain extremely sensitive data
/[Mm]emoryCaptures/
-
-# Recordings can get excessive in size
/[Rr]ecordings/
-
-# Uncomment this line if you wish to ignore the asset store tools plugin
-# /[Aa]ssets/AssetStoreTools*
-
-# Autogenerated Jetbrains Rider plugin
/[Aa]ssets/Plugins/Editor/JetBrains*
-# Jetbrains Rider personal-layer settings
*.DotSettings.user
-# Visual Studio cache directory
+# Editor and generated project state
.vs/
-
-# Gradle cache directory
.gradle/
-
-# Autogenerated VS/MD/Consulo solution and project files
ExportedObj/
.consulo/
-*.csproj
*.unityproj
-*.sln
-*.slnx
*.suo
*.tmp
*.user
@@ -59,48 +34,34 @@ ExportedObj/
*.opendb
*.VC.db
-# Unity3D generated meta files
*.pidb.meta
*.pdb.meta
*.mdb.meta
-
-# Unity3D generated file on crash reports
sysinfo.txt
-
-# Mono auto generated files
mono_crash.*
-# Builds
+# Unity build artifacts
*.apk
*.aab
*.unitypackage
*.unitypackage.meta
*.app
-
-# Crashlytics generated file
crashlytics-build.properties
-
-# TestRunner generated files
InitTestScene*.unity*
-
-# Addressables default ignores, before user customizations
/ServerData
/[Aa]ssets/StreamingAssets/aa*
/[Aa]ssets/AddressableAssetsData/link.xml*
/[Aa]ssets/Addressables_Temp*
-# By default, Addressables content builds will generate addressables_content_state.bin
-# files in platform-specific subfolders, for example:
-# /Assets/AddressableAssetsData/OSX/addressables_content_state.bin
/[Aa]ssets/AddressableAssetsData/*/*.bin*
-
-# Visual Scripting auto-generated files
-/[Aa]ssets/Unity.VisualScripting.Generated/VisualScripting.Flow/UnitOptions.db
-/[Aa]ssets/Unity.VisualScripting.Generated/VisualScripting.Flow/UnitOptions.db.meta
-/[Aa]ssets/Unity.VisualScripting.Generated/VisualScripting.Core/Property Providers
-/[Aa]ssets/Unity.VisualScripting.Generated/VisualScripting.Core/Property Providers.meta
-
-# Auto-generated scenes by play mode tests
+/[Aa]ssets/VisualScripting.Generated/
/[Aa]ssets/[Ii]nit[Tt]est[Ss]cene*.unity*
-
-# Auto-generated cache in Assets folder
/[Aa]ssets/[Ss]ceneDependencyCache*
+
+# .NET build and test artifacts
+bin/
+obj/
+TestResults/
+artifacts/
+
+# OS files
+.DS_Store
diff --git a/Documentation~/MajdataPlay.md b/Documentation~/MajdataPlay.md
new file mode 100644
index 0000000..b03ee1e
--- /dev/null
+++ b/Documentation~/MajdataPlay.md
@@ -0,0 +1,9 @@
+# MajdataPlay integration
+
+MajdataPlay should pin this repository as `Assets/Plugins/MajRadar` and keep its
+existing `Assets/Plugins/MajSimai` pin. `MajRadar.asmdef` references that one
+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.
diff --git a/MajRadar.csproj b/MajRadar.csproj
new file mode 100644
index 0000000..ed9aac4
--- /dev/null
+++ b/MajRadar.csproj
@@ -0,0 +1,32 @@
+
+
+ netstandard2.1
+ latest
+ enable
+ enable
+ false
+ MajRadar
+ MajRadar
+ TeamMajdata.MajRadar
+ 0.1.0
+ Team Majdata
+ Radar analysis and constant estimation for Simai charts.
+ GPL-3.0-only
+ README.md
+ https://github.com/TeamMajdata/MajRadar
+ git
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MajRadar.slnx b/MajRadar.slnx
new file mode 100644
index 0000000..b9701de
--- /dev/null
+++ b/MajRadar.slnx
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/README.md b/README.md
index eee5297..f374f56 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,298 @@
# MajRadar
-A radar analyser and constant estimationfor module for simai charts, inspired by BEMANI series games
+
+[中文](#中文) | [English](#english)
+
+
+
+## 中文
+
+MajRadar 是一个独立的的Simai谱面雷达分析和拟合定数估计组件。通过分析`SimaiChart`格式的已解析谱面计算固定七维特征产生雷达轴和拟合定数。
+
+### Runtime 生命周期
+
+建议由宿主的雷达服务创建一个 `RadarRuntime`,并在应用生命周期内跨选歌复用。
+`RadarRuntime` 不缓存谱面或结果,也不保留单次分析状态。它是普通对象而不是全局
+静态单例,便于测试和其他宿主注入不同的扩展 Slide 实现。为了避免代码重复,MajSimai的拓展`Slidecode`星星的长度计算交给Play内部的组件负责,因而需要依赖注入。
+此Provider将同步到Play仓库里。
+```csharp
+private readonly RadarRuntime _runtime =
+ new(new PlayExtendedSlideBarCountProvider());
+```
+
+满足以下条件时,同一 Runtime 可以并发分析多张谱面:
+
+- 注入的 `IExtendedSlideBarCountProvider` 无状态且线程安全;
+- 分析期间调用方不修改传入的 `SimaiChart` 或 `RadarChartInput`。
+
+`AnalyzeAsync` 在线程池运行同步分析。与它一起使用的 provider 不能访问仅限 Unity
+主线程的对象。Play provider 每次创建局部 Slide 路径,并且只调用纯几何计算,符合此约束。
+
+### 调用 Runtime
+
+已有 MajSimai 解析结果时,优先复用现成的谱面:
+
+```csharp
+RadarResult result = runtime.Analyze(existingSimaiChart, cancellationToken);
+```
+
+需要保持 Unity 主线程响应时,使用异步包装:
+
+```csharp
+RadarResult result = await _runtime.AnalyzeAsync(
+ existingSimaiChart,
+ cancellationToken);
+```
+
+只有一段 inote 文本时,可以使用:
+
+```csharp
+RadarResult result = await _runtime.ParseAndAnalyzeAsync(
+ inote,
+ cancellationToken);
+```
+
+### Slidecode 依赖
+
+扩展Slidecode的长度算法需要依赖Play自身的Parser。
+
+```csharp
+internal sealed class PlayExtendedSlideBarCountProvider
+ : IExtendedSlideBarCountProvider
+{
+ public int ResolveBarCount(string slideCode)
+ {
+ var path = SlideCodeParser.Parse(slideCode);
+ return SlideDataBuilder.BuildArrowData(path).Length - 2;
+ }
+}
+```
+provider只接受SlideCode,例如 `1P6K7`,并且必须返回正数 arrow/bar count。
+provider 抛出的异常或非正结果会变成结构化适配失败。未提供 provider 时,普通谱面仍可
+正常分析;遇到 `K` Slide 会返回错误
+
+### 取消与选歌状态
+
+取消采用合作式机制,并作为结果数据返回:
+
+```csharp
+if (result.IsCancelled)
+ return;
+```
+
+- `AnalyzeAsync`会在下一个检查点返回
+ `Status == "cancelled"` 的 `RadarResult`。
+- MajSimai 没有提供解析中途取消,因此 `ParseAndAnalyzeAsync` 只能parser 返回后立即再次检查。
+- 同步provider应调用中途不会被取消。
+
+应为当前选歌持有一个 `CancellationTokenSource`。歌曲或难度变化时,取消上一份
+token、启动新分析,并丢弃过期的结果。
+
+### 结果处理方式
+
+```csharp
+if (result.IsSuccess)
+{
+ foreach (var dimension in result.DimensionOrder)
+ Render(dimension, result.Scores[dimension]);
+}
+else if (!result.IsCancelled)
+{
+ LogErrors(result.Errors);
+}
+```
+
+- `Analysis.Features` 包含模型使用的固定七维,包括内部维度 `slide_cumulate`。
+- `RawValues` 和 `Scores` 始终保持公开形状:六个雷达轴加
+ `fitted_constant`;不可用项为 `null`。
+- 只有七维 raw 全部成功时才生成 `FittedConstant` 和映射后的 score。
+- `partial` 会保留已完成的特征,但不会生成拟合定数。
+### MajdataPlay 源码接入
+
+MajdataPlay 应在现有 MajSimai submodule 旁固定 MajRadar submodule。Unity 通过
+`MajRadar.asmdef` 编译 `Runtime/`,该程序集引用项目内唯一的 `MajSimai` 程序集。
+同一个 Unity 项目中不要再安装 MajRadar NuGet 包。
+
+### .NET 与 NuGet
+
+```sh
+dotnet test MajRadar.slnx
+dotnet pack MajRadar.csproj -c Release
+```
+
+NuGet包以 `netstandard2.1` 为目标,包含MajSimai包依赖。Pull Request 会执行
+构建与测试;main在push发布prerelease,在v标签发布正式版。
+兼容性测试可以直接引用与Play一致的 MajSimai 源码项目:
+
+```sh
+dotnet test MajRadar.slnx \
+ -p:MajSimaiProject=/absolute/path/to/MajSimai.csproj
+```
+
+
+---
+
+
+
+## English
+
+MajRadar is a Unity-free radar analyser and fitted-constant estimator for Simai
+charts. It consumes MajSimai's typed `SimaiChart`, preserves chart-relative
+timing, computes seven fixed raw features, applies the frozen regression model,
+and maps the public radar axes.
+
+### Runtime lifetime
+
+Create one `RadarRuntime` for the lifetime of the host radar service and reuse
+it across chart selections. `RadarRuntime` does not cache charts or results and
+does not retain per-analysis state. It is intentionally a normal object rather
+than a global singleton so tests and other hosts can supply different extended
+Slide implementations.
+
+```csharp
+private readonly RadarRuntime _runtime =
+ new(new PlayExtendedSlideBarCountProvider());
+```
+
+The same runtime may analyze multiple charts concurrently when both conditions
+below hold:
+
+- the injected `IExtendedSlideBarCountProvider` is stateless and thread-safe;
+- callers do not mutate a supplied `SimaiChart` or `RadarChartInput` while it is
+ being analyzed.
+
+`AnalyzeAsync` executes the synchronous analysis on the thread pool. A provider
+used with it must not access Unity main-thread-only objects. Play's provider is
+suitable because it creates a local Slide path and uses only pure geometry.
+
+### Calling the runtime
+
+Reuse an existing chart parsed by MajSimai whenever possible:
+
+```csharp
+RadarResult result = runtime.Analyze(existingSimaiChart, cancellationToken);
+```
+
+Use the asynchronous wrapper when the caller must keep the Unity main thread
+responsive:
+
+```csharp
+RadarResult result = await _runtime.AnalyzeAsync(
+ existingSimaiChart,
+ cancellationToken);
+```
+
+Standalone callers that only have one inote string may use:
+
+```csharp
+RadarResult result = await _runtime.ParseAndAnalyzeAsync(
+ inote,
+ cancellationToken);
+```
+
+`ParseAndAnalyzeAsync` is a convenience boundary, not a song loader. The caller
+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:
+
+```csharp
+internal sealed class PlayExtendedSlideBarCountProvider
+ : IExtendedSlideBarCountProvider
+{
+ public int ResolveBarCount(string slideCode)
+ {
+ var path = SlideCodeParser.Parse(slideCode);
+ return SlideDataBuilder.BuildArrowData(path).Length - 2;
+ }
+}
+```
+
+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.
+
+Ordinary Slides, feature parameters, regression coefficients, and score mapping
+are built in and are not dependency-injected.
+
+### Cancellation and selection ownership
+
+Cancellation is cooperative and returned as data:
+
+```csharp
+if (result.IsCancelled)
+ return;
+```
+
+- `Analyze` checks the token during adaptation, between feature dimensions, and
+ throughout the high-complexity Sweep candidate, family, hand-motion, and
+ selection loops.
+- Other dimensions check at their feature or bounded section boundaries.
+- `AnalyzeAsync` does not forcibly abort its worker thread; it returns a
+ `RadarResult` with `Status == "cancelled"` after the next observation point.
+- MajSimai does not expose cancellation for an in-progress parse. Therefore
+ `ParseAndAnalyzeAsync` checks immediately before parsing and again when the
+ parser returns.
+- The synchronous provider method is expected to be short and bounded; it is not
+ interrupted in the middle of a call.
+
+The host owns one `CancellationTokenSource` per current selection. On a song or
+difficulty change, cancel the previous source, start a new analysis, and discard
+any result whose selection generation is no longer current. Cancellation alone
+does not replace the generation check because a completed older task may race a
+new selection.
+
+### Result contract
+
+```csharp
+if (result.IsSuccess)
+{
+ foreach (var dimension in result.DimensionOrder)
+ Render(dimension, result.Scores[dimension]);
+}
+else if (!result.IsCancelled)
+{
+ LogErrors(result.Errors);
+}
+```
+
+- `Analysis.Features` contains all seven fixed model inputs, including internal
+ `slide_cumulate`.
+- `RawValues` and `Scores` always use the shape-stable public order: six radar
+ axes plus `fitted_constant`; unavailable entries are `null`.
+- `FittedConstant` and mapped scores are produced only when all seven raw
+ features succeed.
+- `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
+ scale.
+
+### MajdataPlay source integration
+
+MajdataPlay should pin MajRadar as a Git submodule beside its existing MajSimai
+submodule. Unity compiles `Runtime/` through `MajRadar.asmdef`, which references
+the single existing `MajSimai` assembly. Do not install the MajRadar NuGet package
+into the same Unity project.
+
+### .NET and NuGet
+
+```sh
+dotnet test MajRadar.slnx
+dotnet pack MajRadar.csproj -c Release
+```
+
+The NuGet package targets `netstandard2.1` and declares one MajSimai package
+dependency. Pull requests build and test; main-branch pushes publish unique CI
+prereleases, while `v*` tags publish stable versions.
+
+Compatibility tests can compile against a source checkout matching Play's pin:
+
+```sh
+dotnet test MajRadar.slnx \
+ -p:MajSimaiProject=/absolute/path/to/MajSimai.csproj
+```
+
+Song discovery, metadata, audio offsets, visualizers, training, and experimental
+pipelines intentionally remain outside this runtime component.
diff --git a/Runtime/Analysis/AnalysisResult.cs b/Runtime/Analysis/AnalysisResult.cs
new file mode 100644
index 0000000..2bec917
--- /dev/null
+++ b/Runtime/Analysis/AnalysisResult.cs
@@ -0,0 +1,99 @@
+using MajRadar.Core;
+using System.Threading;
+
+namespace MajRadar.Analysis;
+
+public static class RadarFeatureNames
+{
+ public const string Note = "note";
+ public const string Peak = "peak";
+ public const string Sweep = "sweep";
+ public const string SlideTricky = "slide_tricky";
+ public const string SlideSequence = "slide_sequence";
+ public const string Jack = "jack";
+ public const string SlideCumulate = "slide_cumulate";
+ public const string FittedConstant = "fitted_constant";
+
+ // Fixed regression input columns. These indexes also align Center, Scale and
+ // the first seven linear coefficients in RegressionBetaParameters:
+ // 0 Note, 1 Peak, 2 Sweep, 3 SlideTricky,
+ // 4 SlideSequence, 5 Jack, 6 SlideCumulate.
+ // This is not a UI-axis setting. Changing a name or its position requires
+ // retraining the model and replacing all fitted constants together.
+ public static readonly IReadOnlyList ModelInputOrder = Array.AsReadOnly(new[]
+ {
+ Note, Peak, Sweep, SlideTricky, SlideSequence, Jack, SlideCumulate
+ });
+
+ // Stable enumeration order for callers that expose every scored scalar.
+ // A UI may display a subset, but model input always remains ModelInputOrder.
+ public static readonly IReadOnlyList ScoredOutputOrder = Array.AsReadOnly(new[]
+ {
+ Note, Peak, Sweep, SlideTricky, SlideSequence, Jack, SlideCumulate,
+ FittedConstant
+ });
+}
+
+public sealed class RadarFeatureResult
+{
+ public double? Value { get; set; }
+ public bool IsSuccess { get; set; }
+ public string? Error { get; set; }
+
+ public static RadarFeatureResult Success(double value) => new()
+ {
+ Value = value,
+ IsSuccess = true
+ };
+
+ public static RadarFeatureResult Failure(string error) => new()
+ {
+ IsSuccess = false,
+ Error = error
+ };
+}
+
+public sealed class RadarAnalysisResult
+{
+ public IReadOnlyDictionary Features { get; set; } =
+ new Dictionary();
+
+ public bool IsCancelled { get; set; }
+
+ public bool IsSuccess => Features.Count == RadarFeatureNames.ModelInputOrder.Count &&
+ Features.Values.All(result => result.IsSuccess) && !IsCancelled;
+
+ public string Status
+ {
+ get
+ {
+ if (IsCancelled) return "cancelled";
+ var successful = Features.Values.Count(result => result.IsSuccess);
+ if (successful == Features.Count && Features.Count > 0) return "ok";
+ return successful > 0 ? "partial" : "error";
+ }
+ }
+}
+
+internal readonly struct AnalysisContext
+{
+ internal AnalysisContext(RadarChartInput chart, CancellationToken cancellationToken)
+ {
+ Events = chart.Events;
+ ChartEndTimeSeconds = chart.ChartEndTimeSeconds;
+ LastEventEndTimeSeconds = chart.LastEventEndTimeSeconds;
+ CancellationToken = cancellationToken;
+ }
+
+ internal IReadOnlyList Events { get; }
+ internal double ChartEndTimeSeconds { get; }
+ internal double? LastEventEndTimeSeconds { get; }
+ internal CancellationToken CancellationToken { get; }
+ internal double DurationSeconds => Math.Max(ChartEndTimeSeconds, LastEventEndTimeSeconds ?? 0);
+ internal void ThrowIfCancellationRequested() => CancellationToken.ThrowIfCancellationRequested();
+}
+
+internal interface IRadarFeatureAnalyzer
+{
+ RadarFeatureResult Analyze(AnalysisContext context);
+}
diff --git a/Runtime/Analysis/Features/JackSequenceAnalyzer.cs b/Runtime/Analysis/Features/JackSequenceAnalyzer.cs
new file mode 100644
index 0000000..b53ef00
--- /dev/null
+++ b/Runtime/Analysis/Features/JackSequenceAnalyzer.cs
@@ -0,0 +1,141 @@
+using MajRadar.Core;
+
+namespace MajRadar.Analysis.Features;
+
+internal sealed class JackSequenceAnalyzer : IRadarFeatureAnalyzer
+{
+ private const int TopK = 5;
+ private const int MaxInterruptingTaps = 4;
+ private const double InterruptingTapWeight = 1.5;
+ private const double ExNoteWeight = 0.3;
+ private const double SpeedReferenceSixteenthBpm = 180;
+ private const double SpeedExponent = 1.5;
+ private const double TopOneMultiplier = 1.3;
+ private const double TopRankDecay = 0.645;
+
+ private sealed class Batch
+ {
+ internal BeatPosition Beat { get; set; }
+ internal double TimeSeconds { get; set; }
+ internal IReadOnlyList Events { get; set; } = Array.Empty();
+ }
+
+ private sealed class Sequence
+ {
+ internal string Position { get; set; } = string.Empty;
+ internal BeatPosition StartBeat { get; set; }
+ internal int AnchorCount { get; set; }
+ internal int ExAnchorCount { get; set; }
+ internal int Interruptions { get; set; }
+ internal int ExInterruptions { get; set; }
+ internal double SpeedFactor { get; set; }
+ internal double Strength => AnchorCount - ExAnchorCount + ExNoteWeight * ExAnchorCount +
+ InterruptingTapWeight * (Interruptions - ExInterruptions + ExNoteWeight * ExInterruptions);
+ internal double WeightedStrength => Strength * SpeedFactor;
+ }
+
+ public RadarFeatureResult Analyze(AnalysisContext context)
+ {
+ context.ThrowIfCancellationRequested();
+ if (context.DurationSeconds <= 0) return RadarFeatureResult.Failure("Chart duration must be positive.");
+ var sequences = Sequences(context.Events);
+ var score = sequences.Take(TopK).Select((item, index) =>
+ item.WeightedStrength * TopOneMultiplier * Math.Pow(TopRankDecay, index)).Sum();
+ return RadarFeatureResult.Success(score);
+ }
+
+ private static IReadOnlyList Sequences(IReadOnlyList events)
+ {
+ var batches = events
+ .Where(item => item.Kind is RadarEventKind.Tap or RadarEventKind.Hold)
+ .GroupBy(item => item.StartBeat)
+ .OrderBy(group => group.Key)
+ .Select(group => new Batch
+ {
+ Beat = group.Key,
+ TimeSeconds = group.Min(item => item.StartTimeSeconds),
+ Events = group.OrderBy(item => item.EventId).ToArray()
+ }).ToArray();
+ var output = new List();
+ for (var position = 1; position <= 8; position++)
+ output.AddRange(PositionSequences(batches, position.ToString()));
+ return output.OrderByDescending(item => item.WeightedStrength)
+ .ThenByDescending(item => item.Strength)
+ .ThenByDescending(item => item.AnchorCount)
+ .ThenBy(item => item.StartBeat)
+ .ThenBy(item => int.Parse(item.Position)).ToArray();
+ }
+
+ private static IReadOnlyList PositionSequences(IReadOnlyList batches, string position)
+ {
+ var output = new List();
+ BeatPosition? startBeat = null, previousBeat = null, pendingStartBeat = null;
+ double? startTime = null, endTime = null;
+ var anchorCount = 0; var exAnchorCount = 0; var anchorTimeCount = 0;
+ var anchorsSinceInterruption = 0; var pending = 0; var pendingEx = 0;
+ var committed = 0; var committedEx = 0;
+
+ void Finish()
+ {
+ if (anchorTimeCount >= 2)
+ {
+ var span = endTime!.Value - startTime!.Value;
+ if (span <= 0) throw new InvalidOperationException("Jack sequence must advance in time.");
+ var bpm = 15 * (anchorTimeCount - 1) / span;
+ output.Add(new Sequence
+ {
+ Position = position,
+ StartBeat = startBeat!.Value,
+ AnchorCount = anchorCount,
+ ExAnchorCount = exAnchorCount,
+ Interruptions = committed,
+ ExInterruptions = committedEx,
+ SpeedFactor = Math.Pow(bpm / SpeedReferenceSixteenthBpm, SpeedExponent)
+ });
+ }
+ startBeat = previousBeat = pendingStartBeat = null;
+ startTime = endTime = null;
+ anchorCount = exAnchorCount = anchorTimeCount = anchorsSinceInterruption = 0;
+ pending = pendingEx = committed = committedEx = 0;
+ }
+
+ foreach (var batch in batches)
+ {
+ var anchors = batch.Events.Where(item => item.Position == position).ToArray();
+ if (previousBeat is not null && batch.Beat - previousBeat.Value > Workload.EighthBeat) Finish();
+ if (anchors.Length > 0)
+ {
+ var returned = pendingStartBeat is not null;
+ if (returned && batch.Beat - pendingStartBeat!.Value > Workload.EighthBeat)
+ {
+ Finish(); returned = false;
+ }
+ else if (returned)
+ {
+ committed += pending; committedEx += pendingEx;
+ }
+ if (startBeat is null) { startBeat = batch.Beat; startTime = batch.TimeSeconds; }
+ endTime = batch.TimeSeconds;
+ anchorCount += anchors.Length;
+ exAnchorCount += anchors.Count(item => item.IsEx == true);
+ anchorTimeCount++;
+ anchorsSinceInterruption = returned ? 1 : anchorsSinceInterruption + 1;
+ pending = pendingEx = 0; pendingStartBeat = null; previousBeat = batch.Beat;
+ continue;
+ }
+ if (startBeat is null) continue;
+ if (batch.Events.Any(item => item.Kind != RadarEventKind.Tap) ||
+ pendingStartBeat is null && anchorsSinceInterruption < 2 ||
+ pending + batch.Events.Count > MaxInterruptingTaps)
+ {
+ Finish(); continue;
+ }
+ pendingStartBeat ??= batch.Beat;
+ pending += batch.Events.Count;
+ pendingEx += batch.Events.Count(item => item.IsEx == true);
+ previousBeat = batch.Beat;
+ }
+ Finish();
+ return output;
+ }
+}
diff --git a/Runtime/Analysis/Features/NoteDensityAnalyzer.cs b/Runtime/Analysis/Features/NoteDensityAnalyzer.cs
new file mode 100644
index 0000000..0ea82e8
--- /dev/null
+++ b/Runtime/Analysis/Features/NoteDensityAnalyzer.cs
@@ -0,0 +1,31 @@
+namespace MajRadar.Analysis.Features;
+
+internal sealed class NoteDensityAnalyzer : IRadarFeatureAnalyzer
+{
+ private const double BurstWeight = 0.3;
+
+ public RadarFeatureResult Analyze(AnalysisContext context)
+ {
+ context.ThrowIfCancellationRequested();
+ var duration = context.DurationSeconds;
+ if (duration <= 0) return RadarFeatureResult.Failure("Chart duration must be positive.");
+ var ratio = duration / Workload.WindowSeconds;
+ var nearest = Math.Round(ratio);
+ if (Math.Abs(ratio - nearest) <= 1e-12) ratio = nearest;
+ var count = Math.Max(1, (int)Math.Ceiling(ratio));
+ var workload = new double[count];
+ foreach (var point in Workload.CorrectedPoints(context.Events))
+ {
+ context.ThrowIfCancellationRequested();
+ var index = Math.Min(
+ (int)Math.Floor((point.TimeSeconds + 1e-9) / Workload.WindowSeconds),
+ count - 1);
+ workload[index] += point.Weight;
+ }
+ var densities = workload.Select(value => value / Workload.WindowSeconds).ToArray();
+ var mean = densities.Sum() / count;
+ if (mean == 0) return RadarFeatureResult.Success(0);
+ var variance = densities.Sum(value => (value - mean) * (value - mean)) / count;
+ return RadarFeatureResult.Success(mean * (1 + BurstWeight * Math.Sqrt(variance) / mean));
+ }
+}
diff --git a/Runtime/Analysis/Features/PeakDensityAnalyzer.cs b/Runtime/Analysis/Features/PeakDensityAnalyzer.cs
new file mode 100644
index 0000000..4d31cf1
--- /dev/null
+++ b/Runtime/Analysis/Features/PeakDensityAnalyzer.cs
@@ -0,0 +1,156 @@
+using MajRadar.Core;
+
+namespace MajRadar.Analysis.Features;
+
+internal sealed class PeakDensityAnalyzer : IRadarFeatureAnalyzer
+{
+ private const double StepSeconds = 0.5;
+ private const double LeftWeight = 0.2;
+ private const double CenterWeight = 0.6;
+ private const double RightWeight = 0.2;
+ private const double TouchGroupWeight = 0.5;
+ private const int PeakCount = 3;
+ private static readonly double[] PeakWeights = { 0.5, 0.3, 0.2 };
+ private const double NeighborhoodSeconds = 3 * Workload.WindowSeconds;
+
+ private sealed class ButtonOnset
+ {
+ internal double TimeSeconds { get; set; }
+ internal BeatPosition Beat { get; set; }
+ internal int Position { get; set; }
+ internal double Weight { get; set; }
+ }
+
+ public RadarFeatureResult Analyze(AnalysisContext context)
+ {
+ context.ThrowIfCancellationRequested();
+ if (context.DurationSeconds <= 0) return RadarFeatureResult.Failure("Chart duration must be positive.");
+ return RadarFeatureResult.Success(PeakDensity(Points(context.Events), context.DurationSeconds));
+ }
+
+ private static IReadOnlyList Points(IReadOnlyList events)
+ {
+ var buttons = new List();
+ var slides = new List();
+ var touches = new List();
+ foreach (var item in events)
+ {
+ if (item.Kind is RadarEventKind.Tap or RadarEventKind.Hold)
+ {
+ var weight = item.Kind == RadarEventKind.Hold &&
+ Workload.BeatDuration(item) > Workload.EighthBeat ? 2 : 1;
+ buttons.Add(new ButtonOnset
+ {
+ TimeSeconds = item.StartTimeSeconds,
+ Beat = item.StartBeat,
+ Position = int.Parse(item.Position!),
+ Weight = weight
+ });
+ }
+ else if (item.Kind == RadarEventKind.Slide) slides.Add(item);
+ else if (item.Kind is RadarEventKind.Touch or RadarEventKind.TouchHold) touches.Add(item);
+ }
+ var output = DecaySweepButtons(buttons).ToList();
+ output.AddRange(Workload.SlidePoints(slides, null));
+ output.AddRange(Workload.TouchPoints(touches, TouchGroupWeight));
+ return output;
+ }
+
+ private static IReadOnlyList DecaySweepButtons(IReadOnlyList buttons)
+ {
+ var byBeat = buttons.GroupBy(item => item.Beat).OrderBy(group => group.Key);
+ var output = new List();
+ var runLength = 0;
+ ButtonOnset? previous = null;
+ BeatPosition? interval = null;
+ int? direction = null;
+ foreach (var group in byBeat)
+ {
+ var simultaneous = group.ToArray();
+ if (simultaneous.Length != 1)
+ {
+ output.AddRange(simultaneous.Select(item => new Workload.Point(item.TimeSeconds, item.Weight)));
+ runLength = 0; previous = null; interval = null; direction = null;
+ continue;
+ }
+ var current = simultaneous[0];
+ var currentDirection = previous is null ? null : Direction(previous.Position, current.Position);
+ BeatPosition? currentInterval = previous is null ? null : current.Beat - previous.Beat;
+ var continues = previous is not null && currentDirection is not null &&
+ currentInterval > BeatPosition.Zero &&
+ (runLength == 1 || currentDirection == direction && currentInterval == interval);
+ if (continues)
+ {
+ runLength++;
+ if (runLength == 2) { direction = currentDirection; interval = currentInterval; }
+ }
+ else if (previous is not null && currentDirection is not null && currentInterval > BeatPosition.Zero)
+ {
+ runLength = 2; direction = currentDirection; interval = currentInterval;
+ }
+ else
+ {
+ runLength = 1; direction = null; interval = null;
+ }
+ var divisor = Math.Log(Math.Max(2, runLength - 2), 2);
+ output.Add(new Workload.Point(current.TimeSeconds, current.Weight / divisor));
+ previous = current;
+ }
+ return output;
+ }
+
+ private static int? Direction(int left, int right)
+ {
+ if (right == left % 8 + 1) return 1;
+ if (right == (left - 2 + 8) % 8 + 1) return -1;
+ return null;
+ }
+
+ private static double PeakDensity(IReadOnlyList source, double duration)
+ {
+ if (source.Count == 0) return 0;
+ var points = source.OrderBy(item => item.TimeSeconds).ThenBy(item => item.Weight).ToArray();
+ var times = points.Select(item => item.TimeSeconds).ToArray();
+ var prefix = new double[points.Length + 1];
+ for (var index = 0; index < points.Length; index++) prefix[index + 1] = prefix[index] + points[index].Weight;
+ double Density(double start)
+ {
+ var begin = LowerBound(times, start - 1e-9);
+ var end = LowerBound(times, start + Workload.WindowSeconds - 1e-9);
+ return (prefix[end] - prefix[begin]) / Workload.WindowSeconds;
+ }
+ var ratio = duration / StepSeconds;
+ var nearest = Math.Round(ratio);
+ if (Math.Abs(ratio - nearest) <= 1e-12) ratio = nearest;
+ var candidates = Enumerable.Range(0, (int)Math.Floor(ratio) + 1)
+ .Select(index =>
+ {
+ var start = index * StepSeconds;
+ var score = LeftWeight * Density(start - Workload.WindowSeconds) +
+ CenterWeight * Density(start) +
+ RightWeight * Density(start + Workload.WindowSeconds);
+ return (Score: score, Start: start);
+ })
+ .OrderByDescending(item => item.Score).ThenBy(item => item.Start);
+ var selected = new List<(double Score, double Start)>();
+ foreach (var candidate in candidates)
+ {
+ if (selected.All(item => Math.Abs(candidate.Start - item.Start) >= NeighborhoodSeconds))
+ selected.Add(candidate);
+ if (selected.Count == PeakCount) break;
+ }
+ return selected.Select((item, index) => item.Score * PeakWeights[index]).Sum();
+ }
+
+ private static int LowerBound(double[] values, double target)
+ {
+ var left = 0; var right = values.Length;
+ while (left < right)
+ {
+ var middle = left + (right - left) / 2;
+ if (values[middle] < target) left = middle + 1;
+ else right = middle;
+ }
+ return left;
+ }
+}
diff --git a/Runtime/Analysis/Features/SlideCumulateAnalyzer.cs b/Runtime/Analysis/Features/SlideCumulateAnalyzer.cs
new file mode 100644
index 0000000..35e9bdb
--- /dev/null
+++ b/Runtime/Analysis/Features/SlideCumulateAnalyzer.cs
@@ -0,0 +1,221 @@
+using MajRadar.Core;
+
+namespace MajRadar.Analysis.Features;
+
+internal sealed class SlideCumulateAnalyzer : IRadarFeatureAnalyzer
+{
+ private const double Alpha = 0.35;
+ private const double ReferenceSeconds = 0.5;
+ private const double SimultaneousSeconds = 1.0 / 60;
+ private const int EffectiveLengthBase = 5;
+ private const double TouchWeight = 1.5;
+ private const double Tolerance = 1e-9;
+
+ private sealed class SlideGroup
+ {
+ internal int Key { get; set; }
+ internal IReadOnlyList Events { get; set; } = Array.Empty();
+ internal double DeclarationTime { get; set; }
+ internal BeatPosition DeclarationBeat { get; set; }
+ internal double LaunchTime { get; set; }
+ internal int? HeadEventId { get; set; }
+ }
+
+ private sealed class Onset
+ {
+ internal double DeclarationTime { get; set; }
+ internal BeatPosition DeclarationBeat { get; set; }
+ internal IReadOnlyList Groups { get; set; } = Array.Empty();
+ }
+
+ private sealed class Point
+ {
+ internal string Id { get; set; } = string.Empty;
+ internal double Time { get; set; }
+ internal double Weight { get; set; }
+ internal int? OwnerGroup { get; set; }
+ }
+
+ public RadarFeatureResult Analyze(AnalysisContext context)
+ {
+ context.ThrowIfCancellationRequested();
+ var duration = context.DurationSeconds;
+ if (duration <= 0) return RadarFeatureResult.Failure("Chart duration must be positive.");
+ var groups = Groups(context.Events);
+ if (groups.Count == 0) return RadarFeatureResult.Success(0);
+ var onsets = Onsets(groups);
+ var assigned = Assign(onsets, Points(context.Events, groups));
+ var totalLoad = 0.0;
+ foreach (var section in Sections(onsets))
+ {
+ context.ThrowIfCancellationRequested();
+ var loads = section.Select(onset => OnsetLoad(onset, assigned[onsets.IndexOf(onset)])).ToArray();
+ var mean = loads.Sum() / loads.Length;
+ var rms = Math.Sqrt(loads.Sum(load => load * load) / loads.Length);
+ var intensity = 0.8 * mean + 0.2 * rms;
+ totalLoad += EffectiveLength(loads.Length) * intensity;
+ }
+ return RadarFeatureResult.Success(totalLoad / (duration / ReferenceSeconds));
+ }
+
+ private static List Groups(IReadOnlyList events)
+ {
+ var output = new List();
+ foreach (var grouping in events.Where(item =>
+ item.Kind == RadarEventKind.Slide && item.SlideGroupId is not null)
+ .GroupBy(item => item.SlideGroupId!.Value))
+ {
+ var paths = grouping.ToArray();
+ var declarationTimes = paths.Select(item => item.SlideDeclareTimeSeconds).Distinct().ToArray();
+ var declarationBeats = paths.Select(item => item.SlideDeclareBeat).Distinct().ToArray();
+ var headIds = paths.Select(item => item.HeadEventId).Distinct().ToArray();
+ if (declarationTimes.Length != 1 || declarationTimes[0] is null ||
+ declarationBeats.Length != 1 || declarationBeats[0] is null || headIds.Length != 1)
+ throw new InvalidOperationException("Slide group declaration metadata is inconsistent.");
+ var valid = paths.Where(item => item.EndTimeSeconds > item.StartTimeSeconds).ToArray();
+ if (valid.Length == 0) continue;
+ output.Add(new SlideGroup
+ {
+ Key = grouping.Key,
+ Events = valid,
+ DeclarationTime = declarationTimes[0]!.Value,
+ DeclarationBeat = declarationBeats[0]!.Value,
+ LaunchTime = valid.Max(item => item.StartTimeSeconds),
+ HeadEventId = headIds[0]
+ });
+ }
+ return output.OrderBy(item => item.DeclarationBeat)
+ .ThenBy(item => item.DeclarationTime).ThenBy(item => item.Key).ToList();
+ }
+
+ private static List Onsets(IReadOnlyList groups)
+ {
+ var buckets = new List>();
+ foreach (var group in groups)
+ {
+ if (buckets.Count == 0 ||
+ group.DeclarationTime - buckets[^1][0].DeclarationTime > SimultaneousSeconds + Tolerance)
+ buckets.Add(new List());
+ buckets[^1].Add(group);
+ }
+ return buckets.Select(bucket => new Onset
+ {
+ DeclarationTime = bucket[0].DeclarationTime,
+ DeclarationBeat = bucket[0].DeclarationBeat,
+ Groups = bucket
+ }).ToList();
+ }
+
+ private static IReadOnlyList> Sections(IReadOnlyList onsets)
+ {
+ var output = new List>();
+ var current = new List { onsets[0] };
+ foreach (var onset in onsets.Skip(1))
+ {
+ var delta = (onset.DeclarationBeat - current[^1].DeclarationBeat).ToDouble();
+ if (delta > Tolerance && delta <= 1 + Tolerance) current.Add(onset);
+ else { output.Add(current); current = new List { onset }; }
+ }
+ output.Add(current);
+ return output;
+ }
+
+ private static List Points(IReadOnlyList events, IReadOnlyList groups)
+ {
+ var headOwners = groups.Where(item => item.HeadEventId is not null)
+ .ToDictionary(item => item.HeadEventId!.Value, item => item.Key);
+ var output = events.Where(item => item.Kind is RadarEventKind.Tap or RadarEventKind.Hold)
+ .Select(item => new Point
+ {
+ Id = $"event:{item.EventId}", Time = item.StartTimeSeconds, Weight = 1,
+ OwnerGroup = headOwners.TryGetValue(item.EventId, out var owner) ? owner : null
+ }).ToList();
+ var touches = events.Where(item => item.Kind is RadarEventKind.Touch or RadarEventKind.TouchHold).ToArray();
+ var touchIndex = 0;
+ output.AddRange(Workload.SimultaneousTouchPoints(touches, TouchWeight).Select(item => new Point
+ {
+ Id = $"touch:{touchIndex++}", Time = item.TimeSeconds, Weight = item.Weight
+ }));
+ foreach (var group in groups)
+ {
+ var index = 0;
+ foreach (var launch in group.Events.Select(item => item.StartTimeSeconds).Distinct().OrderBy(value => value))
+ output.Add(new Point
+ {
+ Id = $"slide:{group.Key}:{index++}", Time = launch, Weight = 1, OwnerGroup = group.Key
+ });
+ }
+ return output.OrderBy(item => item.Time).ThenBy(item => item.Id, StringComparer.Ordinal).ToList();
+ }
+
+ private static List> Assign(IReadOnlyList onsets, IReadOnlyList points)
+ {
+ var memberKeys = onsets.SelectMany(item => item.Groups).Select(item => item.Key).ToHashSet();
+ var assigned = Enumerable.Range(0, onsets.Count).Select(_ => new List()).ToList();
+ foreach (var point in points)
+ {
+ if (point.OwnerGroup is int owner && memberKeys.Contains(owner)) continue;
+ var candidates = new List<(double Distance, int Index)>();
+ for (var index = 0; index < onsets.Count; index++)
+ {
+ var pending = onsets[index].Groups.Where(group =>
+ point.Time >= group.DeclarationTime - Tolerance &&
+ point.Time <= group.LaunchTime + Tolerance).ToArray();
+ if (pending.Length > 0)
+ candidates.Add((pending.Min(group => Math.Max(0, group.LaunchTime - point.Time)), index));
+ }
+ if (candidates.Count > 0)
+ {
+ var selected = candidates.OrderBy(item => item.Distance).ThenBy(item => item.Index).First();
+ assigned[selected.Index].Add(point);
+ }
+ }
+ return assigned;
+ }
+
+ private static double OnsetLoad(Onset onset, IReadOnlyList points)
+ {
+ var launchTimes = onset.Groups.Select(item => item.LaunchTime).ToArray();
+ var launchPoints = points.Where(point => launchTimes.Any(time => SameTime(point.Time, time))).ToArray();
+ var launchIds = launchPoints.Select(item => item.Id).ToHashSet();
+ var internalPoints = points.Where(item => !launchIds.Contains(item.Id)).ToArray();
+ var internalLoad = 0.0;
+ var previousCount = 0;
+ foreach (var batch in Batches(internalPoints))
+ {
+ var nextCount = previousCount + batch.Count;
+ var marginal = (InternalTotal(nextCount) - InternalTotal(previousCount)) / batch.Count;
+ internalLoad += marginal * batch.Sum(item => item.Weight);
+ previousCount = nextCount;
+ }
+ return internalLoad + launchPoints.Sum(item => item.Weight);
+ }
+
+ private static IReadOnlyList> Batches(IReadOnlyList points)
+ {
+ var output = new List>();
+ foreach (var point in points)
+ {
+ if (output.Count == 0 || !SameTime(output[^1][0].Time, point.Time))
+ output.Add(new List());
+ ((List)output[^1]).Add(point);
+ }
+ return output;
+ }
+
+ private static double InternalTotal(int count)
+ {
+ var logFactorialBase2 = 0.0;
+ for (var value = 2; value <= count; value++) logFactorialBase2 += Math.Log(value, 2);
+ return count + Alpha * logFactorialBase2;
+ }
+
+ private static double EffectiveLength(int count)
+ {
+ if (count <= EffectiveLengthBase) return count;
+ var scaled = 1 + (double)(count - EffectiveLengthBase) / EffectiveLengthBase;
+ return EffectiveLengthBase * (1 + Math.Log(scaled, EffectiveLengthBase));
+ }
+
+ private static bool SameTime(double left, double right) => Math.Abs(left - right) <= Tolerance;
+}
diff --git a/Runtime/Analysis/Features/SlideSequenceAnalyzer.cs b/Runtime/Analysis/Features/SlideSequenceAnalyzer.cs
new file mode 100644
index 0000000..fd9995e
--- /dev/null
+++ b/Runtime/Analysis/Features/SlideSequenceAnalyzer.cs
@@ -0,0 +1,200 @@
+using MajRadar.Core;
+
+namespace MajRadar.Analysis.Features;
+
+internal sealed class SlideSequenceAnalyzer : IRadarFeatureAnalyzer
+{
+ private const double CadenceReferenceSeconds = 0.5;
+ private const double SimultaneousSeconds = 1.0 / 60;
+ private static readonly BeatPosition MinimumInterval = new(1, 2);
+ private const int LengthBase = 4;
+ private const int LengthKnee = 16;
+ private const double LengthKneeFactor = 3;
+ private const int TopCount = 5;
+ private const double ConcurrencyWeight = 0.5;
+ private const double Tolerance = 1e-9;
+ private static readonly double[] TopWeights = Enumerable.Range(1, TopCount)
+ .Select(rank => 1 / Math.Log(rank + 1, 2)).ToArray();
+
+ private sealed class Group
+ {
+ internal BeatPosition DeclarationBeat { get; set; }
+ internal double DeclarationTime { get; set; }
+ internal int PathCount { get; set; }
+ internal int Key { get; set; }
+ }
+
+ private sealed class Cluster
+ {
+ internal BeatPosition DeclarationBeat { get; set; }
+ internal double DeclarationTime { get; set; }
+ internal IReadOnlyList Groups { get; set; } = Array.Empty();
+ }
+
+ private sealed class PathState
+ {
+ internal IReadOnlyList Indexes { get; set; } = Array.Empty();
+ internal int DirectLinks { get; set; }
+ internal double CadenceSum { get; set; }
+ internal double ConcurrencySum { get; set; }
+ }
+
+ public RadarFeatureResult Analyze(AnalysisContext context)
+ {
+ context.ThrowIfCancellationRequested();
+ if (context.DurationSeconds <= 0) return RadarFeatureResult.Failure("Chart duration must be positive.");
+ var groups = Groups(context.Events);
+ if (groups.Count == 0) return RadarFeatureResult.Success(0);
+ var intensities = Sections(Clusters(groups)).Select(SectionIntensity)
+ .Where(value => value > 0).OrderByDescending(value => value).Take(TopCount).ToArray();
+ var weighted = intensities.Select((value, index) => value * TopWeights[index]).Sum();
+ return RadarFeatureResult.Success(weighted / TopWeights.Sum());
+ }
+
+ private static List Groups(IReadOnlyList events)
+ {
+ var output = new List();
+ foreach (var grouping in events.Where(item =>
+ item.Kind == RadarEventKind.Slide && item.SlideGroupId is not null)
+ .GroupBy(item => item.SlideGroupId!.Value))
+ {
+ var valid = grouping.Where(item => item.EndTimeSeconds > item.StartTimeSeconds).ToArray();
+ if (valid.Length == 0) continue;
+ var times = valid.Select(item => item.SlideDeclareTimeSeconds).Distinct().ToArray();
+ var beats = valid.Select(item => item.SlideDeclareBeat).Distinct().ToArray();
+ if (times.Length != 1 || times[0] is null || beats.Length != 1 || beats[0] is null)
+ throw new InvalidOperationException("Slide group declaration metadata is inconsistent.");
+ output.Add(new Group
+ {
+ Key = grouping.Key,
+ DeclarationTime = times[0]!.Value,
+ DeclarationBeat = beats[0]!.Value,
+ PathCount = valid.Length
+ });
+ }
+ return output.OrderBy(item => item.DeclarationBeat)
+ .ThenBy(item => item.DeclarationTime).ThenBy(item => item.Key).ToList();
+ }
+
+ private static List Clusters(IReadOnlyList groups)
+ {
+ var buckets = new List>();
+ foreach (var group in groups)
+ {
+ if (buckets.Count == 0 ||
+ group.DeclarationTime - buckets[^1][0].DeclarationTime > SimultaneousSeconds + Tolerance)
+ buckets.Add(new List());
+ buckets[^1].Add(group);
+ }
+ return buckets.Select(bucket => new Cluster
+ {
+ DeclarationTime = bucket[0].DeclarationTime,
+ DeclarationBeat = bucket[0].DeclarationBeat,
+ Groups = bucket
+ }).ToList();
+ }
+
+ private static IReadOnlyList> Sections(IReadOnlyList clusters)
+ {
+ var output = new List>();
+ var current = new List { clusters[0] };
+ foreach (var cluster in clusters.Skip(1))
+ {
+ var delta = (cluster.DeclarationBeat - current[^1].DeclarationBeat).ToDouble();
+ if (delta > Tolerance && delta <= 1 + Tolerance) current.Add(cluster);
+ else { output.Add(current); current = new List { cluster }; }
+ }
+ output.Add(current);
+ return output;
+ }
+
+ private static double SectionIntensity(IReadOnlyList section)
+ {
+ var selected = SequenceOnsets(section);
+ var cadence = selected.Count == 1 ? 1 : selected.Zip(selected.Skip(1), (left, right) =>
+ {
+ var interval = right.DeclarationTime - left.DeclarationTime;
+ if (interval <= 0) throw new InvalidOperationException("Distinct Slide onsets must advance in time.");
+ return CadenceReferenceSeconds / interval;
+ }).Average();
+ var concurrency = selected.Average(Concurrency);
+ var continuous = selected.Count >= 2 ? cadence * LengthFactor(selected.Count) : 0;
+ return continuous + ConcurrencyWeight * concurrency;
+ }
+
+ private static IReadOnlyList SequenceOnsets(IReadOnlyList section)
+ {
+ var bestAt = new List>();
+ for (var index = 0; index < section.Count; index++)
+ {
+ var current = new Dictionary
+ {
+ [false] = new PathState
+ {
+ Indexes = new[] { index },
+ ConcurrencySum = Concurrency(section[index])
+ }
+ };
+ for (var previous = index - 1; previous >= 0; previous--)
+ {
+ var gap = section[index].DeclarationBeat - section[previous].DeclarationBeat;
+ if (gap > new BeatPosition(1)) break;
+ if (gap < MinimumInterval) continue;
+ var elapsed = section[index].DeclarationTime - section[previous].DeclarationTime;
+ if (elapsed <= 0) throw new InvalidOperationException("Distinct Slide onsets must advance in time.");
+ foreach (var pair in bestAt[previous])
+ {
+ var direct = previous == index - 1;
+ var candidate = new PathState
+ {
+ Indexes = pair.Value.Indexes.Concat(new[] { index }).ToArray(),
+ DirectLinks = pair.Value.DirectLinks + (direct ? 1 : 0),
+ CadenceSum = pair.Value.CadenceSum + CadenceReferenceSeconds / elapsed,
+ ConcurrencySum = pair.Value.ConcurrencySum + Concurrency(section[index])
+ };
+ var key = pair.Key || direct;
+ if (!current.TryGetValue(key, out var incumbent) || Compare(candidate, incumbent) > 0)
+ current[key] = candidate;
+ }
+ }
+ bestAt.Add(current);
+ }
+ var eligible = bestAt.Where(item => item.ContainsKey(true)).Select(item => item[true]).ToArray();
+ IReadOnlyList indexes;
+ if (eligible.Length > 0)
+ indexes = eligible.Aggregate((best, item) => Compare(item, best) > 0 ? item : best).Indexes;
+ else
+ indexes = new[] { Enumerable.Range(0, section.Count)
+ .OrderByDescending(index => Concurrency(section[index])).ThenBy(index => index).First() };
+ return indexes.Select(index => section[index]).ToArray();
+ }
+
+ private static int Compare(PathState left, PathState right)
+ {
+ var comparisons = new[]
+ {
+ left.Indexes.Count.CompareTo(right.Indexes.Count),
+ left.DirectLinks.CompareTo(right.DirectLinks),
+ left.CadenceSum.CompareTo(right.CadenceSum),
+ left.ConcurrencySum.CompareTo(right.ConcurrencySum)
+ };
+ foreach (var comparison in comparisons) if (comparison != 0) return comparison;
+ for (var index = 0; index < Math.Min(left.Indexes.Count, right.Indexes.Count); index++)
+ {
+ var comparison = right.Indexes[index].CompareTo(left.Indexes[index]);
+ if (comparison != 0) return comparison;
+ }
+ return 0;
+ }
+
+ private static double Concurrency(Cluster cluster) =>
+ Math.Max(0, cluster.Groups.Sum(group => Math.Sqrt(group.PathCount)) - 1);
+
+ private static double LengthFactor(int count)
+ {
+ if (count <= LengthBase) return 1;
+ if (count <= LengthKnee)
+ return 1 + (LengthKneeFactor - 1) * (double)(count - LengthBase) / (LengthKnee - LengthBase);
+ return LengthKneeFactor * Math.Sqrt((double)count / LengthKnee);
+ }
+}
diff --git a/Runtime/Analysis/Features/SlideTrickyAnalyzer.cs b/Runtime/Analysis/Features/SlideTrickyAnalyzer.cs
new file mode 100644
index 0000000..666c070
--- /dev/null
+++ b/Runtime/Analysis/Features/SlideTrickyAnalyzer.cs
@@ -0,0 +1,408 @@
+using MajRadar.Core;
+
+namespace MajRadar.Analysis.Features;
+
+internal sealed class SlideTrickyAnalyzer : IRadarFeatureAnalyzer
+{
+ private const double SimultaneousSeconds = 1.0 / 60;
+ private const int ObjectCap = 16;
+ private const int TopCount = 5;
+ private const double WaitBucketSeconds = 0.05;
+ private const double MaxWaitModeRatio = 4;
+ private const double SpeedReferenceEighthBpm = 180;
+ private const double SpeedExponent = 0.5;
+ private const double SpeedMaxFactor = 1.5;
+ private const double TouchWeight = 1.5;
+ private const int TouchCap = 2;
+ private const double SamePositionMultiplier = 1.5;
+ private const double PendingHeadMultiplier = 2;
+ private const double MultiSlideUplift = 0.15;
+ private const double Tolerance = 1e-9;
+ private static readonly double[] TopWeights = Enumerable.Range(1, TopCount)
+ .Select(rank => 1 / Math.Log(rank + 1, 2)).ToArray();
+
+ private sealed class Group
+ {
+ internal int Key { get; set; }
+ internal IReadOnlyList Events { get; set; } = Array.Empty();
+ internal double DeclarationTime { get; set; }
+ internal BeatPosition DeclarationBeat { get; set; }
+ internal double LaunchTime { get; set; }
+ internal IReadOnlyList LaunchTimes { get; set; } = Array.Empty();
+ internal IReadOnlyList<(double Start, double End)> ActiveIntervals { get; set; } =
+ Array.Empty<(double, double)>();
+ internal int? HeadEventId { get; set; }
+ }
+
+ private sealed class Cluster
+ {
+ internal double DeclarationTime { get; set; }
+ internal BeatPosition DeclarationBeat { get; set; }
+ internal IReadOnlyList Groups { get; set; } = Array.Empty();
+ }
+
+ private sealed class Point
+ {
+ internal int Id { get; set; }
+ internal double Time { get; set; }
+ internal double Weight { get; set; }
+ internal string Kind { get; set; } = string.Empty;
+ internal BeatPosition? Beat { get; set; }
+ internal string? Position { get; set; }
+ internal int? OwnerGroup { get; set; }
+ internal double HeadBodyWeight { get; set; }
+ }
+
+ private readonly struct Assigned
+ {
+ internal Assigned(Point point, string phase) { Point = point; Phase = phase; }
+ internal Point Point { get; }
+ internal string Phase { get; }
+ }
+
+ private readonly struct TrickyValue
+ {
+ internal TrickyValue(double load) => Load = load;
+ internal double Load { get; }
+ }
+
+ private readonly struct TempoPoint
+ {
+ internal TempoPoint(double time, BeatPosition beat, double bpm)
+ { Time = time; Beat = beat; Bpm = bpm; }
+ internal double Time { get; }
+ internal BeatPosition Beat { get; }
+ internal double Bpm { get; }
+ }
+
+ public RadarFeatureResult Analyze(AnalysisContext context)
+ {
+ context.ThrowIfCancellationRequested();
+ if (context.DurationSeconds <= 0) return RadarFeatureResult.Failure("Chart duration must be positive.");
+ var groups = Groups(context.Events);
+ if (groups.Count == 0) return RadarFeatureResult.Success(0);
+ var clusters = Clusters(groups);
+ var points = Points(context.Events, groups);
+ var assigned = Assign(clusters, points);
+ var modeWait = ModalWait(groups);
+ var loads = new List();
+ for (var index = 0; index < clusters.Count; index++)
+ {
+ context.ThrowIfCancellationRequested();
+ var filtered = Filter(clusters[index], modeWait);
+ if (filtered is null) continue;
+ var clusterPoints = assigned[index];
+ if (!ReferenceEquals(filtered, clusters[index]))
+ clusterPoints = Assign(new[] { filtered }, clusterPoints.Select(item => item.Point).ToArray())[0];
+ loads.Add(ClusterTricky(filtered, clusterPoints).Load);
+ }
+ var top = loads.OrderByDescending(value => value).Take(TopCount).ToArray();
+ return RadarFeatureResult.Success(
+ top.Select((value, index) => value * TopWeights[index]).Sum() / TopWeights.Sum());
+ }
+
+ private static List Groups(IReadOnlyList events)
+ {
+ var tempo = events.Where(item => item.Kind == RadarEventKind.Timing && item.Bpm is not null)
+ .Select(item => new TempoPoint(item.StartTimeSeconds, item.StartBeat, item.Bpm!.Value))
+ .OrderBy(item => item.Beat).ToArray();
+ var output = new List();
+ foreach (var grouping in events.Where(item =>
+ item.Kind == RadarEventKind.Slide && item.SlideGroupId is not null)
+ .GroupBy(item => item.SlideGroupId!.Value))
+ {
+ var paths = grouping.ToArray();
+ var valid = paths.Where(item => item.EndTimeSeconds > item.StartTimeSeconds).ToArray();
+ if (valid.Length == 0) continue;
+ var times = paths.Select(item => item.SlideDeclareTimeSeconds).Distinct().ToArray();
+ var beats = paths.Select(item => item.SlideDeclareBeat).Distinct().ToArray();
+ var headIds = paths.Select(item => item.HeadEventId).Distinct().ToArray();
+ if (times.Length != 1 || times[0] is null || beats.Length != 1 || beats[0] is null ||
+ headIds.Length != 1)
+ throw new InvalidOperationException("Slide group declaration metadata is inconsistent.");
+ output.Add(new Group
+ {
+ Key = grouping.Key,
+ Events = valid,
+ DeclarationTime = times[0]!.Value,
+ DeclarationBeat = beats[0]!.Value,
+ LaunchTime = valid.Max(item => item.StartTimeSeconds),
+ LaunchTimes = valid.Select(item => item.StartTimeSeconds).Distinct().OrderBy(value => value).ToArray(),
+ ActiveIntervals = valid.Select(item =>
+ {
+ var oneBeatEnd = SecondsAtBeat(item.StartBeat + new BeatPosition(1), tempo);
+ return (item.StartTimeSeconds, Math.Min(item.EndTimeSeconds, oneBeatEnd));
+ }).ToArray(),
+ HeadEventId = headIds[0]
+ });
+ }
+ return output.OrderBy(item => item.DeclarationBeat)
+ .ThenBy(item => item.DeclarationTime).ThenBy(item => item.Key).ToList();
+ }
+
+ private static double SecondsAtBeat(BeatPosition beat, IReadOnlyList tempo)
+ {
+ for (var index = tempo.Count - 1; index >= 0; index--)
+ {
+ if (tempo[index].Beat <= beat)
+ return tempo[index].Time + (beat - tempo[index].Beat).ToDouble() * 60 / tempo[index].Bpm;
+ }
+ throw new InvalidOperationException("Beat precedes the first BPM event.");
+ }
+
+ private static List Clusters(IReadOnlyList groups)
+ {
+ var buckets = new List>();
+ foreach (var group in groups)
+ {
+ if (buckets.Count == 0 ||
+ group.DeclarationTime - buckets[^1][0].DeclarationTime > SimultaneousSeconds + Tolerance)
+ buckets.Add(new List());
+ buckets[^1].Add(group);
+ }
+ return buckets.Select(bucket => new Cluster
+ {
+ DeclarationTime = bucket[0].DeclarationTime,
+ DeclarationBeat = bucket[0].DeclarationBeat,
+ Groups = bucket
+ }).ToList();
+ }
+
+ private static List Points(IReadOnlyList events, IReadOnlyList groups)
+ {
+ var headOwners = groups.Where(item => item.HeadEventId is not null)
+ .ToDictionary(item => item.HeadEventId!.Value, item => item.Key);
+ var headWeights = groups.Where(item => item.HeadEventId is not null)
+ .ToDictionary(item => item.HeadEventId!.Value, item => (double)item.Events.Count);
+ var output = new List();
+ var id = 0;
+ foreach (var item in events.Where(item => item.Kind is RadarEventKind.Tap or RadarEventKind.Hold))
+ output.Add(new Point
+ {
+ Id = ++id, Time = item.StartTimeSeconds, Weight = 1, Kind = "button",
+ Beat = item.StartBeat, Position = item.Position,
+ OwnerGroup = headOwners.TryGetValue(item.EventId, out var owner) ? owner : null,
+ HeadBodyWeight = headWeights.TryGetValue(item.EventId, out var weight) ? weight : 0
+ });
+ var touches = events.Where(item => item.Kind is RadarEventKind.Touch or RadarEventKind.TouchHold).ToArray();
+ foreach (var touch in Workload.SimultaneousTouchPoints(touches, TouchWeight))
+ output.Add(new Point { Id = ++id, Time = touch.TimeSeconds, Weight = touch.Weight, Kind = "touch" });
+ foreach (var group in groups)
+ {
+ foreach (var launch in group.Events.GroupBy(item => item.StartTimeSeconds).OrderBy(item => item.Key))
+ output.Add(new Point
+ {
+ Id = ++id, Time = launch.Key, Weight = launch.Count(), Kind = "slide_launch",
+ OwnerGroup = group.Key
+ });
+ }
+ return output.OrderBy(item => item.Time).ThenBy(item => item.Id).ToList();
+ }
+
+ private static List> Assign(IReadOnlyList clusters, IReadOnlyList points)
+ {
+ var assigned = Enumerable.Range(0, clusters.Count).Select(_ => new List()).ToList();
+ foreach (var point in points)
+ {
+ var candidates = new List<(int Priority, double Distance, int Index, string Phase)>();
+ for (var index = 0; index < clusters.Count; index++)
+ {
+ var cluster = clusters[index];
+ var keys = cluster.Groups.Select(item => item.Key).ToHashSet();
+ if (point.OwnerGroup is int owner && keys.Contains(owner)) continue;
+ var launch = cluster.Groups.Any(group => group.LaunchTimes.Any(time => SameTime(point.Time, time)));
+ var waiting = cluster.Groups.Where(group =>
+ point.Time >= group.DeclarationTime - Tolerance &&
+ point.Time < group.LaunchTime - Tolerance).ToArray();
+ var active = cluster.Groups.SelectMany(group => group.ActiveIntervals)
+ .Where(interval => point.Time > interval.Start + Tolerance &&
+ point.Time <= interval.End + Tolerance).ToArray();
+ if (launch) candidates.Add((0, 0, index, "launch"));
+ else if (waiting.Length > 0)
+ candidates.Add((1, waiting.Min(group => group.LaunchTime - point.Time), index, "waiting"));
+ else if (active.Length > 0)
+ candidates.Add((2, active.Min(interval => point.Time - interval.Start), index, "active"));
+ }
+ if (candidates.Count > 0)
+ {
+ var selected = candidates.OrderBy(item => item.Priority).ThenBy(item => item.Distance)
+ .ThenBy(item => item.Index).First();
+ assigned[selected.Index].Add(new Assigned(point, selected.Phase));
+ }
+ }
+ return assigned;
+ }
+
+ private static TrickyValue ClusterTricky(Cluster cluster, IReadOnlyList assigned)
+ {
+ var targets = cluster.Groups.SelectMany(group => group.Events).Select(item => item.Position!)
+ .ToHashSet();
+ var touchIds = assigned.Where(item => item.Point.Kind == "touch")
+ .OrderBy(item => item.Point.Time).ThenBy(item => item.Point.Id)
+ .Take(TouchCap).Select(item => item.Point.Id).ToHashSet();
+ var counted = assigned.Where(item => item.Point.Kind != "touch" || touchIds.Contains(item.Point.Id)).ToArray();
+ var launch = counted.Where(item => item.Phase == "launch").Select(item => item.Point).ToArray();
+ var internalItems = counted.Where(item => item.Phase != "launch").ToArray();
+ var internalButtons = internalItems.Where(item => item.Point.Kind == "button").Select(item => item.Point).ToArray();
+ var internalOther = internalItems.Where(item => item.Point.Kind != "button").Select(item => item.Point).ToArray();
+ var waitingIds = internalItems.Where(item => item.Phase == "waiting" && item.Point.Kind == "button")
+ .Select(item => item.Point.Id).ToHashSet();
+ var speed = OrdinarySpeed(cluster, counted);
+ var internalLoad = SweepAdjustedButtons(internalButtons, targets, waitingIds, speed) +
+ internalOther.Sum(item => item.Weight);
+ var headOwners = launch.Where(item => item.Kind == "button" && item.HeadBodyWeight > 0)
+ .Select(item => item.OwnerGroup).ToHashSet();
+ var deduplicatedLaunch = launch.Where(item =>
+ !(item.Kind == "slide_launch" && headOwners.Contains(item.OwnerGroup))).ToArray();
+ var launchLoad = deduplicatedLaunch.Sum(item => item.Weight + item.HeadBodyWeight) / 2;
+ var logicalCount = internalOther.Sum(item => item.Kind == "slide_launch" ? (int)item.Weight : 1) +
+ internalButtons.Length + deduplicatedLaunch.Sum(item =>
+ item.Kind == "slide_launch" ? (int)item.Weight :
+ item.Kind == "button" ? 1 + (int)item.HeadBodyWeight : 1);
+ var capFactor = logicalCount > 0 ? Math.Min(1, (double)ObjectCap / logicalCount) : 1;
+ var concurrency = cluster.Groups.Sum(group => Math.Sqrt(group.Events.Count));
+ var multiplier = 1 + MultiSlideUplift * Math.Max(0, concurrency - 1);
+ return new TrickyValue((internalLoad + launchLoad) * capFactor * multiplier);
+ }
+
+ private static double SweepAdjustedButtons(
+ IReadOnlyList points,
+ HashSet targets,
+ HashSet samePositionIds,
+ double speed)
+ {
+ if (points.Count == 0) return 0;
+ var batches = points.GroupBy(item => item.Beat!.Value).OrderBy(item => item.Key);
+ var runLength = 0;
+ Dictionary? previousPositions = null;
+ BeatPosition? previousBeat = null, interval = null;
+ int? direction = null;
+ var total = 0.0;
+ foreach (var batchGroup in batches)
+ {
+ var batch = batchGroup.ToArray();
+ var batchWeight = batch.Sum(point => ButtonWeight(point, targets, samePositionIds, speed));
+ if (batch.Length is not (1 or 2))
+ {
+ total += batchWeight; runLength = 0; previousPositions = null;
+ previousBeat = interval = null; direction = null; continue;
+ }
+ var positions = batch.GroupBy(item => int.Parse(item.Position!))
+ .ToDictionary(item => item.Key, item => item.Count());
+ BeatPosition? currentInterval = previousBeat is null
+ ? null
+ : batchGroup.Key - previousBeat.Value;
+ var currentDirection = previousPositions is null || previousPositions.Values.Sum() != batch.Length
+ ? null : AdjacentDirection(previousPositions, positions);
+ var continues = previousBeat is not null && currentDirection is not null &&
+ currentInterval > BeatPosition.Zero &&
+ (runLength == 1 || currentDirection == direction && currentInterval == interval);
+ if (continues)
+ {
+ runLength++;
+ if (runLength == 2) { direction = currentDirection; interval = currentInterval; }
+ }
+ else if (previousBeat is not null && currentDirection is not null && currentInterval > BeatPosition.Zero)
+ { runLength = 2; direction = currentDirection; interval = currentInterval; }
+ else { runLength = 1; direction = null; interval = null; }
+ total += batchWeight / Math.Log(Math.Max(2, runLength - 1), 2);
+ previousPositions = positions; previousBeat = batchGroup.Key;
+ }
+ return total;
+ }
+
+ private static double ButtonWeight(Point point, HashSet targets, HashSet eligible, double speed)
+ {
+ var multiplier = eligible.Contains(point.Id) && targets.Contains(point.Position!)
+ ? SamePositionMultiplier : 1;
+ if (point.HeadBodyWeight > 0) multiplier = Math.Max(multiplier, PendingHeadMultiplier);
+ return point.Weight * multiplier * (point.HeadBodyWeight == 0 ? speed : 1);
+ }
+
+ private static int? AdjacentDirection(Dictionary left, Dictionary right)
+ {
+ bool EqualShift(int delta)
+ {
+ var shifted = left.ToDictionary(pair => (pair.Key - 1 + delta + 8) % 8 + 1, pair => pair.Value);
+ return shifted.Count == right.Count && shifted.All(pair =>
+ right.TryGetValue(pair.Key, out var value) && value == pair.Value);
+ }
+ if (EqualShift(1)) return 1;
+ if (EqualShift(-1)) return -1;
+ return null;
+ }
+
+ private static double OrdinarySpeed(Cluster cluster, IReadOnlyList assigned)
+ {
+ var times = assigned.Where(item => item.Point.Kind == "button" && item.Point.HeadBodyWeight == 0)
+ .Select(item => item.Point.Time).Distinct().OrderBy(value => value).ToArray();
+ if (times.Length == 0) return 1;
+ var timeline = new[] { cluster.DeclarationTime }.Concat(times).ToArray();
+ var gaps = timeline.Zip(timeline.Skip(1), (left, right) => right - left)
+ .Where(gap => gap > Tolerance).OrderBy(value => value).ToArray();
+ if (gaps.Length == 0) return 1;
+ var median = gaps.Length % 2 == 1 ? gaps[gaps.Length / 2] :
+ (gaps[gaps.Length / 2 - 1] + gaps[gaps.Length / 2]) / 2;
+ var ratio = (30 / median) / SpeedReferenceEighthBpm;
+ return ratio < 1 ? Math.Pow(ratio, SpeedExponent) : Math.Min(SpeedMaxFactor, ratio);
+ }
+
+ private static double? ModalWait(IReadOnlyList groups)
+ {
+ var buckets = new Dictionary>();
+ foreach (var group in groups)
+ foreach (var path in group.Events)
+ {
+ var wait = path.StartTimeSeconds - group.DeclarationTime;
+ if (wait <= Tolerance) continue;
+ var bucket = (int)Math.Floor((wait + Tolerance) / WaitBucketSeconds + 0.5);
+ if (!buckets.TryGetValue(bucket, out var values)) buckets[bucket] = values = new List();
+ values.Add(wait);
+ }
+ if (buckets.Count == 0) return null;
+ var winner = buckets.OrderByDescending(item => item.Value.Count).ThenByDescending(item => item.Key).First().Value;
+ winner.Sort();
+ return winner.Count % 2 == 1 ? winner[winner.Count / 2] :
+ (winner[winner.Count / 2 - 1] + winner[winner.Count / 2]) / 2;
+ }
+
+ private static Cluster? Filter(Cluster cluster, double? modeWait)
+ {
+ if (modeWait is null) return cluster;
+ var threshold = modeWait.Value * MaxWaitModeRatio;
+ var groups = new List();
+ var changed = false;
+ foreach (var group in cluster.Groups)
+ {
+ var keptIndexes = Enumerable.Range(0, group.Events.Count).Where(index =>
+ {
+ var wait = group.Events[index].StartTimeSeconds - group.DeclarationTime;
+ var remove = wait > threshold && Math.Abs(wait - threshold) > Tolerance;
+ changed |= remove;
+ return !remove;
+ }).ToArray();
+ if (keptIndexes.Length == group.Events.Count) groups.Add(group);
+ else if (keptIndexes.Length > 0)
+ {
+ var events = keptIndexes.Select(index => group.Events[index]).ToArray();
+ groups.Add(new Group
+ {
+ Key = group.Key, Events = events, DeclarationTime = group.DeclarationTime,
+ DeclarationBeat = group.DeclarationBeat, HeadEventId = group.HeadEventId,
+ LaunchTime = events.Max(item => item.StartTimeSeconds),
+ LaunchTimes = events.Select(item => item.StartTimeSeconds).Distinct().OrderBy(value => value).ToArray(),
+ ActiveIntervals = keptIndexes.Select(index => group.ActiveIntervals[index]).ToArray()
+ });
+ }
+ }
+ if (!changed) return cluster;
+ return groups.Count == 0 ? null : new Cluster
+ {
+ DeclarationTime = cluster.DeclarationTime,
+ DeclarationBeat = cluster.DeclarationBeat,
+ Groups = groups
+ };
+ }
+
+ private static bool SameTime(double left, double right) => Math.Abs(left - right) <= Tolerance;
+}
diff --git a/Runtime/Analysis/Features/SweepBurstAnalyzer.cs b/Runtime/Analysis/Features/SweepBurstAnalyzer.cs
new file mode 100644
index 0000000..30c8de9
--- /dev/null
+++ b/Runtime/Analysis/Features/SweepBurstAnalyzer.cs
@@ -0,0 +1,393 @@
+using MajRadar.Core;
+using System.Threading;
+
+namespace MajRadar.Analysis.Features;
+
+///
+/// Scores the three strongest non-overlapping two-second Sweep windows. The
+/// constants below are the active fitted-feature definition, not user options.
+///
+internal sealed class SweepBurstAnalyzer : IRadarFeatureAnalyzer
+{
+ private const double WindowSeconds = 2;
+ private const int WindowCount = 3;
+ private const double WindowRankDecayExponent = 0.5;
+ private const double ReferenceIntervalSeconds = 1.0 / 12;
+ private const double SpeedExponent = 0.5;
+ private const double ProtectedNoteWeight = 0.3;
+ private const int SimpleRunFullAttacks = 16;
+ private const double SimpleRunDecayExponent = 0.5;
+ private const double SameDirectionConnectionBonus = 0.2;
+ private const double SameDirectionHandoffBonus = 0.2;
+ private const double EighthGapGroupBonus = 0.05;
+ private const double IdleDistanceWeight = 0.5;
+ private const double IdleSpeedReferenceKeysPerSecond = 10;
+ private const double TakeoverWeight = 1;
+ private const double FastJumpWeight = 2;
+ private const double PatternMotionFloor = 0.1;
+ private const int PatternMinimumGroups = 6;
+ private const int PatternMaximumPeriod = 4;
+ private const double PatternMinimumMatchRatio = 0.8;
+ private const double Tolerance = 1e-9;
+ private static readonly BeatPosition EighthBeat = new(1, 2);
+
+ private readonly struct PatternToken : IEquatable
+ {
+ internal PatternToken(char hand, int direction) { Hand = hand; Direction = direction; }
+ internal char Hand { get; }
+ internal int Direction { get; }
+ public bool Equals(PatternToken other) => Hand == other.Hand && Direction == other.Direction;
+ public override bool Equals(object? obj) => obj is PatternToken other && Equals(other);
+ public override int GetHashCode() => HashCode.Combine(Hand, Direction);
+ }
+
+ private sealed class PointValue
+ {
+ internal double Base { get; set; }
+ internal double Motion { get; set; }
+ internal double RawMotion { get; set; }
+ }
+
+ public RadarFeatureResult Analyze(AnalysisContext context)
+ {
+ context.ThrowIfCancellationRequested();
+ if (context.DurationSeconds <= 0)
+ return RadarFeatureResult.Failure("Chart duration must be positive.");
+ return RadarFeatureResult.Success(Score(
+ context.Events, context.DurationSeconds, context.CancellationToken).Value);
+ }
+
+ internal static SweepBurstResult Score(
+ IReadOnlyList events,
+ double durationSeconds,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var sequences = SweepRecognizer.Recognize(events, cancellationToken);
+ var (groups, families) = SweepFamilyBuilder.Build(sequences, cancellationToken);
+ var groupsById = groups.ToDictionary(item => item.Id);
+ var motions = families.ToDictionary(
+ family => family.Id, family => SweepHandMotion.ForFamily(
+ family, groups, cancellationToken));
+ var eighthBonusGroups = EighthBonusGroups(groups);
+ var points = new SortedDictionary();
+
+ void AddPoint(double time, double baseValue = 0, double motion = 0, double rawMotion = 0)
+ {
+ if (!points.TryGetValue(time, out var point)) points[time] = point = new PointValue();
+ point.Base += baseValue;
+ point.Motion += motion;
+ point.RawMotion += rawMotion;
+ }
+
+ var firstBatchPhysicalBase = new Dictionary();
+ foreach (var group in groups)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var sequence = group.Sequence;
+ var speedByBatch = new[] { sequence.UnitTimeIntervals[0] }
+ .Concat(sequence.UnitTimeIntervals).ToArray();
+ var simpleRunLength = 0;
+ for (var index = 0; index < sequence.Times.Count; index++)
+ {
+ var noteWeight = sequence.NormalDeclarations[index] +
+ sequence.ProtectedDeclarations[index] * ProtectedNoteWeight;
+ var speedFactor = Math.Pow(
+ ReferenceIntervalSeconds / speedByBatch[index], SpeedExponent);
+ if (index == 0)
+ firstBatchPhysicalBase[group.Id] = sequence.Widths[index] * speedFactor;
+
+ var decay = 1.0;
+ if (sequence.Widths[index] >= 2) simpleRunLength = 0;
+ else
+ {
+ if (sequence.SpeedSwitches.Contains(index) ||
+ sequence.DirectionSwitches.Contains(index)) simpleRunLength = 0;
+ simpleRunLength++;
+ if (simpleRunLength > SimpleRunFullAttacks)
+ {
+ var excessRank = simpleRunLength - SimpleRunFullAttacks + 1;
+ decay = Math.Pow(excessRank, -SimpleRunDecayExponent);
+ }
+ }
+ var baseValue = noteWeight * speedFactor * decay;
+ if (eighthBonusGroups.Contains(group.Id))
+ baseValue += sequence.Widths[index] * speedFactor * EighthGapGroupBonus;
+ if (sequence.DoubleHandoffs.Contains(index) &&
+ !sequence.DirectionSwitches.Contains(index))
+ baseValue += sequence.Widths[index] * speedFactor *
+ SameDirectionHandoffBonus;
+ AddPoint(sequence.Times[index], baseValue);
+ }
+ }
+
+ var sameDirectionGroups = new HashSet();
+ foreach (var group in groups)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (group.ParentId is null) continue;
+ var parent = groupsById[group.ParentId.Value];
+ if (!SweepFamilyBuilder.SameDirection(parent, group)) continue;
+ sameDirectionGroups.Add(group.Id);
+ AddPoint(group.Sequence.StartTime,
+ firstBatchPhysicalBase[group.Id] * SameDirectionConnectionBonus);
+ }
+
+ foreach (var family in families)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var motion = motions[family.Id];
+ var regularStarts = RegularPatternGroups(
+ family, groupsById, motion, sameDirectionGroups)
+ .Select(id => groupsById[id].Sequence.StartTime).ToHashSet();
+ double? lastLeftUse = null, lastRightUse = null;
+ foreach (var assignment in motion.Assignments)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var weightedIdle = 0.0;
+ if (assignment.LeftIdleDistance != 0)
+ {
+ if (lastLeftUse is null)
+ throw new InvalidOperationException("Idle displacement has no prior left-hand use.");
+ weightedIdle += IdlePressure(
+ assignment.LeftIdleDistance, assignment.Time - lastLeftUse.Value);
+ }
+ if (assignment.RightIdleDistance != 0)
+ {
+ if (lastRightUse is null)
+ throw new InvalidOperationException("Idle displacement has no prior right-hand use.");
+ weightedIdle += IdlePressure(
+ assignment.RightIdleDistance, assignment.Time - lastRightUse.Value);
+ }
+ if (assignment.LeftLanes.Count != 0) lastLeftUse = assignment.Time;
+ if (assignment.RightLanes.Count != 0) lastRightUse = assignment.Time;
+
+ var idleBonus = weightedIdle * IdleDistanceWeight;
+ var otherBonus = assignment.FreeHandTakeover * TakeoverWeight +
+ assignment.FastJumpViolations * FastJumpWeight;
+ var rawBonus = idleBonus + otherBonus;
+ if (rawBonus == 0) continue;
+ var adjusted = regularStarts.Contains(assignment.Time)
+ ? rawBonus * PatternMotionFloor : rawBonus;
+ AddPoint(assignment.Time, motion: adjusted, rawMotion: rawBonus);
+ }
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+ return Windows(points, durationSeconds);
+ }
+
+ private static HashSet EighthBonusGroups(IReadOnlyList groups)
+ {
+ var output = new HashSet();
+ var endingAt = new Dictionary>();
+ foreach (var group in groups)
+ {
+ if (group.ParentId is null && endingAt.TryGetValue(
+ group.Sequence.StartBeat - EighthBeat, out var previous) &&
+ previous.Any(parent => SameSpeed(
+ parent.Sequence.MedianIntervalSeconds,
+ group.Sequence.MedianIntervalSeconds, 0.1)))
+ output.Add(group.Id);
+ if (!endingAt.TryGetValue(group.Sequence.EndBeat, out var ending))
+ endingAt[group.Sequence.EndBeat] = ending = new List();
+ ending.Add(group);
+ }
+ return output;
+ }
+
+ private static HashSet RegularPatternGroups(
+ SweepFamily family,
+ IReadOnlyDictionary groups,
+ HandMotionResult motion,
+ IReadOnlyCollection protectedGroups)
+ {
+ var assignments = motion.Assignments.ToDictionary(item => item.Time);
+ var output = new HashSet();
+ var segment = new List<(int GroupId, PatternToken Token)>();
+ foreach (var group in family.GroupIds.Select(id => groups[id])
+ .OrderBy(item => item.Sequence.StartTime))
+ {
+ var token = SimpleToken(group, assignments);
+ if (token is null)
+ {
+ output.UnionWith(MarkRegular(segment));
+ segment.Clear();
+ }
+ else segment.Add((group.Id, token.Value));
+ }
+ output.UnionWith(MarkRegular(segment));
+ output.ExceptWith(protectedGroups);
+ return output;
+ }
+
+ private static PatternToken? SimpleToken(
+ ScoredSweepGroup group, IReadOnlyDictionary assignments)
+ {
+ var sequence = group.Sequence;
+ if (sequence.Widths.Any(width => width != 1) || sequence.SpeedSwitches.Count != 0 ||
+ sequence.DirectionSwitches.Count != 0 || sequence.DoubleHandoffs.Count != 0)
+ return null;
+ var directions = sequence.Strands.SelectMany(strand =>
+ new[] { strand.InitialDirection, strand.FinalDirection }).Distinct().ToArray();
+ if (directions.Length != 1) return null;
+ char? hand = null;
+ foreach (var time in sequence.Times)
+ {
+ if (!assignments.TryGetValue(time, out var assignment)) return null;
+ var current = assignment.LeftLanes.Count != 0 && assignment.RightLanes.Count == 0
+ ? 'L' : assignment.RightLanes.Count != 0 && assignment.LeftLanes.Count == 0
+ ? 'R' : (char?)null;
+ if (current is null || hand is not null && hand != current) return null;
+ hand = current;
+ }
+ return new PatternToken(hand!.Value, directions[0]);
+ }
+
+ private static HashSet MarkRegular(
+ IReadOnlyList<(int GroupId, PatternToken Token)> segment)
+ {
+ if (segment.Count < PatternMinimumGroups) return new HashSet();
+ PatternToken[]? bestTemplate = null;
+ var bestQuality = double.NegativeInfinity;
+ var bestPeriod = int.MaxValue;
+ for (var period = 1;
+ period <= Math.Min(PatternMaximumPeriod, segment.Count / 3);
+ period++)
+ {
+ var template = Enumerable.Range(0, period).Select(phase =>
+ MostCommon(segment.Where((_, index) => index % period == phase)
+ .Select(item => item.Token))).ToArray();
+ var matched = segment.Select((item, index) =>
+ item.Token.Equals(template[index % period])).Count(value => value);
+ var ratio = (double)matched / segment.Count;
+ if (ratio < PatternMinimumMatchRatio) continue;
+ var quality = ratio - 0.02 * (period - 1);
+ if (quality > bestQuality || quality == bestQuality && period < bestPeriod)
+ {
+ bestQuality = quality;
+ bestPeriod = period;
+ bestTemplate = template;
+ }
+ }
+ if (bestTemplate is null) return new HashSet();
+ return segment.Select((item, index) => (item, index))
+ .Where(pair => pair.item.Token.Equals(bestTemplate[pair.index % bestTemplate.Length]))
+ .Select(pair => pair.item.GroupId).ToHashSet();
+ }
+
+ private static PatternToken MostCommon(IEnumerable values)
+ {
+ var counts = new Dictionary();
+ var index = 0;
+ foreach (var value in values)
+ {
+ if (counts.TryGetValue(value, out var row)) counts[value] = (row.Count + 1, row.First);
+ else counts[value] = (1, index);
+ index++;
+ }
+ return counts.OrderByDescending(pair => pair.Value.Count)
+ .ThenBy(pair => pair.Value.First).First().Key;
+ }
+
+ private static double IdlePressure(int distance, double elapsed)
+ {
+ if (distance == 0) return 0;
+ if (elapsed <= 0) throw new InvalidOperationException("Idle hand travel requires positive time.");
+ var ratio = distance / (elapsed * IdleSpeedReferenceKeysPerSecond);
+ return distance * Math.Sqrt(Math.Max(1, ratio));
+ }
+
+ private static SweepBurstResult Windows(
+ SortedDictionary points, double duration)
+ {
+ var maxStart = Math.Max(0, duration - WindowSeconds);
+ var starts = new HashSet { 0, maxStart };
+ foreach (var time in points.Keys)
+ {
+ starts.Add(Math.Min(maxStart, Math.Max(0, time)));
+ starts.Add(Math.Min(maxStart, Math.Max(0, time - WindowSeconds)));
+ }
+ var times = points.Keys.ToArray();
+ var basePrefix = Prefix(points.Values.Select(item => item.Base));
+ var motionPrefix = Prefix(points.Values.Select(item => item.Motion));
+ var rawPrefix = Prefix(points.Values.Select(item => item.RawMotion));
+ var candidates = starts.OrderBy(value => value).Select(start =>
+ {
+ var left = LowerBound(times, start - Tolerance);
+ var right = LowerBound(times, start + WindowSeconds - Tolerance);
+ var baseDensity = (basePrefix[right] - basePrefix[left]) / WindowSeconds;
+ var motionDensity = (motionPrefix[right] - motionPrefix[left]) / WindowSeconds;
+ var rawDensity = (rawPrefix[right] - rawPrefix[left]) / WindowSeconds;
+ return new SweepBurstWindow
+ {
+ Value = baseDensity + motionDensity,
+ BaseDensity = baseDensity,
+ MotionDensity = motionDensity,
+ RawMotionDensity = rawDensity,
+ Start = start,
+ End = start + WindowSeconds
+ };
+ }).ToArray();
+
+ var selected = new List();
+ foreach (var candidate in candidates.OrderByDescending(item => item.Value)
+ .ThenBy(item => item.Start))
+ {
+ if (candidate.Value <= 0 || selected.Any(other =>
+ candidate.Start < other.End - Tolerance &&
+ other.Start < candidate.End - Tolerance)) continue;
+ selected.Add(candidate);
+ if (selected.Count == WindowCount) break;
+ }
+ if (selected.Count == 0)
+ {
+ var empty = new SweepBurstWindow { Start = 0, End = WindowSeconds };
+ return new SweepBurstResult
+ {
+ StrongestWindowStart = 0,
+ StrongestWindowEnd = WindowSeconds,
+ Windows = new[] { empty }
+ };
+ }
+
+ var weights = Enumerable.Range(1, selected.Count)
+ .Select(rank => Math.Pow(rank, -WindowRankDecayExponent)).ToArray();
+ var weightTotal = weights.Sum();
+ double Weighted(Func property) =>
+ selected.Select((window, index) => weights[index] * property(window)).Sum() / weightTotal;
+ return new SweepBurstResult
+ {
+ Value = Weighted(item => item.Value),
+ BaseDensity = Weighted(item => item.BaseDensity),
+ MotionDensity = Weighted(item => item.MotionDensity),
+ RawMotionDensity = Weighted(item => item.RawMotionDensity),
+ StrongestWindowStart = selected[0].Start,
+ StrongestWindowEnd = selected[0].End,
+ Windows = selected
+ };
+ }
+
+ private static double[] Prefix(IEnumerable values)
+ {
+ var output = new List { 0 };
+ foreach (var value in values) output.Add(output[^1] + value);
+ return output.ToArray();
+ }
+
+ private static int LowerBound(IReadOnlyList values, double target)
+ {
+ var low = 0;
+ var high = values.Count;
+ while (low < high)
+ {
+ var middle = low + (high - low) / 2;
+ if (values[middle] < target) low = middle + 1;
+ else high = middle;
+ }
+ return low;
+ }
+
+ private static bool SameSpeed(double left, double right, double relativeTolerance) =>
+ Math.Abs(left - right) <= Math.Max(Tolerance,
+ relativeTolerance * Math.Max(Math.Abs(left), Math.Abs(right)));
+}
diff --git a/Runtime/Analysis/Features/SweepFamilyBuilder.cs b/Runtime/Analysis/Features/SweepFamilyBuilder.cs
new file mode 100644
index 0000000..a3fbcdf
--- /dev/null
+++ b/Runtime/Analysis/Features/SweepFamilyBuilder.cs
@@ -0,0 +1,144 @@
+namespace MajRadar.Analysis.Features;
+
+using System.Threading;
+
+///
+/// Connects recognized groups into chronological families. Only the constants
+/// used by the active Sweep model live here; experimental Python modes are not
+/// part of the runtime surface.
+///
+internal static class SweepFamilyBuilder
+{
+ private const double SingleConnectionIncrement = 0.2;
+ private const double DoubleConnectionIncrement = 0.0;
+ private const double SpeedChangeIncrement = 0.2;
+ private const double SameDirectionIncrement = 0.1;
+ private const double ReversalIncrement = 0.2;
+ private const double Tolerance = 1e-9;
+ private const int MaximumConnectionChecks = 1_000_000;
+
+ internal static (IReadOnlyList Groups, IReadOnlyList Families)
+ Build(
+ IReadOnlyList sequences,
+ CancellationToken cancellationToken = default)
+ {
+ var ordered = sequences.OrderBy(item => item.StartTime).ThenBy(item => item.EndTime)
+ .ThenBy(item => item, Comparer.Create(CompareLanes)).ToArray();
+ var multipliers = Enumerable.Repeat(1.0, ordered.Length).ToArray();
+ var parents = new int?[ordered.Length];
+ var activeParents = new List();
+ var connectionChecks = 0;
+ for (var childIndex = 0; childIndex < ordered.Length; childIndex++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ for (var index = activeParents.Count - 1; index >= 0; index--)
+ {
+ var parent = ordered[activeParents[index]];
+ if (parent.EndTime + parent.MedianIntervalSeconds + Tolerance <
+ ordered[childIndex].StartTime)
+ activeParents.RemoveAt(index);
+ }
+ (double Multiplier, double Nearness, int Earlier, int Parent)? best = null;
+ foreach (var parentIndex in activeParents)
+ {
+ if (++connectionChecks > MaximumConnectionChecks)
+ throw new InvalidOperationException(
+ $"Sweep family connection budget exceeded ({MaximumConnectionChecks}).");
+ var increment = ConnectionIncrement(ordered[parentIndex], ordered[childIndex]);
+ if (increment is null) continue;
+ var candidate = (
+ multipliers[parentIndex] + increment.Value,
+ -Math.Abs(ordered[childIndex].StartTime - ordered[parentIndex].EndTime),
+ -parentIndex,
+ parentIndex);
+ if (best is null || CompareChoice(candidate, best.Value) > 0) best = candidate;
+ }
+ if (best is not null)
+ {
+ multipliers[childIndex] = best.Value.Multiplier;
+ parents[childIndex] = best.Value.Parent;
+ }
+ activeParents.Add(childIndex);
+ }
+
+ var groups = ordered.Select((sequence, index) => new ScoredSweepGroup
+ {
+ Id = index + 1,
+ Sequence = sequence,
+ ParentId = parents[index] is null ? null : parents[index] + 1
+ }).ToArray();
+ var byRoot = new SortedDictionary>();
+ var roots = new int[groups.Length];
+ for (var index = 0; index < groups.Length; index++)
+ {
+ var root = parents[index] is int parent ? roots[parent] : index;
+ roots[index] = root;
+ if (!byRoot.TryGetValue(root, out var members)) byRoot[root] = members = new List();
+ members.Add(groups[index].Id);
+ }
+ var families = byRoot.Select((pair, index) => new SweepFamily
+ {
+ Id = index + 1,
+ GroupIds = pair.Value
+ }).ToArray();
+ return (groups, families);
+ }
+
+ internal static bool SameDirection(ScoredSweepGroup parent, ScoredSweepGroup child) =>
+ parent.Sequence.Strands.Select(item => item.FinalDirection)
+ .Intersect(child.Sequence.Strands.Select(item => item.InitialDirection)).Any();
+
+ private static double? ConnectionIncrement(SweepSequence parent, SweepSequence child)
+ {
+ var gap = child.StartTime - parent.EndTime;
+ if (gap < -Tolerance || gap > parent.MedianIntervalSeconds + Tolerance) return null;
+ var doubleConnection = Math.Abs(gap) <= Tolerance;
+ var speedChanged = !SameSpeed(parent.MedianIntervalSeconds, child.MedianIntervalSeconds);
+ var parentDirections = parent.Strands.Select(item => item.FinalDirection).ToHashSet();
+ var childDirections = child.Strands.Select(item => item.InitialDirection).ToHashSet();
+ var nearStarts = parent.LanesByBatch[0].Any(left =>
+ child.LanesByBatch[0].Any(right => SweepRecognizer.CircularDistance(left, right) <= 1));
+ var sameDirection = parentDirections.Overlaps(childDirections);
+ var fold = parentDirections.Any(left => childDirections.Contains(-left)) &&
+ parent.LanesByBatch[^1].Any(left => child.LanesByBatch[0]
+ .Any(right => SweepRecognizer.CircularDistance(left, right) <= 1));
+ var sameDirectionBonus = sameDirection && nearStarts;
+ var increment = doubleConnection ? DoubleConnectionIncrement :
+ sameDirectionBonus || fold ? SingleConnectionIncrement : 0;
+ if (speedChanged) increment += SpeedChangeIncrement;
+ if (sameDirectionBonus) increment += SameDirectionIncrement;
+ else if (fold) increment += ReversalIncrement;
+ return increment;
+ }
+
+ private static bool SameSpeed(double left, double right) =>
+ Math.Abs(left - right) <= Math.Max(Tolerance,
+ SweepRecognizer.SpeedRelativeTolerance * Math.Max(Math.Abs(left), Math.Abs(right)));
+
+ private static int CompareChoice(
+ (double Multiplier, double Nearness, int Earlier, int Parent) left,
+ (double Multiplier, double Nearness, int Earlier, int Parent) right)
+ {
+ var result = left.Multiplier.CompareTo(right.Multiplier);
+ if (result != 0) return result;
+ result = left.Nearness.CompareTo(right.Nearness);
+ return result != 0 ? result : left.Earlier.CompareTo(right.Earlier);
+ }
+
+ private static int CompareLanes(SweepSequence left, SweepSequence right)
+ {
+ for (var batch = 0; batch < Math.Min(left.LanesByBatch.Count, right.LanesByBatch.Count); batch++)
+ {
+ var a = left.LanesByBatch[batch];
+ var b = right.LanesByBatch[batch];
+ for (var lane = 0; lane < Math.Min(a.Count, b.Count); lane++)
+ {
+ var result = a[lane].CompareTo(b[lane]);
+ if (result != 0) return result;
+ }
+ var length = a.Count.CompareTo(b.Count);
+ if (length != 0) return length;
+ }
+ return left.LanesByBatch.Count.CompareTo(right.LanesByBatch.Count);
+ }
+}
diff --git a/Runtime/Analysis/Features/SweepHandMotion.cs b/Runtime/Analysis/Features/SweepHandMotion.cs
new file mode 100644
index 0000000..832632f
--- /dev/null
+++ b/Runtime/Analysis/Features/SweepHandMotion.cs
@@ -0,0 +1,335 @@
+namespace MajRadar.Analysis.Features;
+
+using System.Buffers;
+using System.Threading;
+
+/// Two-hand displacement DP used by Sweep burst motion scoring.
+internal static class SweepHandMotion
+{
+ private const double ReferenceStepSeconds = 1.0 / 12;
+
+ private readonly struct MotionState : IEquatable
+ {
+ internal MotionState(int? left, int? right, int? leftBatch, int? rightBatch)
+ {
+ Left = left; Right = right; LeftBatch = leftBatch; RightBatch = rightBatch;
+ }
+ internal int? Left { get; }
+ internal int? Right { get; }
+ internal int? LeftBatch { get; }
+ internal int? RightBatch { get; }
+ public bool Equals(MotionState other) => Left == other.Left && Right == other.Right &&
+ LeftBatch == other.LeftBatch && RightBatch == other.RightBatch;
+ public override bool Equals(object? obj) => obj is MotionState other && Equals(other);
+ public override int GetHashCode() => HashCode.Combine(Left, Right, LeftBatch, RightBatch);
+ }
+
+ private readonly struct MotionLexKey : IComparable, IEquatable
+ {
+ internal MotionLexKey(int prefix, int left, int right)
+ { Prefix = prefix; Left = left; Right = right; }
+ internal int Prefix { get; }
+ internal int Left { get; }
+ internal int Right { get; }
+ public int CompareTo(MotionLexKey other)
+ {
+ var result = Prefix.CompareTo(other.Prefix);
+ if (result != 0) return result;
+ result = Left.CompareTo(other.Left);
+ return result != 0 ? result : Right.CompareTo(other.Right);
+ }
+ public bool Equals(MotionLexKey other) =>
+ Prefix == other.Prefix && Left == other.Left && Right == other.Right;
+ public override bool Equals(object? obj) => obj is MotionLexKey other && Equals(other);
+ public override int GetHashCode() => HashCode.Combine(Prefix, Left, Right);
+ }
+
+ private sealed class MotionRecord
+ {
+ internal int FastJumps { get; set; }
+ internal int TotalDistance { get; set; }
+ internal int ActiveDistance { get; set; }
+ internal int IdleDistance { get; set; }
+ internal int Takeovers { get; set; }
+ internal MotionRecord? Previous { get; set; }
+ internal int Length { get; set; }
+ internal int LexRank { get; set; }
+ internal MotionLexKey LexKey { get; set; }
+ internal double Time { get; set; }
+ internal IReadOnlyList LeftLanes { get; set; } = Array.Empty();
+ internal IReadOnlyList RightLanes { get; set; } = Array.Empty();
+ internal int? LeftPosition { get; set; }
+ internal int? RightPosition { get; set; }
+ internal int LeftIdleDistance { get; set; }
+ internal int RightIdleDistance { get; set; }
+ internal int AssignmentTakeover { get; set; }
+ internal int AssignmentFastJumps { get; set; }
+ }
+
+ private sealed class AssignmentOption
+ {
+ internal IReadOnlyList LeftLanes { get; set; } = Array.Empty();
+ internal IReadOnlyList RightLanes { get; set; } = Array.Empty();
+ internal int? LeftTarget { get; set; }
+ internal int? RightTarget { get; set; }
+ }
+
+ private readonly struct MoveCost
+ {
+ internal MoveCost(int distance, int active, int idle, int fast)
+ { Distance = distance; Active = active; Idle = idle; Fast = fast; }
+ internal int Distance { get; }
+ internal int Active { get; }
+ internal int Idle { get; }
+ internal int Fast { get; }
+ }
+
+ internal static HandMotionResult ForFamily(
+ SweepFamily family,
+ IReadOnlyList groups,
+ CancellationToken cancellationToken = default)
+ {
+ if (family.GroupIds.Count == 1)
+ {
+ var sequence = groups[family.GroupIds[0] - 1].Sequence;
+ return Calculate(
+ sequence.Times,
+ sequence.LanesByBatch,
+ cancellationToken: cancellationToken);
+ }
+ var byId = groups.ToDictionary(item => item.Id);
+ var batches = new SortedDictionary>();
+ foreach (var groupId in family.GroupIds)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var group = byId[groupId];
+ for (var index = 0; index < group.Sequence.Times.Count; index++)
+ {
+ var time = group.Sequence.Times[index];
+ if (!batches.TryGetValue(time, out var lanes))
+ batches[time] = lanes = new HashSet();
+ lanes.UnionWith(group.Sequence.LanesByBatch[index]);
+ }
+ }
+ return Calculate(
+ batches.Keys.ToArray(),
+ batches.Values.Select(lanes => (IReadOnlyList)lanes.OrderBy(x => x).ToArray()).ToArray(),
+ cancellationToken: cancellationToken);
+ }
+
+ internal static HandMotionResult Calculate(
+ IReadOnlyList times,
+ IReadOnlyList> lanesByBatch,
+ IReadOnlyCollection? idleTransitionIndexes = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (times.Count == 0 || times.Count != lanesByBatch.Count)
+ throw new InvalidOperationException(
+ "Sweep hand-motion times and lane batches must have the same positive length.");
+ var lanes = lanesByBatch.Select(batch =>
+ (IReadOnlyList)batch.Distinct().OrderBy(x => x).ToArray()).ToArray();
+ var idle = idleTransitionIndexes is null
+ ? new HashSet() : idleTransitionIndexes.ToHashSet();
+ if (times.Select((time, index) => (time, index)).Any(item =>
+ double.IsNaN(item.time) || double.IsInfinity(item.time) ||
+ item.index > 0 && item.time <= times[item.index - 1]))
+ throw new InvalidOperationException("Sweep hand-motion times must be finite and increasing.");
+ if (lanes.Any(batch => batch.Count is < 1 or > 3 || batch.Any(x => x is < 1 or > 8)))
+ throw new InvalidOperationException("A Sweep hand-motion batch needs one to three outer lanes.");
+
+ var states = new Dictionary
+ {
+ [new MotionState(null, null, null, null)] = new MotionRecord()
+ };
+ var optionCache = new Dictionary>();
+ for (var batchIndex = 0; batchIndex < times.Count; batchIndex++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var laneMask = lanes[batchIndex].Aggregate(0, (mask, lane) => mask | 1 << (lane - 1));
+ if (!optionCache.TryGetValue(laneMask, out var options))
+ optionCache[laneMask] = options = Options(lanes[batchIndex]);
+ var next = new Dictionary();
+ foreach (var pair in states)
+ foreach (var option in options)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var previousLeftUsed = pair.Key.LeftBatch == batchIndex - 1;
+ var previousRightUsed = pair.Key.RightBatch == batchIndex - 1;
+ var left = Cost(pair.Key.Left, option.LeftTarget, pair.Key.LeftBatch,
+ batchIndex, times, idle.Contains(batchIndex));
+ var right = Cost(pair.Key.Right, option.RightTarget, pair.Key.RightBatch,
+ batchIndex, times, idle.Contains(batchIndex));
+ var usedLeft = option.LeftTarget is not null;
+ var usedRight = option.RightTarget is not null;
+ var takeover = lanes[batchIndex].Count == 1 &&
+ (usedLeft && !previousLeftUsed && previousRightUsed ||
+ usedRight && !previousRightUsed && previousLeftUsed) ? 1 : 0;
+ var state = new MotionState(
+ usedLeft ? option.LeftTarget : pair.Key.Left,
+ usedRight ? option.RightTarget : pair.Key.Right,
+ usedLeft ? batchIndex : pair.Key.Left is null ? null : -1,
+ usedRight ? batchIndex : pair.Key.Right is null ? null : -1);
+ var fastJumps = pair.Value.FastJumps + left.Fast + right.Fast;
+ var totalDistance = pair.Value.TotalDistance + left.Distance + right.Distance;
+ var activeDistance = pair.Value.ActiveDistance + left.Active + right.Active;
+ var idleDistance = pair.Value.IdleDistance + left.Idle + right.Idle;
+ var takeovers = pair.Value.Takeovers + takeover;
+ var lexKey = new MotionLexKey(
+ pair.Value.LexRank, state.Left ?? 0, state.Right ?? 0);
+ if (next.TryGetValue(state, out var previous) && CompareCandidate(
+ fastJumps, takeovers, totalDistance, activeDistance, lexKey, previous) >= 0)
+ continue;
+ next[state] = new MotionRecord
+ {
+ FastJumps = fastJumps,
+ TotalDistance = totalDistance,
+ ActiveDistance = activeDistance,
+ IdleDistance = idleDistance,
+ Takeovers = takeovers,
+ Previous = pair.Value,
+ Length = pair.Value.Length + 1,
+ LexKey = lexKey,
+ Time = times[batchIndex],
+ LeftLanes = option.LeftLanes,
+ RightLanes = option.RightLanes,
+ LeftPosition = state.Left,
+ RightPosition = state.Right,
+ LeftIdleDistance = left.Idle,
+ RightIdleDistance = right.Idle,
+ AssignmentTakeover = takeover,
+ AssignmentFastJumps = left.Fast + right.Fast
+ };
+ }
+
+ var ranks = ArrayPool.Shared.Rent(next.Count);
+ var rankIndex = 0;
+ foreach (var record in next.Values) ranks[rankIndex++] = record.LexKey;
+ Array.Sort(ranks, 0, next.Count);
+ var uniqueRankCount = 0;
+ for (var index = 0; index < next.Count; index++)
+ if (uniqueRankCount == 0 || !ranks[index].Equals(ranks[uniqueRankCount - 1]))
+ ranks[uniqueRankCount++] = ranks[index];
+ try
+ {
+ foreach (var record in next.Values)
+ record.LexRank = Array.BinarySearch(
+ ranks, 0, uniqueRankCount, record.LexKey);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(ranks);
+ }
+ states = next;
+ }
+ var best = states.Values.Aggregate((left, right) => Compare(left, right) <= 0 ? left : right);
+ var assignments = new HandAssignment[best.Length];
+ for (var record = best; record.Previous is not null; record = record.Previous)
+ assignments[record.Length - 1] = new HandAssignment
+ {
+ Time = record.Time,
+ LeftLanes = record.LeftLanes,
+ RightLanes = record.RightLanes,
+ LeftPosition = record.LeftPosition,
+ RightPosition = record.RightPosition,
+ LeftIdleDistance = record.LeftIdleDistance,
+ RightIdleDistance = record.RightIdleDistance,
+ FreeHandTakeover = record.AssignmentTakeover,
+ FastJumpViolations = record.AssignmentFastJumps
+ };
+ return new HandMotionResult
+ {
+ TotalDistance = best.TotalDistance,
+ ActiveDistance = best.ActiveDistance,
+ IdleDistance = best.IdleDistance,
+ FreeHandTakeovers = best.Takeovers,
+ FastJumpViolations = best.FastJumps,
+ Assignments = assignments
+ };
+ }
+
+ private static int CompareCandidate(
+ int fastJumps,
+ int takeovers,
+ int totalDistance,
+ int activeDistance,
+ MotionLexKey lexKey,
+ MotionRecord right)
+ {
+ var result = fastJumps.CompareTo(right.FastJumps);
+ if (result != 0) return result;
+ result = takeovers.CompareTo(right.Takeovers);
+ if (result != 0) return result;
+ result = totalDistance.CompareTo(right.TotalDistance);
+ if (result != 0) return result;
+ result = activeDistance.CompareTo(right.ActiveDistance);
+ return result != 0 ? result : lexKey.CompareTo(right.LexKey);
+ }
+
+ private static IReadOnlyList Options(IReadOnlyList lanes)
+ {
+ if (lanes.Count == 1)
+ {
+ var lane = lanes[0];
+ return new[]
+ {
+ Option(new[] { lane }, Array.Empty(), lane, null),
+ Option(Array.Empty(), new[] { lane }, null, lane)
+ };
+ }
+ if (lanes.Count == 2)
+ {
+ return new[]
+ {
+ Option(new[] { lanes[0] }, new[] { lanes[1] }, lanes[0], lanes[1]),
+ Option(new[] { lanes[1] }, new[] { lanes[0] }, lanes[1], lanes[0])
+ };
+ }
+ var output = new List();
+ for (var singleIndex = 0; singleIndex < 3; singleIndex++)
+ {
+ var single = lanes[singleIndex];
+ var pair = lanes.Where((_, index) => index != singleIndex).ToArray();
+ if (SweepRecognizer.CircularDistance(pair[0], pair[1]) != 1) continue;
+ foreach (var pairPosition in pair)
+ {
+ output.Add(Option(pair, new[] { single }, pairPosition, single));
+ output.Add(Option(new[] { single }, pair, single, pairPosition));
+ }
+ }
+ return output;
+ }
+
+ private static AssignmentOption Option(
+ IReadOnlyList left, IReadOnlyList right, int? leftTarget, int? rightTarget) =>
+ new() { LeftLanes = left, RightLanes = right, LeftTarget = leftTarget, RightTarget = rightTarget };
+
+ private static MoveCost Cost(
+ int? previous, int? target, int? lastBatch, int batchIndex,
+ IReadOnlyList times, bool forceIdle)
+ {
+ if (target is null || previous is null) return new MoveCost(0, 0, 0, 0);
+ var distance = SweepRecognizer.CircularDistance(previous.Value, target.Value);
+ var active = lastBatch == batchIndex - 1 && !forceIdle;
+ var fast = 0;
+ if (active && batchIndex > 0)
+ {
+ var allowed = Math.Max(1, (int)Math.Floor(
+ (times[batchIndex] - times[batchIndex - 1]) / ReferenceStepSeconds + 1e-9));
+ fast = distance > allowed ? 1 : 0;
+ }
+ return new MoveCost(distance, active ? distance : 0, active ? 0 : distance, fast);
+ }
+
+ private static int Compare(MotionRecord left, MotionRecord right)
+ {
+ var result = left.FastJumps.CompareTo(right.FastJumps);
+ if (result != 0) return result;
+ result = left.Takeovers.CompareTo(right.Takeovers);
+ if (result != 0) return result;
+ result = left.TotalDistance.CompareTo(right.TotalDistance);
+ if (result != 0) return result;
+ result = left.ActiveDistance.CompareTo(right.ActiveDistance);
+ if (result != 0) return result;
+ return left.LexRank.CompareTo(right.LexRank);
+ }
+}
diff --git a/Runtime/Analysis/Features/SweepModel.cs b/Runtime/Analysis/Features/SweepModel.cs
new file mode 100644
index 0000000..5a451f0
--- /dev/null
+++ b/Runtime/Analysis/Features/SweepModel.cs
@@ -0,0 +1,107 @@
+using MajRadar.Core;
+
+namespace MajRadar.Analysis.Features;
+
+// Internal data passed between the three independently testable Sweep stages.
+internal sealed class SweepAttack
+{
+ internal int Id { get; set; }
+ internal int Lane { get; set; }
+ internal BeatPosition Beat { get; set; }
+ internal double TimeSeconds { get; set; }
+ internal IReadOnlyList EventIds { get; set; } = Array.Empty();
+ internal int NormalDeclarations { get; set; }
+ internal int ProtectedDeclarations { get; set; }
+}
+
+internal sealed class SweepStrand
+{
+ internal IReadOnlyList AttackIds { get; set; } = Array.Empty();
+ internal IReadOnlyList Lanes { get; set; } = Array.Empty();
+ internal int InitialDirection { get; set; }
+ internal int FinalDirection { get; set; }
+ internal int TurnCount { get; set; }
+}
+
+internal sealed class SweepSequence
+{
+ internal IReadOnlyList> LanesByBatch { get; set; } =
+ Array.Empty>();
+ internal IReadOnlyList Times { get; set; } = Array.Empty();
+ internal IReadOnlyList UnitTimeIntervals { get; set; } = Array.Empty();
+ internal IReadOnlyCollection SpeedSwitches { get; set; } = new HashSet();
+ internal IReadOnlyCollection DirectionSwitches { get; set; } = new HashSet();
+ internal IReadOnlyCollection WidthSwitches { get; set; } = new HashSet();
+ internal IReadOnlyCollection DoubleHandoffs { get; set; } = new HashSet();
+ internal IReadOnlyList NormalDeclarations { get; set; } = Array.Empty();
+ internal IReadOnlyList ProtectedDeclarations { get; set; } = Array.Empty();
+ internal IReadOnlyList Strands { get; set; } = Array.Empty();
+ internal IReadOnlyList Widths { get; set; } = Array.Empty();
+ internal BeatPosition StartBeat { get; set; }
+ internal BeatPosition EndBeat { get; set; }
+ internal int AttackCount { get; set; }
+ internal int BatchCount { get; set; }
+ internal double MedianIntervalSeconds { get; set; }
+
+ internal double StartTime => Times[0];
+ internal double EndTime => Times[^1];
+}
+
+internal sealed class ScoredSweepGroup
+{
+ internal int Id { get; set; }
+ internal SweepSequence Sequence { get; set; } = null!;
+ internal int? ParentId { get; set; }
+}
+
+internal sealed class SweepFamily
+{
+ internal int Id { get; set; }
+ internal IReadOnlyList GroupIds { get; set; } = Array.Empty();
+}
+
+internal sealed class HandAssignment
+{
+ internal double Time { get; set; }
+ internal IReadOnlyList LeftLanes { get; set; } = Array.Empty();
+ internal IReadOnlyList RightLanes { get; set; } = Array.Empty();
+ internal int? LeftPosition { get; set; }
+ internal int? RightPosition { get; set; }
+ internal int LeftIdleDistance { get; set; }
+ internal int RightIdleDistance { get; set; }
+ internal int FreeHandTakeover { get; set; }
+ internal int FastJumpViolations { get; set; }
+}
+
+internal sealed class HandMotionResult
+{
+ internal int TotalDistance { get; set; }
+ internal int ActiveDistance { get; set; }
+ internal int IdleDistance { get; set; }
+ internal int FreeHandTakeovers { get; set; }
+ internal int FastJumpViolations { get; set; }
+ internal IReadOnlyList Assignments { get; set; } =
+ Array.Empty();
+}
+
+internal sealed class SweepBurstWindow
+{
+ internal double Value { get; set; }
+ internal double BaseDensity { get; set; }
+ internal double MotionDensity { get; set; }
+ internal double RawMotionDensity { get; set; }
+ internal double Start { get; set; }
+ internal double End { get; set; }
+}
+
+internal sealed class SweepBurstResult
+{
+ internal double Value { get; set; }
+ internal double BaseDensity { get; set; }
+ internal double MotionDensity { get; set; }
+ internal double RawMotionDensity { get; set; }
+ internal double StrongestWindowStart { get; set; }
+ internal double StrongestWindowEnd { get; set; }
+ internal IReadOnlyList Windows { get; set; } =
+ Array.Empty();
+}
diff --git a/Runtime/Analysis/Features/SweepRecognizer.cs b/Runtime/Analysis/Features/SweepRecognizer.cs
new file mode 100644
index 0000000..4d54de9
--- /dev/null
+++ b/Runtime/Analysis/Features/SweepRecognizer.cs
@@ -0,0 +1,707 @@
+using System.Buffers;
+using System.Threading;
+using MajRadar.Core;
+
+namespace MajRadar.Analysis.Features;
+
+///
+/// Recognizes maximal one-spine, variable-width button sweeps. Scan states and
+/// prefix candidates are persistent/lightweight; only selected sequences expand
+/// their per-batch arrays.
+///
+internal static class SweepRecognizer
+{
+ internal const int MaximumStates = 300_000;
+ internal const int MaximumButtonAttacks = 30_000;
+ internal const int MaximumLightweightCandidates = 50_000;
+ internal const int MaximumSelectionStates = 100_000;
+ internal const double SpeedRelativeTolerance = 0.005;
+ private static readonly BeatPosition ShortHoldMaximum = new(1, 4);
+ private static readonly BeatPosition BaseMaximumUnit = new(1, 3);
+ private static readonly BeatPosition EighthBeat = new(1, 2);
+ private const double TimeTolerance = 1e-9;
+
+ private sealed class HoldOccupancy
+ {
+ internal int Lane { get; set; }
+ internal BeatPosition Start { get; set; }
+ internal BeatPosition End { get; set; }
+ }
+
+ private sealed class AttackBatch
+ {
+ internal BeatPosition Beat { get; set; }
+ internal double Time { get; set; }
+ internal IReadOnlyList AttackIds { get; set; } = Array.Empty();
+ }
+
+ private sealed class SpineState
+ {
+ internal SpineState? Previous { get; set; }
+ internal int BatchIndex { get; set; }
+ internal int StartBatchIndex { get; set; }
+ internal int EntryAttackId { get; set; }
+ internal int ExitAttackId { get; set; }
+ internal int Length { get; set; }
+ internal int? Direction { get; set; }
+ internal int RunSteps { get; set; }
+ internal int TurnCount { get; set; }
+ internal BeatPosition? UnitBeatInterval { get; set; }
+ internal double? UnitTimeInterval { get; set; }
+ internal bool SpeedSwitched { get; set; }
+ internal bool DirectionSwitched { get; set; }
+ internal int SpeedSwitchCount { get; set; }
+ internal int DirectionSwitchCount { get; set; }
+ internal int WidthSwitchCount { get; set; }
+ internal int HandoffCount { get; set; }
+ internal int LaneMask { get; set; }
+ internal int EntryLexRank { get; set; }
+ internal int ExitLexRank { get; set; }
+ }
+
+ private sealed class Candidate
+ {
+ internal SpineState State { get; set; } = null!;
+ internal int OriginalIndex { get; set; }
+ internal int StartBatch => State.StartBatchIndex;
+ internal int EndBatch => State.BatchIndex;
+ internal int EventCount { get; set; }
+ internal int LaneCount { get; set; }
+ }
+
+ private sealed class Selection
+ {
+ internal static readonly Selection Empty = new();
+ internal int EventCount { get; set; }
+ internal int SequenceCount { get; set; }
+ internal int BatchCount { get; set; }
+ internal int[] CandidateIndexes { get; set; } = Array.Empty();
+
+ internal Selection Add(Candidate candidate)
+ {
+ var indexes = new int[CandidateIndexes.Length + 1];
+ var insert = Array.BinarySearch(CandidateIndexes, candidate.OriginalIndex);
+ insert = insert < 0 ? ~insert : insert;
+ Array.Copy(CandidateIndexes, 0, indexes, 0, insert);
+ indexes[insert] = candidate.OriginalIndex;
+ Array.Copy(CandidateIndexes, insert, indexes, insert + 1,
+ CandidateIndexes.Length - insert);
+ return new Selection
+ {
+ EventCount = EventCount + candidate.EventCount,
+ SequenceCount = SequenceCount + 1,
+ BatchCount = BatchCount + candidate.State.Length,
+ CandidateIndexes = indexes
+ };
+ }
+ }
+
+ private readonly struct StateKey : IEquatable
+ {
+ private readonly int _exit;
+ private readonly int? _direction;
+ private readonly int _run;
+ private readonly double? _seconds;
+ private readonly BeatPosition? _beats;
+
+ internal StateKey(SpineState state)
+ {
+ _exit = state.ExitAttackId;
+ _direction = state.Direction;
+ _run = Math.Min(state.RunSteps, 2);
+ _seconds = state.UnitTimeInterval is double seconds
+ ? Math.Round(seconds, 9) : null;
+ _beats = state.UnitBeatInterval;
+ }
+
+ public bool Equals(StateKey other) => _exit == other._exit &&
+ _direction == other._direction && _run == other._run &&
+ _seconds == other._seconds && _beats == other._beats;
+ public override bool Equals(object? obj) => obj is StateKey other && Equals(other);
+ public override int GetHashCode() =>
+ HashCode.Combine(_exit, _direction, _run, _seconds, _beats);
+ }
+
+ private readonly struct LexKey : IComparable, IEquatable
+ {
+ internal LexKey(int length, int prefix, int current)
+ { Length = length; Prefix = prefix; Current = current; }
+ internal int Length { get; }
+ internal int Prefix { get; }
+ internal int Current { get; }
+ public int CompareTo(LexKey other)
+ {
+ var result = Length.CompareTo(other.Length);
+ if (result != 0) return result;
+ result = Prefix.CompareTo(other.Prefix);
+ return result != 0 ? result : Current.CompareTo(other.Current);
+ }
+ public bool Equals(LexKey other) =>
+ Length == other.Length && Prefix == other.Prefix && Current == other.Current;
+ public override bool Equals(object? obj) => obj is LexKey other && Equals(other);
+ public override int GetHashCode() => HashCode.Combine(Length, Prefix, Current);
+ }
+
+ internal static IReadOnlyList ButtonAttacks(IReadOnlyList events)
+ {
+ var grouped = new Dictionary<(BeatPosition Beat, int Lane), List>();
+ foreach (var item in events)
+ {
+ if (item.Kind is not (RadarEventKind.Tap or RadarEventKind.Hold)) continue;
+ if (item.Kind == RadarEventKind.Hold && item.EndBeat - item.StartBeat > ShortHoldMaximum)
+ continue;
+ if (!int.TryParse(item.Position, out var lane) || lane is < 1 or > 8)
+ throw new InvalidOperationException("Button event is missing a valid outer lane.");
+ var key = (item.StartBeat, lane);
+ if (!grouped.TryGetValue(key, out var declarations))
+ grouped[key] = declarations = new List();
+ declarations.Add(item);
+ }
+
+ var output = new List();
+ foreach (var pair in grouped.OrderBy(pair => pair.Key.Beat).ThenBy(pair => pair.Key.Lane))
+ {
+ var declarations = pair.Value.OrderBy(item => item.EventId).ToArray();
+ var time = declarations[0].StartTimeSeconds;
+ if (declarations.Skip(1).Any(item => Math.Abs(item.StartTimeSeconds - time) > 1e-12))
+ throw new InvalidOperationException("Same-beat duplicate attacks disagree on chart time.");
+ output.Add(new SweepAttack
+ {
+ Id = output.Count,
+ Lane = pair.Key.Lane,
+ Beat = pair.Key.Beat,
+ TimeSeconds = time,
+ EventIds = declarations.Select(item => item.EventId).ToArray(),
+ NormalDeclarations = declarations.Count(item => item.IsEx != true),
+ ProtectedDeclarations = declarations.Count(item => item.IsEx == true)
+ });
+ }
+ return output;
+ }
+
+ internal static IReadOnlyList Recognize(
+ IReadOnlyList events,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var attacks = ButtonAttacks(events);
+ if (attacks.Count > MaximumButtonAttacks)
+ throw new InvalidOperationException(
+ $"Sweep attack budget exceeded ({attacks.Count} > {MaximumButtonAttacks}).");
+ var batches = Batches(attacks);
+ var candidates = CandidateStates(
+ attacks, batches, LongHolds(events), cancellationToken);
+ var selected = SelectDisjoint(candidates, cancellationToken);
+ return selected.Select(candidate => ToSequence(candidate.State, attacks, batches))
+ .OrderBy(item => item, Comparer.Create(CompareSequenceOrder))
+ .ToArray();
+ }
+
+ private static IReadOnlyList LongHolds(IReadOnlyList events) =>
+ events.Where(item => item.Kind == RadarEventKind.Hold &&
+ item.EndBeat - item.StartBeat > ShortHoldMaximum)
+ .Select(item => new HoldOccupancy
+ {
+ Lane = int.TryParse(item.Position, out var lane) ? lane :
+ throw new InvalidOperationException("Hold event is missing a valid outer lane."),
+ Start = item.StartBeat,
+ End = item.EndBeat
+ }).ToArray();
+
+ private static IReadOnlyList Batches(IReadOnlyList attacks) =>
+ attacks.GroupBy(item => item.Beat).OrderBy(group => group.Key).Select(group =>
+ {
+ var members = group.OrderBy(item => item.Lane).ThenBy(item => item.Id).ToArray();
+ var time = members[0].TimeSeconds;
+ if (members.Skip(1).Any(item => Math.Abs(item.TimeSeconds - time) > 1e-12))
+ throw new InvalidOperationException("Same-beat attacks disagree on chart time.");
+ return new AttackBatch
+ {
+ Beat = group.Key,
+ Time = time,
+ AttackIds = members.Select(item => item.Id).ToArray()
+ };
+ }).ToArray();
+
+ private static IReadOnlyList CandidateStates(
+ IReadOnlyList attacks,
+ IReadOnlyList batches,
+ IReadOnlyList holds,
+ CancellationToken cancellationToken)
+ {
+ var attackPrefix = new int[batches.Count + 1];
+ var eventPrefix = new int[batches.Count + 1];
+ for (var index = 0; index < batches.Count; index++)
+ {
+ attackPrefix[index + 1] = attackPrefix[index] + batches[index].AttackIds.Count;
+ eventPrefix[index + 1] = eventPrefix[index] + batches[index].AttackIds
+ .Sum(id => attacks[id].EventIds.Count);
+ }
+
+ var orderedKeys = new List<(int Start, int End)>();
+ var candidates = new Dictionary<(int Start, int End), Candidate>();
+ var active = new List();
+ var visited = 0;
+ for (var batchIndex = 0; batchIndex < batches.Count; batchIndex++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var batch = batches[batchIndex];
+ if (batch.AttackIds.Count > 3)
+ {
+ active.Clear();
+ continue;
+ }
+
+ var next = batch.AttackIds.Select(attackId => InitialState(
+ batchIndex, attackId, attacks[attackId].Lane)).ToList();
+ foreach (var state in active)
+ foreach (var entry in batch.AttackIds)
+ foreach (var exit in batch.AttackIds)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var advanced = Advance(
+ state, batchIndex, entry, exit, attacks, batches, holds);
+ if (advanced is not null) next.Add(advanced);
+ }
+
+ var keys = new List();
+ var deduplicated = new Dictionary();
+ AssignLexRanks(next, entry: true);
+ AssignLexRanks(next, entry: false);
+ foreach (var state in next)
+ {
+ if (++visited > MaximumStates)
+ throw new InvalidOperationException(
+ "Sweep main-spine state budget exceeded; no partial result returned.");
+ var key = new StateKey(state);
+ if (!deduplicated.TryGetValue(key, out var previous))
+ {
+ keys.Add(key);
+ deduplicated[key] = state;
+ }
+ else if (CompareStateQuality(state, previous) > 0)
+ deduplicated[key] = state;
+ }
+ active = keys.Select(key => deduplicated[key]).ToList();
+ foreach (var state in active.Where(Complete))
+ {
+ var range = (state.StartBatchIndex, state.BatchIndex);
+ var candidate = new Candidate
+ {
+ State = state,
+ EventCount = eventPrefix[range.Item2 + 1] - eventPrefix[range.Item1],
+ LaneCount = attackPrefix[range.Item2 + 1] - attackPrefix[range.Item1]
+ };
+ if (!candidates.TryGetValue(range, out var previous))
+ {
+ if (orderedKeys.Count >= MaximumLightweightCandidates)
+ throw new InvalidOperationException(
+ $"Sweep lightweight candidate budget exceeded ({MaximumLightweightCandidates}).");
+ candidate.OriginalIndex = orderedKeys.Count;
+ orderedKeys.Add(range);
+ candidates[range] = candidate;
+ }
+ else if (CompareCandidateQuality(candidate, previous) > 0)
+ {
+ candidate.OriginalIndex = previous.OriginalIndex;
+ candidates[range] = candidate;
+ }
+ }
+ }
+
+ var unique = orderedKeys.Select(key => candidates[key]).ToArray();
+ var retained = new bool[unique.Length];
+ var furthestEnd = -1;
+ foreach (var candidate in unique.OrderBy(item => item.StartBatch)
+ .ThenByDescending(item => item.EndBatch)
+ .ThenBy(item => item.OriginalIndex))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (candidate.EndBatch <= furthestEnd) continue;
+ retained[candidate.OriginalIndex] = true;
+ furthestEnd = candidate.EndBatch;
+ }
+ return unique.Where(item => retained[item.OriginalIndex]).ToArray();
+ }
+
+ private static SpineState InitialState(int batchIndex, int attackId, int lane) => new()
+ {
+ BatchIndex = batchIndex,
+ StartBatchIndex = batchIndex,
+ EntryAttackId = attackId,
+ ExitAttackId = attackId,
+ Length = 1,
+ LaneMask = 1 << (lane - 1)
+ };
+
+ private static SpineState? Advance(
+ SpineState state,
+ int batchIndex,
+ int entryId,
+ int exitId,
+ IReadOnlyList attacks,
+ IReadOnlyList batches,
+ IReadOnlyList holds)
+ {
+ var move = Move(attacks[state.ExitAttackId], attacks[entryId], holds);
+ if (move is null) return null;
+ var (direction, units) = move.Value;
+ var previousBatch = batches[state.BatchIndex];
+ var currentBatch = batches[batchIndex];
+ var gapBeat = currentBatch.Beat - previousBatch.Beat;
+ var gapTime = currentBatch.Time - previousBatch.Time;
+ if (gapBeat <= BeatPosition.Zero || gapTime <= 0) return null;
+ var unitBeat = Divide(gapBeat, units);
+ var unitTime = gapTime / units;
+
+ bool speedSwitch;
+ if (state.UnitTimeInterval is null)
+ {
+ if (unitBeat > BaseMaximumUnit) return null;
+ speedSwitch = false;
+ }
+ else
+ {
+ var previousTime = state.UnitTimeInterval.Value;
+ speedSwitch = !SameSpeed(unitTime, previousTime, SpeedRelativeTolerance);
+ var ordinary = unitBeat <= BaseMaximumUnit;
+ var continuingEighth = unitBeat == EighthBeat &&
+ state.UnitBeatInterval == EighthBeat && !speedSwitch;
+ var deceleratingToEighth = unitBeat == EighthBeat && speedSwitch &&
+ unitTime > previousTime;
+ if (!ordinary && !continuingEighth && !deceleratingToEighth) return null;
+ }
+
+ var directionSwitched = false;
+ int runSteps, turns;
+ if (state.Direction is null)
+ {
+ runSteps = 1;
+ turns = state.TurnCount;
+ }
+ else if (state.Direction == direction)
+ {
+ runSteps = state.RunSteps + 1;
+ turns = state.TurnCount;
+ }
+ else
+ {
+ if (state.RunSteps < 2) return null;
+ runSteps = 1;
+ turns = state.TurnCount + 1;
+ directionSwitched = true;
+ }
+ return new SpineState
+ {
+ Previous = state,
+ BatchIndex = batchIndex,
+ StartBatchIndex = state.StartBatchIndex,
+ EntryAttackId = entryId,
+ ExitAttackId = exitId,
+ Length = state.Length + 1,
+ Direction = direction,
+ RunSteps = runSteps,
+ TurnCount = turns,
+ UnitBeatInterval = unitBeat,
+ UnitTimeInterval = unitTime,
+ SpeedSwitched = speedSwitch,
+ DirectionSwitched = directionSwitched,
+ SpeedSwitchCount = state.SpeedSwitchCount + (speedSwitch ? 1 : 0),
+ DirectionSwitchCount = state.DirectionSwitchCount + (directionSwitched ? 1 : 0),
+ WidthSwitchCount = state.WidthSwitchCount +
+ (previousBatch.AttackIds.Count != currentBatch.AttackIds.Count ? 1 : 0),
+ HandoffCount = state.HandoffCount + (entryId != exitId ? 1 : 0),
+ LaneMask = state.LaneMask |
+ 1 << (attacks[entryId].Lane - 1) |
+ 1 << (attacks[exitId].Lane - 1)
+ };
+ }
+
+ private static (int Direction, int Units)? Move(
+ SweepAttack left, SweepAttack right, IReadOnlyList holds)
+ {
+ var clockwise = Mod(right.Lane - left.Lane, 8);
+ var counterclockwise = Mod(left.Lane - right.Lane, 8);
+ if (clockwise == 1) return (1, 1);
+ if (counterclockwise == 1) return (-1, 1);
+ var direction = clockwise == 2 ? 1 : counterclockwise == 2 ? -1 : 0;
+ if (direction == 0) return null;
+ var skipped = Ring(left.Lane + direction);
+ return holds.Any(hold => hold.Lane == skipped && hold.Start <= left.Beat &&
+ hold.End >= right.Beat)
+ ? (direction, 2) : null;
+ }
+
+ private static bool Complete(SpineState state) =>
+ state.Length >= 3 && state.Direction is not null &&
+ (state.TurnCount == 0 || state.RunSteps >= 2) && CountBits(state.LaneMask) >= 3;
+
+ private static SweepSequence ToSequence(
+ SpineState state,
+ IReadOnlyList attacks,
+ IReadOnlyList batches)
+ {
+ var chain = new SpineState[state.Length];
+ for (var current = state; current is not null; current = current.Previous)
+ chain[current.Length - 1] = current;
+ var widths = new int[chain.Length];
+ var lanes = new IReadOnlyList[chain.Length];
+ var times = new double[chain.Length];
+ var normal = new int[chain.Length];
+ var protectedCounts = new int[chain.Length];
+ var unitTimes = new double[chain.Length - 1];
+ var mainAttackIds = new int[chain.Length];
+ var mainLanes = new int[chain.Length];
+ var speedSwitches = new HashSet();
+ var directionSwitches = new HashSet();
+ var widthSwitches = new HashSet();
+ var doubleHandoffs = new HashSet();
+
+ for (var index = 0; index < chain.Length; index++)
+ {
+ var item = chain[index];
+ var batch = batches[item.BatchIndex];
+ widths[index] = batch.AttackIds.Count;
+ lanes[index] = batch.AttackIds.Select(id => attacks[id].Lane).ToArray();
+ times[index] = batch.Time;
+ normal[index] = batch.AttackIds.Sum(id => attacks[id].NormalDeclarations);
+ protectedCounts[index] = batch.AttackIds.Sum(id => attacks[id].ProtectedDeclarations);
+ mainAttackIds[index] = item.EntryAttackId;
+ mainLanes[index] = attacks[item.EntryAttackId].Lane;
+ if (index == 0) continue;
+ unitTimes[index - 1] = item.UnitTimeInterval!.Value;
+ if (item.SpeedSwitched) speedSwitches.Add(index);
+ if (item.DirectionSwitched) directionSwitches.Add(index - 1);
+ if (widths[index - 1] != widths[index]) widthSwitches.Add(index);
+ if (item.EntryAttackId != item.ExitAttackId) doubleHandoffs.Add(index);
+ }
+
+ return new SweepSequence
+ {
+ LanesByBatch = lanes,
+ Times = times,
+ UnitTimeIntervals = unitTimes,
+ SpeedSwitches = speedSwitches,
+ DirectionSwitches = directionSwitches,
+ WidthSwitches = widthSwitches,
+ DoubleHandoffs = doubleHandoffs,
+ NormalDeclarations = normal,
+ ProtectedDeclarations = protectedCounts,
+ Widths = widths,
+ StartBeat = batches[chain[0].BatchIndex].Beat,
+ EndBeat = batches[chain[^1].BatchIndex].Beat,
+ AttackCount = widths.Sum(),
+ BatchCount = chain.Length,
+ MedianIntervalSeconds = Median(unitTimes),
+ Strands = new[]
+ {
+ new SweepStrand
+ {
+ AttackIds = mainAttackIds,
+ Lanes = mainLanes,
+ InitialDirection = chain[1].Direction!.Value,
+ FinalDirection = chain[^1].Direction!.Value,
+ TurnCount = state.TurnCount
+ }
+ }
+ };
+ }
+
+ private static IReadOnlyList SelectDisjoint(
+ IReadOnlyList candidates,
+ CancellationToken cancellationToken)
+ {
+ if (candidates.Count == 0) return Array.Empty();
+ var byOriginal = candidates.ToDictionary(item => item.OriginalIndex);
+ var components = new List