mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
refactor(decisioncore): M4.4.3 reducer handlers for diagnostic signals
DeviceInfoCollected and AutopilotProfileRead previously fell through to HandleUnhandledSignal, writing unhandled_signal_kind:* DeadEnd rows into the journal on every emission — pure noise, no decision value, but distorting the Inspector timeline. Plan §4.x M4.4.3 fix. Both are diagnostic signals: they carry hardware inventory / Autopilot profile payload used only by the Inspector and backend analyzers. They do NOT drive stage or hypotheses. Fix is minimal: neutral Taken transitions via a new shared helper RecordDiagnosticObservation — bookkeeping advances, stage/hypotheses untouched, no effects, no DeadEndReason. Full payload stays in the SignalLog for Inspector rendering (Plan §2.7c — Snapshot/Journal are Cache, SignalLog is Master; no reason to duplicate data into DecisionState). Version-gap protection: dispatcher entries are keyed on (Kind, v1). A future v2 payload still dead-ends with unhandled_signal_kind:*:v2 — the L.6 forward-compat contract requires explicit handler upgrades, not silent recording under the old handler. AppInstallCompleted/AppInstallFailed intentionally stay unwired — no decision consumer in v11 scope, and the collector isn't emitting them yet. +11 tests (1339 -> 1350 green): Taken-without-DeadEnd, stage+hypotheses unchanged, bookkeeping advancement, no effects, existing deadlines preserved, v2-schema-version falls through to unhandled. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan §4.x M4.4.3 — <see cref="DecisionSignalKind.DeviceInfoCollected"/> and
|
||||
/// <see cref="DecisionSignalKind.AutopilotProfileRead"/> are diagnostic signals that
|
||||
/// previously fell through to <c>HandleUnhandledSignal</c> and produced
|
||||
/// <c>unhandled_signal_kind:*</c> dead-ends in the journal. They are now first-class
|
||||
/// neutral observations: bookkeeping advances, the transition is <c>Taken</c>, the stage
|
||||
/// is unchanged, no effects, no hypothesis impact.
|
||||
/// </summary>
|
||||
public sealed class DiagnosticSignalHandlerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(DecisionSignalKind.DeviceInfoCollected)]
|
||||
[InlineData(DecisionSignalKind.AutopilotProfileRead)]
|
||||
public void Diagnostic_signal_records_taken_transition_without_deadend(DecisionSignalKind kind)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t");
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(kind, ordinal: 3));
|
||||
|
||||
Assert.True(step.Transition.Taken);
|
||||
Assert.Null(step.Transition.DeadEndReason);
|
||||
Assert.Equal(kind.ToString(), step.Transition.Trigger);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DecisionSignalKind.DeviceInfoCollected)]
|
||||
[InlineData(DecisionSignalKind.AutopilotProfileRead)]
|
||||
public void Diagnostic_signal_leaves_stage_and_hypotheses_unchanged(DecisionSignalKind kind)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var midFlight = DecisionState.CreateInitial("s", "t")
|
||||
.ToBuilder()
|
||||
.WithStage(SessionStage.EspAccountSetup)
|
||||
.WithStepIndex(10)
|
||||
.WithLastAppliedSignalOrdinal(9)
|
||||
.Build();
|
||||
|
||||
var step = engine.Reduce(midFlight, MakeSignal(kind, ordinal: 10));
|
||||
|
||||
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
|
||||
Assert.Same(midFlight.EnrollmentType, step.NewState.EnrollmentType);
|
||||
Assert.Same(midFlight.WhiteGloveSealing, step.NewState.WhiteGloveSealing);
|
||||
Assert.Same(midFlight.DeviceOnlyDeployment, step.NewState.DeviceOnlyDeployment);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DecisionSignalKind.DeviceInfoCollected)]
|
||||
[InlineData(DecisionSignalKind.AutopilotProfileRead)]
|
||||
public void Diagnostic_signal_advances_bookkeeping(DecisionSignalKind kind)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t")
|
||||
.ToBuilder()
|
||||
.WithStepIndex(7)
|
||||
.WithLastAppliedSignalOrdinal(6)
|
||||
.Build();
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(kind, ordinal: 7));
|
||||
|
||||
Assert.Equal(8, step.NewState.StepIndex);
|
||||
Assert.Equal(7, step.NewState.LastAppliedSignalOrdinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DecisionSignalKind.DeviceInfoCollected)]
|
||||
[InlineData(DecisionSignalKind.AutopilotProfileRead)]
|
||||
public void Diagnostic_signal_emits_no_effects(DecisionSignalKind kind)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t");
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(kind));
|
||||
|
||||
Assert.Empty(step.Effects);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DecisionSignalKind.DeviceInfoCollected)]
|
||||
[InlineData(DecisionSignalKind.AutopilotProfileRead)]
|
||||
public void Diagnostic_signal_does_not_cancel_active_deadlines(DecisionSignalKind kind)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var stateWithDeadline = DecisionState.CreateInitial("s", "t")
|
||||
.ToBuilder()
|
||||
.AddDeadline(new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: new DateTime(2026, 4, 20, 10, 5, 0, DateTimeKind.Utc),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired))
|
||||
.Build();
|
||||
|
||||
var step = engine.Reduce(stateWithDeadline, MakeSignal(kind));
|
||||
|
||||
Assert.Single(step.NewState.Deadlines);
|
||||
Assert.Equal(DeadlineNames.HelloSafety, step.NewState.Deadlines[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diagnostic_signals_at_higher_schema_version_fall_through_to_unhandled()
|
||||
{
|
||||
// §2.2 L.6 — dispatch is keyed on (Kind, SchemaVersion). If a collector ever emits
|
||||
// a v2 variant before the reducer handles it, the kernel must dead-end (not
|
||||
// silently record), so replay flags the version gap.
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t");
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(DecisionSignalKind.DeviceInfoCollected, schemaVersion: 2));
|
||||
|
||||
Assert.False(step.Transition.Taken);
|
||||
Assert.Equal("unhandled_signal_kind:DeviceInfoCollected:v2", step.Transition.DeadEndReason);
|
||||
}
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
DecisionSignalKind kind,
|
||||
long ordinal = 0,
|
||||
int schemaVersion = 1,
|
||||
IReadOnlyDictionary<string, string>? payload = null) =>
|
||||
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.Raw, $"test:{kind}", "test"),
|
||||
payload: payload);
|
||||
}
|
||||
}
|
||||
@@ -235,6 +235,39 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
return new DecisionStep(newState, transition, effects);
|
||||
}
|
||||
|
||||
// ============================================================== diagnostic signals
|
||||
// Plan §4.x M4.4.3 — close the reducer-handler gap for signals that carry useful
|
||||
// telemetry but do NOT influence the state machine. Previously these fell through to
|
||||
// HandleUnhandledSignal, which wrote a DeadEnd transition every time — noise in the
|
||||
// journal. Now they record as neutral taken transitions; full payload/evidence
|
||||
// remains in the SignalLog for Inspector analysis.
|
||||
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.DeviceInfoCollected"/>. Diagnostic-only —
|
||||
/// carries hardware inventory in payload, does not drive stage or hypothesis.
|
||||
/// </summary>
|
||||
private DecisionStep HandleDeviceInfoCollectedV1(DecisionState state, DecisionSignal signal) =>
|
||||
RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.DeviceInfoCollected));
|
||||
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.AutopilotProfileRead"/>. Diagnostic-only —
|
||||
/// carries Autopilot profile registry contents, does not drive stage or hypothesis.
|
||||
/// </summary>
|
||||
private DecisionStep HandleAutopilotProfileReadV1(DecisionState state, DecisionSignal signal) =>
|
||||
RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.AutopilotProfileRead));
|
||||
|
||||
private DecisionStep RecordDiagnosticObservation(DecisionState state, DecisionSignal signal, string trigger)
|
||||
{
|
||||
var newState = BumpStepBookkeeping(state, signal);
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: state.Stage,
|
||||
nextStepIndex: newState.StepIndex,
|
||||
trigger: trigger);
|
||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
/// <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.
|
||||
|
||||
@@ -88,7 +88,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
(DecisionSignalKind.EspTerminalFailure, 1) => HandleEspTerminalFailureV1(state, signal),
|
||||
(DecisionSignalKind.SystemRebootObserved, 1) => HandleSystemRebootObservedV1(state, signal),
|
||||
|
||||
// ----- Still unwired (AppInstall, DeviceInfo, AutopilotProfileRead — v11.1+) -----
|
||||
// ----- Diagnostic observations (DecisionEngine.Shared.cs) — Plan §4.x M4.4.3 -----
|
||||
(DecisionSignalKind.DeviceInfoCollected, 1) => HandleDeviceInfoCollectedV1(state, signal),
|
||||
(DecisionSignalKind.AutopilotProfileRead, 1) => HandleAutopilotProfileReadV1(state, signal),
|
||||
|
||||
// ----- Still unwired (AppInstall — v11.1+) -----
|
||||
_ => HandleUnhandledSignal(state, signal),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user