diff --git a/Backend.Tests/PolicyRunnerTests.cs b/Backend.Tests/PolicyRunnerTests.cs new file mode 100644 index 0000000..9c044cd --- /dev/null +++ b/Backend.Tests/PolicyRunnerTests.cs @@ -0,0 +1,55 @@ +using System.IO; +using Backend; +using Xunit; + +namespace Backend.Tests; + +public class PolicyRunnerTests +{ + private static string LocateOnnxModel() + { + var dir = AppContext.BaseDirectory; + while (dir is not null) + { + var candidate = Path.Combine(dir, "models", "ppo_lander.onnx"); + if (File.Exists(candidate)) return candidate; + var parent = Directory.GetParent(dir); + if (parent is null) break; + dir = parent.FullName; + } + throw new FileNotFoundException( + "models/ppo_lander.onnx not found — run `python Training/export_onnx.py ...`"); + } + + [Fact] + public void LoadsWithoutError() + { + using var runner = new PolicyRunner(LocateOnnxModel()); + } + + [Fact] + public void SelectAction_ReturnsValidActionForZeroObs() + { + using var runner = new PolicyRunner(LocateOnnxModel()); + var obs = new float[7]; // all zeros + int action = runner.SelectAction(obs); + Assert.InRange(action, 0, 3); + } + + [Fact] + public void SelectAction_IsDeterministicForSameInput() + { + using var runner = new PolicyRunner(LocateOnnxModel()); + var obs = new float[] { 0.1f, -0.2f, 0.05f, 0.0f, 0.0f, 1.0f, 0.0f }; + int a1 = runner.SelectAction(obs); + int a2 = runner.SelectAction(obs); + Assert.Equal(a1, a2); + } + + [Fact] + public void SelectAction_ThrowsIfObsLengthWrong() + { + using var runner = new PolicyRunner(LocateOnnxModel()); + Assert.Throws(() => runner.SelectAction(new float[3])); + } +} diff --git a/Backend/PolicyRunner.cs b/Backend/PolicyRunner.cs new file mode 100644 index 0000000..9ec10b0 --- /dev/null +++ b/Backend/PolicyRunner.cs @@ -0,0 +1,52 @@ +using Microsoft.ML.OnnxRuntime; +using Microsoft.ML.OnnxRuntime.Tensors; + +namespace Backend; + +/// +/// Loads a PPO policy ONNX model once and provides deterministic action selection. +/// Thread-safe: is safe for concurrent Run() calls. +/// +public sealed class PolicyRunner : IDisposable +{ + public const int ObsDim = 7; + public const int NActions = 4; + + private readonly InferenceSession _session; + private readonly string _inputName; + + public PolicyRunner(string onnxPath) + { + if (!File.Exists(onnxPath)) + throw new FileNotFoundException($"ONNX model not found at {onnxPath}"); + _session = new InferenceSession(onnxPath); + _inputName = _session.InputMetadata.Keys.First(); + } + + /// Run one inference and return the argmax action index. + public int SelectAction(ReadOnlySpan obs) + { + if (obs.Length != ObsDim) + throw new ArgumentException($"expected obs of length {ObsDim}, got {obs.Length}", nameof(obs)); + + var tensor = new DenseTensor(new[] { 1, ObsDim }); + for (int i = 0; i < ObsDim; i++) tensor[0, i] = obs[i]; + + using var results = _session.Run(new[] + { + NamedOnnxValue.CreateFromTensor(_inputName, tensor) + }); + + var logits = results.First().AsEnumerable().ToArray(); + // argmax + int best = 0; + float bestVal = logits[0]; + for (int i = 1; i < logits.Length; i++) + { + if (logits[i] > bestVal) { best = i; bestVal = logits[i]; } + } + return best; + } + + public void Dispose() => _session.Dispose(); +}