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:
Oliver Kieselbach
2026-04-20 19:03:08 +02:00
parent bca53bd43d
commit fb98d43ae5
3 changed files with 175 additions and 1 deletions
@@ -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),
};
}