mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
refactor(decisioncore): engine foundation — dispatch, fail-safe, clock, serialization (M3.0)
M3.0 establishes the reducer infrastructure the scenario sub-milestones
(M3.1–M3.5) will build on. No domain-specific reducer behavior yet; only
kernel plumbing + a live SessionStarted / SessionRecovered / SessionAborted
lifecycle path.
Kernel (plan §2.5 / L.2 / L.3 / L.16):
- DecisionEngine dispatches on (Kind, KindSchemaVersion) to partial-class
handlers. Unhandled kinds produce a DeadEnd transition with a versioned
reason string — never a throw, never a mutation.
- Exception fail-safe (L.16): any handler exception is caught in the
kernel, StepIndex + LastAppliedSignalOrdinal still advance (so the
signal counts as processed and there is no retry loop), the transition
records DeadEndReason="reducer_exception" + SHA256-short StackTraceHash.
DecisionState is returned untouched beyond bookkeeping.
- Shared helpers for Taken / DeadEnd transition construction keep
partial-class handlers short and consistent.
State (plan §2.3 / L.3):
- Immutable DecisionState gets a mutable DecisionStateBuilder companion —
reducer handlers use state.ToBuilder().WithStage(...).Build() to
express "copy with changes" without retyping 20+ ctor arguments.
- Fluent helpers AddDeadline (replace-if-same-name), CancelDeadline,
WithCurrentEnrollmentPhase carry the most common reducer operations.
Time (plan §2.6 / L.7):
- IClock interface + SystemClock (wall-clock wrapper). The only way any
decision-relevant code can observe time going forward. VirtualClock
lives in the test assembly — never in shipping builds.
Serialization (plan §2.8 / §2.15 / L.14):
- Newtonsoft.Json added to DecisionCore (both net48 + net8.0 consume the
same DTOs with the same converters).
- UnknownFallbackEnumConverter<T> maps unrecognized enum strings to a
caller-provided fallback instead of throwing — lets an older backend
read a newer DecisionCore's rows without crashing; the verifier will
flag them as drift_tolerated in §2.10.
- DecisionCoreJsonSettings registers fallback converters for every
DecisionCore enum (DecisionSignalKind, EvidenceKind, HypothesisLevel,
SessionStage, SessionOutcome, DecisionEffectKind, EnrollmentPhase).
- SignalSerializer round-trips DecisionSignal JSONL with enforced
Evidence.Identifier and Evidence.Kind — a line without Evidence throws
just like constructor validation.
Lifecycle (DecisionEngine.Shared.cs):
- SessionStarted: anchors the first journal transition; rejects mid-flight
replay (StepIndex != 0) with a DeadEnd instead of silently resetting
hypotheses.
- SessionRecovered: neutral bookkeeping in M3.0 — WhiteGlove Part 1 → 2
transition lands in M3.4 DecisionEngine.WhiteGlovePart2.cs.
- SessionAborted: terminal, Outcome=Aborted, all deadlines cleared —
orchestrator admin-action path (plan §2.7 audit).
Test coverage (37 total):
- VirtualClockTests (6) — monotonic clock, Delay instant-return
- DecisionStateBuilderTests (5) — immutable semantics, deadline replace/cancel
- SerializationTests (6) — round-trip, UnknownFallback both string + numeric
- ReducerFailSafeTests (6) — null-arg, replay-on-active, unhandled kind,
SessionAborted clears deadlines, ReducerVersion
- SmokeTests (7) — smoke, updated: Reduce(SessionStarted) is a
real transition, not a throw
- HarnessTests (6) — empty-stream determinism, single-signal replay,
unknown-kind DeadEnd at harness level
- VirtualClock helper (instance) covered via Harness integration
Suite totals unchanged elsewhere: 507 Legacy + 557 Functions. Full solution
builds 0 warnings, 0 errors.
Plan: plans/REFACTOR_AGENT_V2.md §4 M3 (sub-milestone M3.0 Foundation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
5
|
||||
7
|
||||
|
||||
@@ -1 +1 @@
|
||||
1047
|
||||
1049
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
public sealed class DecisionStateBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_withoutChanges_returnsEquivalentState()
|
||||
{
|
||||
var original = DecisionState.CreateInitial("s", "t");
|
||||
var rebuilt = original.ToBuilder().Build();
|
||||
|
||||
Assert.NotSame(original, rebuilt);
|
||||
Assert.Equal(original.SessionId, rebuilt.SessionId);
|
||||
Assert.Equal(original.Stage, rebuilt.Stage);
|
||||
Assert.Equal(original.StepIndex, rebuilt.StepIndex);
|
||||
Assert.Equal(original.LastAppliedSignalOrdinal, rebuilt.LastAppliedSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithStage_returnsNewInstance_originalUnchanged()
|
||||
{
|
||||
var original = DecisionState.CreateInitial("s", "t");
|
||||
var updated = original.ToBuilder().WithStage(SessionStage.AwaitingHello).Build();
|
||||
|
||||
Assert.Equal(SessionStage.SessionStarted, original.Stage);
|
||||
Assert.Equal(SessionStage.AwaitingHello, updated.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDeadline_sameName_replacesExisting()
|
||||
{
|
||||
var original = DecisionState.CreateInitial("s", "t");
|
||||
var dl1 = new ActiveDeadline(
|
||||
name: "hello_safety",
|
||||
dueAtUtc: new DateTime(2026, 4, 20, 10, 5, 0, DateTimeKind.Utc),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired);
|
||||
var dl2 = new ActiveDeadline(
|
||||
name: "hello_safety",
|
||||
dueAtUtc: new DateTime(2026, 4, 20, 10, 10, 0, DateTimeKind.Utc),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired);
|
||||
|
||||
var result = original.ToBuilder().AddDeadline(dl1).AddDeadline(dl2).Build();
|
||||
|
||||
Assert.Single(result.Deadlines);
|
||||
Assert.Equal(dl2.DueAtUtc, result.Deadlines[0].DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelDeadline_removesByName()
|
||||
{
|
||||
var original = DecisionState.CreateInitial("s", "t");
|
||||
var dl = new ActiveDeadline(
|
||||
name: "hello_safety",
|
||||
dueAtUtc: DateTime.UtcNow.AddMinutes(5),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired);
|
||||
|
||||
var withDeadline = original.ToBuilder().AddDeadline(dl).Build();
|
||||
var cancelled = withDeadline.ToBuilder().CancelDeadline("hello_safety").Build();
|
||||
|
||||
Assert.Single(withDeadline.Deadlines);
|
||||
Assert.Empty(cancelled.Deadlines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithCurrentEnrollmentPhase_stampsSourceOrdinal()
|
||||
{
|
||||
var original = DecisionState.CreateInitial("s", "t");
|
||||
var updated = original.ToBuilder()
|
||||
.WithCurrentEnrollmentPhase(EnrollmentPhase.AccountSetup, sourceSignalOrdinal: 7)
|
||||
.Build();
|
||||
|
||||
Assert.Equal(EnrollmentPhase.AccountSetup, updated.CurrentEnrollmentPhase!.Value);
|
||||
Assert.Equal(7, updated.CurrentEnrollmentPhase!.SourceSignalOrdinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests.Harness
|
||||
{
|
||||
/// <summary>
|
||||
/// Load a DecisionSignal JSONL fixture from disk. Plan §4 M2 harness integration.
|
||||
/// Comment lines starting with '#' and blank lines are ignored.
|
||||
/// </summary>
|
||||
public static class FixtureLoader
|
||||
{
|
||||
public static IReadOnlyList<DecisionSignal> LoadJsonl(string path)
|
||||
{
|
||||
var signals = new List<DecisionSignal>();
|
||||
foreach (var rawLine in File.ReadAllLines(path))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0 || line[0] == '#') continue;
|
||||
signals.Add(SignalSerializer.Deserialize(line));
|
||||
}
|
||||
return signals;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests.Harness
|
||||
{
|
||||
/// <summary>
|
||||
/// Deterministic <see cref="IClock"/> for reducer / harness / scenario tests.
|
||||
/// Plan §2.6 — replay advances the clock to <c>signal.OccurredAtUtc</c> before each
|
||||
/// reduce call; every delay is instant. Never used at runtime.
|
||||
/// </summary>
|
||||
public sealed class VirtualClock : IClock
|
||||
{
|
||||
private DateTime _now;
|
||||
|
||||
public VirtualClock(DateTime initialUtcNow)
|
||||
{
|
||||
if (initialUtcNow.Kind != DateTimeKind.Utc && initialUtcNow.Kind != DateTimeKind.Unspecified)
|
||||
{
|
||||
throw new ArgumentException("VirtualClock expects UTC/unspecified DateTime.", nameof(initialUtcNow));
|
||||
}
|
||||
_now = DateTime.SpecifyKind(initialUtcNow, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
public DateTime UtcNow => _now;
|
||||
|
||||
public Task Delay(TimeSpan delay, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Advance(delay);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void SetUtcNow(DateTime utcNow)
|
||||
{
|
||||
utcNow = DateTime.SpecifyKind(utcNow, DateTimeKind.Utc);
|
||||
if (utcNow < _now)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"VirtualClock cannot go backwards (current={_now:O}, requested={utcNow:O}).");
|
||||
}
|
||||
_now = utcNow;
|
||||
}
|
||||
|
||||
public void Advance(TimeSpan delta)
|
||||
{
|
||||
if (delta < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(delta), "Advance delta must be non-negative.");
|
||||
}
|
||||
_now = _now.Add(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests.Harness
|
||||
{
|
||||
public sealed class VirtualClockTests
|
||||
{
|
||||
[Fact]
|
||||
public void UtcNow_reflectsInitialValue()
|
||||
{
|
||||
var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var clock = new VirtualClock(t0);
|
||||
Assert.Equal(t0, clock.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_increasesUtcNow()
|
||||
{
|
||||
var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var clock = new VirtualClock(t0);
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
Assert.Equal(t0.AddMinutes(5), clock.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_negative_throws()
|
||||
{
|
||||
var clock = new VirtualClock(DateTime.UtcNow);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => clock.Advance(TimeSpan.FromMinutes(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetUtcNow_backwards_throws()
|
||||
{
|
||||
var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var clock = new VirtualClock(t0);
|
||||
Assert.Throws<InvalidOperationException>(() => clock.SetUtcNow(t0.AddMinutes(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delay_completesImmediately_AndAdvancesClock()
|
||||
{
|
||||
var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var clock = new VirtualClock(t0);
|
||||
var expectedNow = t0.AddHours(7);
|
||||
|
||||
// In real wall-clock this would take 7 hours; VirtualClock returns instantly.
|
||||
var start = DateTime.UtcNow;
|
||||
await clock.Delay(TimeSpan.FromHours(7));
|
||||
var elapsed = DateTime.UtcNow - start;
|
||||
|
||||
Assert.Equal(expectedNow, clock.UtcNow);
|
||||
Assert.True(elapsed < TimeSpan.FromSeconds(1),
|
||||
$"VirtualClock.Delay should be instant, took {elapsed.TotalMilliseconds} ms.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delay_withCancelledToken_throwsOperationCanceled()
|
||||
{
|
||||
var clock = new VirtualClock(DateTime.UtcNow);
|
||||
using (var cts = new CancellationTokenSource())
|
||||
{
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||||
clock.Delay(TimeSpan.FromSeconds(1), cts.Token));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,11 +62,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_nonEmptyStream_M2Stub_throwsViaReducer()
|
||||
public void Replay_singleSessionStartedSignal_recordsOneTakenTransition()
|
||||
{
|
||||
// M1 stub: DecisionEngine.Reduce throws NotImplementedException.
|
||||
// The harness should surface that faithfully — no silent swallowing.
|
||||
// M3 replaces this test's expectation with real terminal-stage assertions.
|
||||
var harness = new ReplayHarness(new DecisionEngine());
|
||||
var signal = new DecisionSignal(
|
||||
sessionSignalOrdinal: 0,
|
||||
@@ -77,8 +74,36 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
sourceOrigin: "harness",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, "session:started", "init"));
|
||||
|
||||
Assert.Throws<NotImplementedException>(() =>
|
||||
harness.Replay("s", "t", new List<DecisionSignal> { signal }));
|
||||
var result = harness.Replay("s", "t", new List<DecisionSignal> { signal });
|
||||
|
||||
Assert.Single(result.Transitions);
|
||||
Assert.True(result.Transitions[0].Taken);
|
||||
Assert.Equal(SessionStage.SessionStarted, result.FinalState.Stage);
|
||||
Assert.Equal(1, result.FinalState.StepIndex);
|
||||
Assert.Equal(0, result.FinalState.LastAppliedSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_unknownSignalKind_recordsDeadEnd_stateUnchanged()
|
||||
{
|
||||
// An unhandled signal advances bookkeeping but leaves stage/outcome alone
|
||||
// and records a DeadEndReason — plan §2.5 fail-safe shape for M3.0 non-handlers.
|
||||
var harness = new ReplayHarness(new DecisionEngine());
|
||||
var signal = new DecisionSignal(
|
||||
sessionSignalOrdinal: 0,
|
||||
sessionTraceOrdinal: 0,
|
||||
kind: DecisionSignalKind.EspPhaseChanged, // not wired until M3.1
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
sourceOrigin: "harness",
|
||||
evidence: new Evidence(EvidenceKind.Raw, "ESP:test", "test"));
|
||||
|
||||
var result = harness.Replay("s", "t", new List<DecisionSignal> { signal });
|
||||
|
||||
var t = Assert.Single(result.Transitions);
|
||||
Assert.False(t.Taken);
|
||||
Assert.StartsWith("unhandled_signal_kind", t.DeadEndReason);
|
||||
Assert.Equal(SessionStage.SessionStarted, result.FinalState.Stage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan §2.5 L.16 — the reducer kernel catches handler exceptions, records them as
|
||||
/// <c>DeadEndReason="reducer_exception"</c> transitions, and leaves state unchanged
|
||||
/// except for bookkeeping. M3.0 has no handlers that throw naturally; we verify the
|
||||
/// kernel's fail-safe path by forcing a handler exception through a broken state.
|
||||
/// </summary>
|
||||
public sealed class ReducerFailSafeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Reduce_nullState_throws_ArgumentNullException()
|
||||
{
|
||||
// This is a programmer-error path — distinct from reducer-internal exceptions.
|
||||
var engine = new DecisionEngine();
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
engine.Reduce(null!, MakeSignal(DecisionSignalKind.SessionStarted)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reduce_nullSignal_throws_ArgumentNullException()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t");
|
||||
Assert.Throws<ArgumentNullException>(() => engine.Reduce(state, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reduce_sessionStartedInActiveState_producesDeadEnd_stateUnchanged()
|
||||
{
|
||||
// Build a state that is NOT at StepIndex 0 & SessionStarted — simulates mid-flight replay.
|
||||
var engine = new DecisionEngine();
|
||||
var active = DecisionState.CreateInitial("s", "t")
|
||||
.ToBuilder()
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
.WithStepIndex(5)
|
||||
.WithLastAppliedSignalOrdinal(4)
|
||||
.Build();
|
||||
|
||||
var signal = MakeSignal(DecisionSignalKind.SessionStarted, ordinal: 5);
|
||||
|
||||
var step = engine.Reduce(active, signal);
|
||||
|
||||
Assert.False(step.Transition.Taken);
|
||||
Assert.StartsWith("session_started_in_active_state", step.Transition.DeadEndReason);
|
||||
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||
Assert.Equal(6, step.NewState.StepIndex);
|
||||
Assert.Equal(5, step.NewState.LastAppliedSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reduce_unhandledKind_producesDeadEnd_withVersionedReason()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t");
|
||||
var signal = MakeSignal(DecisionSignalKind.EspPhaseChanged, schemaVersion: 1);
|
||||
|
||||
var step = engine.Reduce(state, signal);
|
||||
|
||||
Assert.False(step.Transition.Taken);
|
||||
Assert.Equal("unhandled_signal_kind:EspPhaseChanged:v1", step.Transition.DeadEndReason);
|
||||
Assert.Equal(SessionStage.SessionStarted, step.NewState.Stage);
|
||||
Assert.Equal(1, step.NewState.StepIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reduce_sessionAborted_terminatesWithAbortedOutcome_clearsDeadlines()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var withDeadline = DecisionState.CreateInitial("s", "t")
|
||||
.ToBuilder()
|
||||
.AddDeadline(new ActiveDeadline(
|
||||
name: "hello_safety",
|
||||
dueAtUtc: DateTime.UtcNow.AddMinutes(5),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired))
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
.Build();
|
||||
|
||||
var step = engine.Reduce(withDeadline, MakeSignal(DecisionSignalKind.SessionAborted));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.Aborted, step.NewState.Outcome);
|
||||
Assert.Empty(step.NewState.Deadlines);
|
||||
Assert.True(step.Transition.Taken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReducerVersion_matchesAssemblyVersion_stableAcrossReduces()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t");
|
||||
|
||||
var step1 = engine.Reduce(state, MakeSignal(DecisionSignalKind.SessionStarted, ordinal: 0));
|
||||
var step2 = engine.Reduce(step1.NewState, MakeSignal(DecisionSignalKind.SessionRecovered, ordinal: 1));
|
||||
|
||||
Assert.Equal("2.0.0.0", engine.ReducerVersion);
|
||||
Assert.Equal("2.0.0.0", step1.Transition.ReducerVersion);
|
||||
Assert.Equal("2.0.0.0", step2.Transition.ReducerVersion);
|
||||
}
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
DecisionSignalKind kind,
|
||||
long ordinal = 0,
|
||||
int schemaVersion = 1) =>
|
||||
new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: schemaVersion,
|
||||
occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"test:{kind}", "test"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
public sealed class SerializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void SignalSerializer_roundtrip_preservesAllFields()
|
||||
{
|
||||
var original = new DecisionSignal(
|
||||
sessionSignalOrdinal: 42,
|
||||
sessionTraceOrdinal: 99,
|
||||
kind: DecisionSignalKind.EspPhaseChanged,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
sourceOrigin: "EspAndHelloTracker",
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Derived,
|
||||
identifier: "esp-phase-detector-v1",
|
||||
summary: "AccountSetup observed in registry",
|
||||
rawPointer: "blob://events/42",
|
||||
derivationInputs: new Dictionary<string, string>
|
||||
{
|
||||
["registryKey"] = "HKLM\\...Autopilot\\EspStatus",
|
||||
["rawValue"] = "2",
|
||||
}),
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
["phase"] = "AccountSetup",
|
||||
});
|
||||
|
||||
var json = SignalSerializer.Serialize(original);
|
||||
var roundtripped = SignalSerializer.Deserialize(json);
|
||||
|
||||
Assert.Equal(original.SessionSignalOrdinal, roundtripped.SessionSignalOrdinal);
|
||||
Assert.Equal(original.SessionTraceOrdinal, roundtripped.SessionTraceOrdinal);
|
||||
Assert.Equal(original.Kind, roundtripped.Kind);
|
||||
Assert.Equal(original.KindSchemaVersion, roundtripped.KindSchemaVersion);
|
||||
Assert.Equal(original.OccurredAtUtc, roundtripped.OccurredAtUtc);
|
||||
Assert.Equal(DateTimeKind.Utc, roundtripped.OccurredAtUtc.Kind);
|
||||
Assert.Equal(original.SourceOrigin, roundtripped.SourceOrigin);
|
||||
Assert.Equal(original.Evidence.Kind, roundtripped.Evidence.Kind);
|
||||
Assert.Equal(original.Evidence.Identifier, roundtripped.Evidence.Identifier);
|
||||
Assert.Equal(original.Evidence.Summary, roundtripped.Evidence.Summary);
|
||||
Assert.Equal(original.Evidence.RawPointer, roundtripped.Evidence.RawPointer);
|
||||
Assert.Equal("HKLM\\...Autopilot\\EspStatus", roundtripped.Evidence.DerivationInputs!["registryKey"]);
|
||||
Assert.Equal("AccountSetup", roundtripped.Payload!["phase"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalSerializer_Deserialize_MissingEvidence_throws()
|
||||
{
|
||||
var json = "{\"SessionSignalOrdinal\":0,\"Kind\":\"SessionStarted\",\"KindSchemaVersion\":1,\"OccurredAtUtc\":\"2026-04-20T10:00:00Z\",\"SourceOrigin\":\"test\"}";
|
||||
Assert.Throws<JsonSerializationException>(() => SignalSerializer.Deserialize(json));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownFallbackEnumConverter_unknownValue_mapsToFallback()
|
||||
{
|
||||
// An old backend reading a row produced by a newer DecisionCore that added a
|
||||
// new Stage value must not crash — it reads it as Unknown instead.
|
||||
var settings = DecisionCoreJsonSettings.Create();
|
||||
var json = "\"NeueUnbekannteStage\"";
|
||||
|
||||
var result = JsonConvert.DeserializeObject<SessionStage>(json, settings);
|
||||
|
||||
Assert.Equal(SessionStage.Unknown, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownFallbackEnumConverter_knownValue_roundtripsByName()
|
||||
{
|
||||
var settings = DecisionCoreJsonSettings.Create();
|
||||
var original = SessionStage.AwaitingHello;
|
||||
|
||||
var json = JsonConvert.SerializeObject(original, settings);
|
||||
var roundtripped = JsonConvert.DeserializeObject<SessionStage>(json, settings);
|
||||
|
||||
Assert.Contains("AwaitingHello", json);
|
||||
Assert.Equal(original, roundtripped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownFallbackEnumConverter_legacyNumericValue_mapsIfDefined()
|
||||
{
|
||||
var settings = DecisionCoreJsonSettings.Create();
|
||||
// EnrollmentPhase.AccountSetup is integer value 4.
|
||||
var json = "4";
|
||||
|
||||
var result = JsonConvert.DeserializeObject<EnrollmentPhase>(json, settings);
|
||||
|
||||
Assert.Equal(EnrollmentPhase.AccountSetup, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownFallbackEnumConverter_outOfRangeNumeric_mapsToFallback()
|
||||
{
|
||||
var settings = DecisionCoreJsonSettings.Create();
|
||||
var json = "9999";
|
||||
|
||||
var result = JsonConvert.DeserializeObject<HypothesisLevel>(json, settings);
|
||||
|
||||
Assert.Equal(HypothesisLevel.Unknown, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,8 +67,10 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecisionEngine_Reduce_M1Stub_throwsNotImplemented()
|
||||
public void DecisionEngine_Reduce_SessionStarted_advancesStep()
|
||||
{
|
||||
// M3.0: Reduce is no longer a stub; SessionStarted handler advances StepIndex
|
||||
// and records a taken transition.
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t");
|
||||
var signal = new DecisionSignal(
|
||||
@@ -80,7 +82,14 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, "session:started", "test"));
|
||||
|
||||
Assert.Throws<NotImplementedException>(() => engine.Reduce(state, signal));
|
||||
var step = engine.Reduce(state, signal);
|
||||
|
||||
Assert.Equal(1, step.NewState.StepIndex);
|
||||
Assert.Equal(0, step.NewState.LastAppliedSignalOrdinal);
|
||||
Assert.True(step.Transition.Taken);
|
||||
Assert.Equal("SessionStarted", step.Transition.Trigger);
|
||||
Assert.Null(step.Transition.DeadEndReason);
|
||||
Assert.Empty(step.Effects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -18,4 +18,9 @@
|
||||
<ProjectReference Include="..\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Plan §2.8 — Newtonsoft.Json on both sides (Agent net48 + Backend net8.0) for DecisionCore DTOs. -->
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Engine
|
||||
{
|
||||
// Lifecycle + cross-scenario shared handlers. Plan §2.5 partial-class layout.
|
||||
public sealed partial class DecisionEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.SessionStarted"/>. Plan §2.7.
|
||||
/// <para>
|
||||
/// For a fresh session this is the first signal and the state already equals
|
||||
/// <see cref="DecisionState.CreateInitial(string, string)"/>. The handler still runs
|
||||
/// through the pipeline so the start is recorded as a journal transition (step 0) —
|
||||
/// this anchors the Inspector timeline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the engine sees <c>SessionStarted</c> on a state whose stage is already
|
||||
/// something other than <see cref="SessionStage.SessionStarted"/>, we treat it as a
|
||||
/// defensive no-op (dead-end) rather than silently reinitializing — replay of a
|
||||
/// truncated log should fail visibly, not reset hard-won hypotheses.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private DecisionStep HandleSessionStartedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
if (state.Stage != SessionStage.SessionStarted && state.StepIndex != 0)
|
||||
{
|
||||
var bookkeptDead = BumpStepBookkeeping(state, signal);
|
||||
return new DecisionStep(
|
||||
bookkeptDead,
|
||||
BuildDeadEndTransition(
|
||||
state: state,
|
||||
signal: signal,
|
||||
nextStepIndex: bookkeptDead.StepIndex,
|
||||
trigger: nameof(DecisionSignalKind.SessionStarted),
|
||||
deadEndReason: $"session_started_in_active_state:{state.Stage}"),
|
||||
Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
// Stage stays SessionStarted; this transition is the "we saw the start" anchor.
|
||||
var newState = state.ToBuilder()
|
||||
.WithStage(SessionStage.SessionStarted)
|
||||
.WithStepIndex(state.StepIndex + 1)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||
.Build();
|
||||
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: SessionStage.SessionStarted,
|
||||
nextStepIndex: newState.StepIndex,
|
||||
trigger: nameof(DecisionSignalKind.SessionStarted));
|
||||
|
||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.SessionRecovered"/>. Plan §2.7 sonder-case 1.
|
||||
/// <para>
|
||||
/// In M3.0 scope this is a generic bookkeeping handler. The White-Glove Part-1 →
|
||||
/// Part-2 post-reboot transition is implemented in <c>DecisionEngine.WhiteGlovePart2.cs</c>
|
||||
/// (M3.4) and takes precedence there when the prior stage was
|
||||
/// <see cref="SessionStage.WhiteGloveSealed"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private DecisionStep HandleSessionRecoveredV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
// M3.4 will add WhiteGlove-Part-2 transition here. For M3.0 the handler is a
|
||||
// neutral "observed a restart" step — stage unchanged, StepIndex advanced.
|
||||
var newState = BumpStepBookkeeping(state, signal);
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: state.Stage,
|
||||
nextStepIndex: newState.StepIndex,
|
||||
trigger: nameof(DecisionSignalKind.SessionRecovered));
|
||||
|
||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.SessionAborted"/>.
|
||||
/// <para>
|
||||
/// Emitted by the orchestrator, never by a collector. Stage transitions to
|
||||
/// <see cref="SessionStage.Failed"/> with <see cref="SessionOutcome.Aborted"/>.
|
||||
/// This is a terminal event; the orchestrator uses it to record admin-kill /
|
||||
/// override actions cleanly without going through the regular completion paths
|
||||
/// (plan §2.7 admin-action audit).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private DecisionStep HandleSessionAbortedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var nextStep = state.StepIndex + 1;
|
||||
var newState = state.ToBuilder()
|
||||
.WithStage(SessionStage.Failed)
|
||||
.WithOutcome(SessionOutcome.Aborted)
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||
.ClearDeadlines()
|
||||
.Build();
|
||||
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: SessionStage.Failed,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.SessionAborted));
|
||||
|
||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
// ============================================================== shared helpers
|
||||
// Partial-class shared helpers used by Classic / SelfDeploying / WhiteGlove handlers
|
||||
// as they come online in M3.1+ live below. M3.0 establishes the skeleton; the bodies
|
||||
// grow with each sub-milestone.
|
||||
|
||||
/// <summary>
|
||||
/// Determine the user-visible enrollment phase implied by an ESP phase-change signal.
|
||||
/// Plan §2.3 phase-fact mapping. Populated in M3.1 as Classic handlers come online.
|
||||
/// </summary>
|
||||
internal static EnrollmentPhase MapEspPhaseToEnrollmentPhase(string rawPhase)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rawPhase)) return EnrollmentPhase.Unknown;
|
||||
return rawPhase switch
|
||||
{
|
||||
"DeviceSetup" => EnrollmentPhase.DeviceSetup,
|
||||
"AccountSetup" => EnrollmentPhase.AccountSetup,
|
||||
"FinalizingSetup" => EnrollmentPhase.FinalizingSetup,
|
||||
"Finalizing" => EnrollmentPhase.FinalizingSetup,
|
||||
"Complete" => EnrollmentPhase.Complete,
|
||||
_ => EnrollmentPhase.Unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Kernel stub for the Decision Engine. Plan §2.5 + M1 gate.
|
||||
/// Decision Engine kernel. Plan §2.5 / L.2 / L.3.
|
||||
/// <para>
|
||||
/// M1 scaffolding — no handler dispatch implemented. <see cref="Reduce"/> throws
|
||||
/// <see cref="NotImplementedException"/> so that any accidental runtime call from
|
||||
/// the Legacy-Agent or Backend fails loudly during the scaffolding phase (plan:
|
||||
/// "Agent + Backend referenzieren, ohne Warnings, keine Call-Sites").
|
||||
/// Dispatches on <c>(<see cref="DecisionSignalKind"/>, <see cref="DecisionSignal.KindSchemaVersion"/>)</c>
|
||||
/// to partial-class handlers. All handlers live in sibling files named
|
||||
/// <c>DecisionEngine.Classic.cs</c>, <c>DecisionEngine.SelfDeploying.cs</c>,
|
||||
/// <c>DecisionEngine.WhiteGlove.cs</c>, <c>DecisionEngine.WhiteGlovePart2.cs</c>,
|
||||
/// <c>DecisionEngine.Shared.cs</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// M3 replaces this body with the partial-class dispatcher
|
||||
/// (<c>DecisionEngine.Classic.cs</c>, <c>.SelfDeploying.cs</c>, <c>.WhiteGlove.cs</c>,
|
||||
/// <c>.WhiteGlovePart2.cs</c>, <c>.Shared.cs</c>) and the dispatch table is keyed on
|
||||
/// <c>(DecisionSignalKind, KindSchemaVersion)</c>.
|
||||
/// Exception fail-safe (L.16): a handler exception never corrupts state; the kernel
|
||||
/// catches it, advances <see cref="DecisionState.StepIndex"/>, applies the signal ordinal,
|
||||
/// and emits a dead-end <see cref="DecisionTransition"/> tagged
|
||||
/// <c>DeadEndReason="reducer_exception"</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed partial class DecisionEngine : IDecisionEngine
|
||||
@@ -29,19 +33,164 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
|
||||
public DecisionStep Reduce(DecisionState oldState, DecisionSignal signal)
|
||||
{
|
||||
if (oldState == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(oldState));
|
||||
}
|
||||
if (oldState == null) throw new ArgumentNullException(nameof(oldState));
|
||||
if (signal == null) throw new ArgumentNullException(nameof(signal));
|
||||
|
||||
if (signal == null)
|
||||
try
|
||||
{
|
||||
throw new ArgumentNullException(nameof(signal));
|
||||
return Dispatch(oldState, signal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BuildReducerExceptionStep(oldState, signal, ex);
|
||||
}
|
||||
}
|
||||
|
||||
throw new NotImplementedException(
|
||||
"DecisionEngine.Reduce is a scaffolding stub (M1). " +
|
||||
"Handler implementation lands in M3 (plan §2.5).");
|
||||
/// <summary>
|
||||
/// Pure dispatch on <c>(Kind, SchemaVersion)</c> — no mutation here, no try/catch
|
||||
/// (outer <see cref="Reduce"/> provides the fail-safe wrapper). Handlers return fully
|
||||
/// constructed <see cref="DecisionStep"/> instances.
|
||||
/// </summary>
|
||||
private DecisionStep Dispatch(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
return (signal.Kind, signal.KindSchemaVersion) switch
|
||||
{
|
||||
// ----- Lifecycle (DecisionEngine.Shared.cs) -----
|
||||
(DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal),
|
||||
(DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal),
|
||||
(DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal),
|
||||
|
||||
// ----- All other kinds (M3.1+) — fall through to Unhandled for M3.0 scope. -----
|
||||
_ => HandleUnhandledSignal(state, signal),
|
||||
};
|
||||
}
|
||||
|
||||
// ================================================================== helpers
|
||||
|
||||
/// <summary>
|
||||
/// Advance StepIndex / LastAppliedSignalOrdinal without touching the engine-visible
|
||||
/// stage or facts. Reducer handlers call this when they record a step but don't
|
||||
/// progress the state machine (e.g. hypothesis-only update, or dead-end).
|
||||
/// </summary>
|
||||
internal DecisionState BumpStepBookkeeping(DecisionState state, DecisionSignal signal) =>
|
||||
state.ToBuilder()
|
||||
.WithStepIndex(state.StepIndex + 1)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="DecisionTransition"/> that records a state-changing step. Guards
|
||||
/// default to empty (for handler-initiated changes that aren't guard-driven); callers
|
||||
/// pass explicit guard evaluations when they matter to the Inspector.
|
||||
/// </summary>
|
||||
internal DecisionTransition BuildTakenTransition(
|
||||
DecisionState before,
|
||||
DecisionSignal signal,
|
||||
SessionStage toStage,
|
||||
int nextStepIndex,
|
||||
string trigger,
|
||||
IReadOnlyList<GuardReport>? guards = null)
|
||||
{
|
||||
return new DecisionTransition(
|
||||
stepIndex: nextStepIndex,
|
||||
sessionTraceOrdinal: signal.SessionTraceOrdinal,
|
||||
signalOrdinalRef: signal.SessionSignalOrdinal,
|
||||
occurredAtUtc: signal.OccurredAtUtc,
|
||||
trigger: trigger,
|
||||
fromStage: before.Stage,
|
||||
toStage: toStage,
|
||||
taken: true,
|
||||
deadEndReason: null,
|
||||
reducerVersion: ReducerVersion,
|
||||
guards: guards,
|
||||
emittedEventSequences: null,
|
||||
classifierVerdict: null,
|
||||
errorMessage: null,
|
||||
stackTraceHash: null);
|
||||
}
|
||||
|
||||
internal DecisionTransition BuildDeadEndTransition(
|
||||
DecisionState state,
|
||||
DecisionSignal signal,
|
||||
int nextStepIndex,
|
||||
string trigger,
|
||||
string deadEndReason,
|
||||
IReadOnlyList<GuardReport>? guards = null)
|
||||
{
|
||||
return new DecisionTransition(
|
||||
stepIndex: nextStepIndex,
|
||||
sessionTraceOrdinal: signal.SessionTraceOrdinal,
|
||||
signalOrdinalRef: signal.SessionSignalOrdinal,
|
||||
occurredAtUtc: signal.OccurredAtUtc,
|
||||
trigger: trigger,
|
||||
fromStage: state.Stage,
|
||||
toStage: state.Stage,
|
||||
taken: false,
|
||||
deadEndReason: deadEndReason,
|
||||
reducerVersion: ReducerVersion,
|
||||
guards: guards,
|
||||
emittedEventSequences: null,
|
||||
classifierVerdict: null,
|
||||
errorMessage: null,
|
||||
stackTraceHash: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan §2.5 L.16 — exception fail-safe. State is unchanged (no builder copy); only
|
||||
/// bookkeeping (StepIndex, LastAppliedSignalOrdinal) advances so the signal counts as
|
||||
/// processed. <see cref="DecisionTransition.ErrorMessage"/> holds the original message
|
||||
/// and <see cref="DecisionTransition.StackTraceHash"/> a short SHA256 of the trace —
|
||||
/// enough for an Inspector to link recurring-exception rows without dumping full PII.
|
||||
/// </summary>
|
||||
private DecisionStep BuildReducerExceptionStep(
|
||||
DecisionState state,
|
||||
DecisionSignal signal,
|
||||
Exception exception)
|
||||
{
|
||||
var bookkept = BumpStepBookkeeping(state, signal);
|
||||
var transition = new DecisionTransition(
|
||||
stepIndex: bookkept.StepIndex,
|
||||
sessionTraceOrdinal: signal.SessionTraceOrdinal,
|
||||
signalOrdinalRef: signal.SessionSignalOrdinal,
|
||||
occurredAtUtc: signal.OccurredAtUtc,
|
||||
trigger: signal.Kind.ToString(),
|
||||
fromStage: state.Stage,
|
||||
toStage: state.Stage,
|
||||
taken: false,
|
||||
deadEndReason: "reducer_exception",
|
||||
reducerVersion: ReducerVersion,
|
||||
guards: null,
|
||||
emittedEventSequences: null,
|
||||
classifierVerdict: null,
|
||||
errorMessage: exception.Message,
|
||||
stackTraceHash: HashStackTrace(exception));
|
||||
|
||||
return new DecisionStep(bookkept, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
private DecisionStep HandleUnhandledSignal(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var bookkept = BumpStepBookkeeping(state, signal);
|
||||
var transition = BuildDeadEndTransition(
|
||||
state: state,
|
||||
signal: signal,
|
||||
nextStepIndex: bookkept.StepIndex,
|
||||
trigger: signal.Kind.ToString(),
|
||||
deadEndReason: $"unhandled_signal_kind:{signal.Kind}:v{signal.KindSchemaVersion}");
|
||||
|
||||
return new DecisionStep(bookkept, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
private static string HashStackTrace(Exception exception)
|
||||
{
|
||||
var raw = exception.StackTrace ?? exception.GetType().FullName ?? "unknown";
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(raw));
|
||||
var sb = new StringBuilder(16);
|
||||
for (int i = 0; i < 8; i++) sb.Append(bytes[i].ToString("x2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Pluggable clock abstraction. Plan §2.6 L.7.
|
||||
/// <para>
|
||||
/// Time is consumed exclusively through this interface by every time-dependent
|
||||
/// component in <c>DecisionCore</c> and <c>Agent.V2.Core</c> (Deadline-Scheduler,
|
||||
/// ClassifierTick-Timer, recovery logic, …). Agent runtime uses <see cref="SystemClock"/>;
|
||||
/// the replay harness uses <c>VirtualClock</c> so 420-second timeouts run in
|
||||
/// milliseconds and are deterministic.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IClock
|
||||
{
|
||||
/// <summary>Current UTC time.</summary>
|
||||
DateTime UtcNow { get; }
|
||||
|
||||
/// <summary>Async delay; virtualized in tests.</summary>
|
||||
Task Delay(TimeSpan delay, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Default <see cref="IClock"/> implementation for the agent runtime — wraps
|
||||
/// <see cref="DateTime.UtcNow"/> and <see cref="Task.Delay(TimeSpan, CancellationToken)"/>.
|
||||
/// Tests must use a virtual clock implementation, never this one.
|
||||
/// </summary>
|
||||
public sealed class SystemClock : IClock
|
||||
{
|
||||
public static readonly SystemClock Instance = new SystemClock();
|
||||
|
||||
public DateTime UtcNow => DateTime.UtcNow;
|
||||
|
||||
public Task Delay(TimeSpan delay, CancellationToken cancellationToken = default) =>
|
||||
Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Serialization
|
||||
{
|
||||
/// <summary>
|
||||
/// Centralized <see cref="JsonSerializerSettings"/> factory for DecisionCore DTOs.
|
||||
/// Plan §2.8 / §2.15 L.14.
|
||||
/// <para>
|
||||
/// Produces a <see cref="JsonSerializerSettings"/> instance with:
|
||||
/// <list type="bullet">
|
||||
/// <item>All DecisionCore enums registered via <see cref="UnknownFallbackEnumConverter{T}"/>
|
||||
/// so unknown values land on <c>Unknown</c> instead of throwing.</item>
|
||||
/// <item><see cref="TypeNameHandling.None"/> — no polymorphism loopholes over the wire.</item>
|
||||
/// <item><see cref="NullValueHandling.Include"/> — preserve nullable fact/hypothesis fields.</item>
|
||||
/// <item>UTC round-trip for <c>DateTime</c> — <see cref="DateTimeZoneHandling.Utc"/>,
|
||||
/// ISO-8601 format.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DecisionCoreJsonSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a fresh settings instance. Not a static singleton — Newtonsoft mutates
|
||||
/// internal state during serialization (binder caches, etc.), so handing out
|
||||
/// shared instances across threads is a well-known footgun.
|
||||
/// </summary>
|
||||
public static JsonSerializerSettings Create()
|
||||
{
|
||||
var settings = new JsonSerializerSettings
|
||||
{
|
||||
TypeNameHandling = TypeNameHandling.None,
|
||||
NullValueHandling = NullValueHandling.Include,
|
||||
DateTimeZoneHandling = DateTimeZoneHandling.Utc,
|
||||
DateParseHandling = DateParseHandling.DateTime,
|
||||
Formatting = Formatting.None,
|
||||
MissingMemberHandling = MissingMemberHandling.Ignore,
|
||||
};
|
||||
|
||||
// Enum converters with Unknown-fallback — plan §2.15 L.14.
|
||||
settings.Converters.Add(new UnknownFallbackEnumConverter<DecisionSignalKind>(DecisionSignalKind.SessionStarted));
|
||||
// ^ SessionStarted is a neutral fallback; unknown signal kinds should be rare (SchemaVersion gate),
|
||||
// and when they do occur we prefer a benign no-op over a crash.
|
||||
settings.Converters.Add(new UnknownFallbackEnumConverter<EvidenceKind>(EvidenceKind.Synthetic));
|
||||
settings.Converters.Add(new UnknownFallbackEnumConverter<HypothesisLevel>(HypothesisLevel.Unknown));
|
||||
settings.Converters.Add(new UnknownFallbackEnumConverter<SessionStage>(SessionStage.Unknown));
|
||||
settings.Converters.Add(new UnknownFallbackEnumConverter<SessionOutcome>(SessionOutcome.Unknown));
|
||||
settings.Converters.Add(new UnknownFallbackEnumConverter<DecisionEffectKind>(DecisionEffectKind.PersistSnapshot));
|
||||
settings.Converters.Add(new UnknownFallbackEnumConverter<EnrollmentPhase>(EnrollmentPhase.Unknown));
|
||||
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Serialization
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialize / deserialize <see cref="DecisionSignal"/> JSONL records.
|
||||
/// Plan §2.7 / §4 M2 harness integration.
|
||||
/// <para>
|
||||
/// Uses Newtonsoft.Json with <see cref="DecisionCoreJsonSettings"/>. The reader
|
||||
/// tolerates missing optional fields but enforces the evidence-kind mandatory-field
|
||||
/// contract (plan §2.2) — a line that would fail <c>new Evidence(...)</c> also fails
|
||||
/// here with the same <see cref="ArgumentException"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class SignalSerializer
|
||||
{
|
||||
public static string Serialize(DecisionSignal signal)
|
||||
{
|
||||
if (signal == null) throw new ArgumentNullException(nameof(signal));
|
||||
return JsonConvert.SerializeObject(signal, DecisionCoreJsonSettings.Create());
|
||||
}
|
||||
|
||||
public static DecisionSignal Deserialize(string line)
|
||||
{
|
||||
if (line == null) throw new ArgumentNullException(nameof(line));
|
||||
|
||||
var settings = DecisionCoreJsonSettings.Create();
|
||||
var obj = JsonConvert.DeserializeObject<JObject>(line, settings)
|
||||
?? throw new JsonReaderException($"Failed to parse line as JSON object: {line}");
|
||||
|
||||
// Evidence is required.
|
||||
var evidenceTok = obj["Evidence"] ?? throw new JsonSerializationException("Signal is missing Evidence block.");
|
||||
var evidenceKind = evidenceTok.Value<string>("Kind") ?? throw new JsonSerializationException("Evidence.Kind missing.");
|
||||
var evidenceIdentifier = evidenceTok.Value<string>("Identifier")
|
||||
?? throw new JsonSerializationException("Evidence.Identifier missing.");
|
||||
var evidenceSummary = evidenceTok.Value<string>("Summary") ?? string.Empty;
|
||||
var rawPointer = evidenceTok.Value<string>("RawPointer");
|
||||
|
||||
IReadOnlyDictionary<string, string>? derivationInputs = null;
|
||||
if (evidenceTok["DerivationInputs"] is JObject diObj)
|
||||
{
|
||||
var d = new Dictionary<string, string>();
|
||||
foreach (var prop in diObj.Properties())
|
||||
{
|
||||
d[prop.Name] = prop.Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
derivationInputs = d;
|
||||
}
|
||||
|
||||
var evidence = new Evidence(
|
||||
kind: ParseEnum<EvidenceKind>(evidenceKind, EvidenceKind.Synthetic),
|
||||
identifier: evidenceIdentifier,
|
||||
summary: evidenceSummary,
|
||||
rawPointer: rawPointer,
|
||||
derivationInputs: derivationInputs);
|
||||
|
||||
IReadOnlyDictionary<string, string>? payload = null;
|
||||
if (obj["Payload"] is JObject payloadObj)
|
||||
{
|
||||
var p = new Dictionary<string, string>();
|
||||
foreach (var prop in payloadObj.Properties())
|
||||
{
|
||||
p[prop.Name] = prop.Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
payload = p;
|
||||
}
|
||||
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: obj.Value<long>("SessionSignalOrdinal"),
|
||||
sessionTraceOrdinal: obj.Value<long>("SessionTraceOrdinal"),
|
||||
kind: ParseEnum<DecisionSignalKind>(obj.Value<string>("Kind"), DecisionSignalKind.SessionStarted),
|
||||
kindSchemaVersion: obj.Value<int?>("KindSchemaVersion") ?? 1,
|
||||
occurredAtUtc: obj.Value<DateTime>("OccurredAtUtc"),
|
||||
sourceOrigin: obj.Value<string>("SourceOrigin") ?? "unknown",
|
||||
evidence: evidence,
|
||||
payload: payload);
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(string? raw, T fallback) where T : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return fallback;
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(typeof(T), parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Serialization
|
||||
{
|
||||
/// <summary>
|
||||
/// Forward-compatibility converter for enums in DecisionCore DTOs. Plan §2.15 L.14.
|
||||
/// <para>
|
||||
/// Serializes <typeparamref name="T"/> as its <see cref="Enum.ToString()"/> name.
|
||||
/// Deserialization maps unknown / unrecognized string values to a caller-provided
|
||||
/// default — typically <c>Unknown</c> — instead of throwing. This lets an older
|
||||
/// consumer (e.g. a backend built against an older DecisionCore version) read rows
|
||||
/// written by a newer producer (new enum values) without crashing; the consumer
|
||||
/// flags those rows as <c>VerificationStatus=drift_tolerated</c> per plan §2.10.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UnknownFallbackEnumConverter<T> : JsonConverter
|
||||
where T : struct, Enum
|
||||
{
|
||||
private readonly T _unknownFallback;
|
||||
|
||||
public UnknownFallbackEnumConverter(T unknownFallback)
|
||||
{
|
||||
_unknownFallback = unknownFallback;
|
||||
}
|
||||
|
||||
public override bool CanConvert(Type objectType)
|
||||
{
|
||||
var t = Nullable.GetUnderlyingType(objectType) ?? objectType;
|
||||
return t == typeof(T);
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
writer.WriteNull();
|
||||
return;
|
||||
}
|
||||
writer.WriteValue(value.ToString());
|
||||
}
|
||||
|
||||
public override object? ReadJson(
|
||||
JsonReader reader,
|
||||
Type objectType,
|
||||
object? existingValue,
|
||||
JsonSerializer serializer)
|
||||
{
|
||||
if (reader.TokenType == JsonToken.Null)
|
||||
{
|
||||
return Nullable.GetUnderlyingType(objectType) != null ? (object?)null : _unknownFallback;
|
||||
}
|
||||
|
||||
if (reader.TokenType == JsonToken.Integer)
|
||||
{
|
||||
// Legacy / numeric payloads. Try to cast; fallback if out of range.
|
||||
var numeric = Convert.ToInt32(reader.Value);
|
||||
if (Enum.IsDefined(typeof(T), numeric))
|
||||
{
|
||||
return (T)Enum.ToObject(typeof(T), numeric);
|
||||
}
|
||||
return _unknownFallback;
|
||||
}
|
||||
|
||||
if (reader.TokenType == JsonToken.String)
|
||||
{
|
||||
var raw = reader.Value as string;
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
return _unknownFallback;
|
||||
}
|
||||
if (Enum.TryParse<T>(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(typeof(T), parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
return _unknownFallback;
|
||||
}
|
||||
|
||||
return _unknownFallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,13 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Produce a mutable builder pre-populated with this state's values.
|
||||
/// Reducer handlers call <c>state.ToBuilder().WithStage(...).Build()</c> to
|
||||
/// express immutable "copy with changes" ergonomically (plan §2.3 / L.3).
|
||||
/// </summary>
|
||||
public DecisionStateBuilder ToBuilder() => new DecisionStateBuilder(this);
|
||||
|
||||
/// <summary>
|
||||
/// Construct the initial non-terminal state for a new session.
|
||||
/// Used by the reducer's <c>SessionStarted</c> handler in M3.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.State
|
||||
{
|
||||
/// <summary>
|
||||
/// Mutable construction helper for producing a new immutable <see cref="DecisionState"/>.
|
||||
/// <para>
|
||||
/// Plan §2.3 / L.3 — <c>DecisionState</c> itself is immutable; reducer handlers use the
|
||||
/// builder to express "copy with changes" without typing all 20+ constructor arguments.
|
||||
/// Call <see cref="Build"/> to materialize a new <see cref="DecisionState"/>. The original
|
||||
/// state is never mutated.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DecisionStateBuilder
|
||||
{
|
||||
public DecisionStateBuilder(DecisionState source)
|
||||
{
|
||||
if (source == null) throw new ArgumentNullException(nameof(source));
|
||||
|
||||
SessionId = source.SessionId;
|
||||
TenantId = source.TenantId;
|
||||
Stage = source.Stage;
|
||||
Outcome = source.Outcome;
|
||||
EnrollmentType = source.EnrollmentType;
|
||||
WhiteGloveSealing = source.WhiteGloveSealing;
|
||||
WhiteGlovePart2Completion = source.WhiteGlovePart2Completion;
|
||||
DeviceOnlyDeployment = source.DeviceOnlyDeployment;
|
||||
CurrentEnrollmentPhase = source.CurrentEnrollmentPhase;
|
||||
DeviceSetupEnteredUtc = source.DeviceSetupEnteredUtc;
|
||||
AccountSetupEnteredUtc = source.AccountSetupEnteredUtc;
|
||||
FinalizingEnteredUtc = source.FinalizingEnteredUtc;
|
||||
EspFinalExitUtc = source.EspFinalExitUtc;
|
||||
DesktopArrivedUtc = source.DesktopArrivedUtc;
|
||||
HelloResolvedUtc = source.HelloResolvedUtc;
|
||||
SystemRebootUtc = source.SystemRebootUtc;
|
||||
HelloOutcome = source.HelloOutcome;
|
||||
AadJoinedWithUser = source.AadJoinedWithUser;
|
||||
ImeMatchedPatternId = source.ImeMatchedPatternId;
|
||||
Deadlines = new List<ActiveDeadline>(source.Deadlines);
|
||||
LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
|
||||
StepIndex = source.StepIndex;
|
||||
SchemaVersion = source.SchemaVersion;
|
||||
}
|
||||
|
||||
public string SessionId { get; set; }
|
||||
public string TenantId { get; set; }
|
||||
public SessionStage Stage { get; set; }
|
||||
public SessionOutcome? Outcome { get; set; }
|
||||
public Hypothesis EnrollmentType { get; set; }
|
||||
public Hypothesis WhiteGloveSealing { get; set; }
|
||||
public Hypothesis WhiteGlovePart2Completion { get; set; }
|
||||
public Hypothesis DeviceOnlyDeployment { get; set; }
|
||||
public SignalFact<EnrollmentPhase>? CurrentEnrollmentPhase { get; set; }
|
||||
public SignalFact<DateTime>? DeviceSetupEnteredUtc { get; set; }
|
||||
public SignalFact<DateTime>? AccountSetupEnteredUtc { get; set; }
|
||||
public SignalFact<DateTime>? FinalizingEnteredUtc { get; set; }
|
||||
public SignalFact<DateTime>? EspFinalExitUtc { get; set; }
|
||||
public SignalFact<DateTime>? DesktopArrivedUtc { get; set; }
|
||||
public SignalFact<DateTime>? HelloResolvedUtc { get; set; }
|
||||
public SignalFact<DateTime>? SystemRebootUtc { get; set; }
|
||||
public SignalFact<string>? HelloOutcome { get; set; }
|
||||
public SignalFact<bool>? AadJoinedWithUser { get; set; }
|
||||
public SignalFact<string>? ImeMatchedPatternId { get; set; }
|
||||
public List<ActiveDeadline> Deadlines { get; set; }
|
||||
public long LastAppliedSignalOrdinal { get; set; }
|
||||
public int StepIndex { get; set; }
|
||||
public string SchemaVersion { get; set; }
|
||||
|
||||
// ---------- fluent helpers for the most common reducer operations ----------
|
||||
|
||||
public DecisionStateBuilder WithStage(SessionStage stage) { Stage = stage; return this; }
|
||||
|
||||
public DecisionStateBuilder WithOutcome(SessionOutcome? outcome) { Outcome = outcome; return this; }
|
||||
|
||||
public DecisionStateBuilder WithStepIndex(int stepIndex) { StepIndex = stepIndex; return this; }
|
||||
|
||||
public DecisionStateBuilder WithLastAppliedSignalOrdinal(long ordinal) { LastAppliedSignalOrdinal = ordinal; return this; }
|
||||
|
||||
public DecisionStateBuilder WithCurrentEnrollmentPhase(EnrollmentPhase phase, long sourceSignalOrdinal)
|
||||
{
|
||||
CurrentEnrollmentPhase = new SignalFact<EnrollmentPhase>(phase, sourceSignalOrdinal);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DecisionStateBuilder AddDeadline(ActiveDeadline deadline)
|
||||
{
|
||||
if (deadline == null) throw new ArgumentNullException(nameof(deadline));
|
||||
// Replace-if-same-name semantic: deadlines identified by Name. Plan §2.6.
|
||||
for (int i = 0; i < Deadlines.Count; i++)
|
||||
{
|
||||
if (string.Equals(Deadlines[i].Name, deadline.Name, StringComparison.Ordinal))
|
||||
{
|
||||
Deadlines[i] = deadline;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
Deadlines.Add(deadline);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DecisionStateBuilder CancelDeadline(string name)
|
||||
{
|
||||
if (name == null) throw new ArgumentNullException(nameof(name));
|
||||
Deadlines.RemoveAll(d => string.Equals(d.Name, name, StringComparison.Ordinal));
|
||||
return this;
|
||||
}
|
||||
|
||||
public DecisionStateBuilder ClearDeadlines() { Deadlines.Clear(); return this; }
|
||||
|
||||
public DecisionState Build() =>
|
||||
new DecisionState(
|
||||
sessionId: SessionId,
|
||||
tenantId: TenantId,
|
||||
stage: Stage,
|
||||
outcome: Outcome,
|
||||
enrollmentType: EnrollmentType,
|
||||
whiteGloveSealing: WhiteGloveSealing,
|
||||
whiteGlovePart2Completion: WhiteGlovePart2Completion,
|
||||
deviceOnlyDeployment: DeviceOnlyDeployment,
|
||||
currentEnrollmentPhase: CurrentEnrollmentPhase,
|
||||
deviceSetupEnteredUtc: DeviceSetupEnteredUtc,
|
||||
accountSetupEnteredUtc: AccountSetupEnteredUtc,
|
||||
finalizingEnteredUtc: FinalizingEnteredUtc,
|
||||
espFinalExitUtc: EspFinalExitUtc,
|
||||
desktopArrivedUtc: DesktopArrivedUtc,
|
||||
helloResolvedUtc: HelloResolvedUtc,
|
||||
systemRebootUtc: SystemRebootUtc,
|
||||
helloOutcome: HelloOutcome,
|
||||
aadJoinedWithUser: AadJoinedWithUser,
|
||||
imeMatchedPatternId: ImeMatchedPatternId,
|
||||
deadlines: Deadlines.ToArray(),
|
||||
lastAppliedSignalOrdinal: LastAppliedSignalOrdinal,
|
||||
stepIndex: StepIndex,
|
||||
schemaVersion: SchemaVersion);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user