mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
v2 codex-05: typed ScenarioProfile + Observations + ClassifierOutcomes aggregates
Codex review follow-up #5 (Option 2b, clean-cut). Replaces the legacy 9-field hypothesis/fact mosaic on DecisionState with three structured aggregates that separate concerns cleanly: - EnrollmentScenarioProfile — derived classification (Mode, JoinMode, EspConfig, PreProvisioningSide, Confidence, EvidenceOrdinal, Reason) replacing the Hypothesis EnrollmentType + SkipUser/DeviceEsp facts. - EnrollmentScenarioObservations — raw per-signal evidence (ShellCoreWhiteGloveSuccessSeen, WhiteGloveSealingPatternSeen, AadUserJoinWithUserObserved, SkipUserEsp, SkipDeviceEsp) with set-once semantics, feeding the WhiteGlove sealing classifier snapshot. - ClassifierOutcomes — classifier verdict + anti-loop storage (WhiteGloveSealing, WhiteGlovePart2Completion, DeviceOnlyDeployment) replacing the three Hypothesis fields they used to occupy. EnrollmentScenarioProfileUpdater holds the signal → profile mapping rules with monotonic-confidence invariants. EspConfigDetected keeps its partial- first-signal / second-signal-completes semantic via the observations half- facts; the profile's EspConfig is only derived once both halves are known. Critical Codex correction: AadUserJoinWithUserObserved is NOT a JoinMode signal — it is a late-AADJ user-presence hard-excluder for the WhiteGlove classifier. JoinMode comes strictly from SessionStarted.payload.isHybridJoin. Classifier algorithm, weights, and thresholds unchanged; only snapshot input sources, anti-loop verdict storage, and the AwaitingHello guard migrate. The WhiteGloveSealingSnapshot fields keep their names and types, so ComputeInputHash stays byte-stable and the anti-loop contract is preserved across the refactor. AssemblyVersion stays 2.0.0.0. Suites: DecisionCore 203/203 (+39 new aggregate/updater tests), Agent.V2.Core 650/650, Functions 735/735; 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,7 +86,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
|||||||
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
|
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
|
||||||
Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
|
Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
|
||||||
// DeviceOnlyDeployment hypothesis should have climbed from Unknown → Strong/Confirmed.
|
// DeviceOnlyDeployment hypothesis should have climbed from Unknown → Strong/Confirmed.
|
||||||
Assert.NotEqual(HypothesisLevel.Unknown, f.Orchestrator.CurrentState.DeviceOnlyDeployment.Level);
|
Assert.NotEqual(HypothesisLevel.Unknown, f.Orchestrator.CurrentState.ClassifierOutcomes.DeviceOnlyDeployment.Level);
|
||||||
|
|
||||||
f.Stop();
|
f.Stop();
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
|||||||
f.PostFixture("whiteglove-inline-v1.jsonl");
|
f.PostFixture("whiteglove-inline-v1.jsonl");
|
||||||
|
|
||||||
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.WhiteGloveSealed));
|
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.WhiteGloveSealed));
|
||||||
Assert.Equal(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.WhiteGloveSealing.Level);
|
Assert.Equal(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.ClassifierOutcomes.WhiteGloveSealing.Level);
|
||||||
|
|
||||||
f.Stop();
|
f.Stop();
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
|||||||
f.PostFixture("whiteglove-signal-correlated-v1.jsonl");
|
f.PostFixture("whiteglove-signal-correlated-v1.jsonl");
|
||||||
|
|
||||||
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.WhiteGloveSealed));
|
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.WhiteGloveSealed));
|
||||||
Assert.Equal(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.WhiteGloveSealing.Level);
|
Assert.Equal(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.ClassifierOutcomes.WhiteGloveSealing.Level);
|
||||||
|
|
||||||
f.Stop();
|
f.Stop();
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
|||||||
// Late-AADJ rejects the WG hypothesis; session still completes via the classic path.
|
// Late-AADJ rejects the WG hypothesis; session still completes via the classic path.
|
||||||
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
|
Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed));
|
||||||
Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
|
Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome);
|
||||||
Assert.NotEqual(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.WhiteGloveSealing.Level);
|
Assert.NotEqual(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.ClassifierOutcomes.WhiteGloveSealing.Level);
|
||||||
|
|
||||||
f.Stop();
|
f.Stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,12 +301,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
|||||||
|
|
||||||
var state = InitialState()
|
var state = InitialState()
|
||||||
.ToBuilder()
|
.ToBuilder()
|
||||||
.Apply(b => b.WhiteGloveSealing = new Hypothesis(
|
.Apply(b => b.ClassifierOutcomes = b.ClassifierOutcomes.WithWhiteGloveSealing(new Hypothesis(
|
||||||
level: HypothesisLevel.Strong,
|
level: HypothesisLevel.Strong,
|
||||||
reason: "seed",
|
reason: "seed",
|
||||||
score: 80,
|
score: 80,
|
||||||
lastUpdatedUtc: At,
|
lastUpdatedUtc: At,
|
||||||
lastClassifierVerdictId: Hash))
|
lastClassifierVerdictId: Hash)))
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var rig = new Rig();
|
var rig = new Rig();
|
||||||
|
|||||||
+1
-1
@@ -99,7 +99,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
|||||||
Stage = SessionStage.Completed,
|
Stage = SessionStage.Completed,
|
||||||
HelloResolvedUtc = new SignalFact<DateTime>(StartUtc.AddMinutes(5), 1),
|
HelloResolvedUtc = new SignalFact<DateTime>(StartUtc.AddMinutes(5), 1),
|
||||||
DesktopArrivedUtc = new SignalFact<DateTime>(StartUtc.AddMinutes(10), 2),
|
DesktopArrivedUtc = new SignalFact<DateTime>(StartUtc.AddMinutes(10), 2),
|
||||||
AadJoinedWithUser = new SignalFact<bool>(true, 3),
|
ScenarioObservations = EnrollmentScenarioObservations.Empty.WithAadUserJoinWithUserObserved(value: true, sourceSignalOrdinal: 3),
|
||||||
ImeMatchedPatternId = new SignalFact<string>("esp_done", 4),
|
ImeMatchedPatternId = new SignalFact<string>("esp_done", 4),
|
||||||
}.Build();
|
}.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
|||||||
Outcome = MapOutcome(terminated.Outcome, state.Stage),
|
Outcome = MapOutcome(terminated.Outcome, state.Stage),
|
||||||
CompletionSource = terminated.Reason.ToString(),
|
CompletionSource = terminated.Reason.ToString(),
|
||||||
HelloOutcome = state.HelloOutcome?.Value ?? "unknown",
|
HelloOutcome = state.HelloOutcome?.Value ?? "unknown",
|
||||||
EnrollmentType = state.EnrollmentType?.Reason ?? state.EnrollmentType?.Level.ToString() ?? "unknown",
|
EnrollmentType = DescribeEnrollmentType(state.ScenarioProfile),
|
||||||
AgentUptimeSeconds = uptimeSeconds,
|
AgentUptimeSeconds = uptimeSeconds,
|
||||||
SignalsSeen = BuildSignalsSeen(state),
|
SignalsSeen = BuildSignalsSeen(state),
|
||||||
AppSummary = BuildAppSummary(packageStates),
|
AppSummary = BuildAppSummary(packageStates),
|
||||||
@@ -73,10 +73,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
|||||||
if (state.HelloResolvedUtc != null) signals.Add("hello_resolved");
|
if (state.HelloResolvedUtc != null) signals.Add("hello_resolved");
|
||||||
if (state.DesktopArrivedUtc != null) signals.Add("desktop_arrived");
|
if (state.DesktopArrivedUtc != null) signals.Add("desktop_arrived");
|
||||||
if (state.SystemRebootUtc != null) signals.Add("system_reboot");
|
if (state.SystemRebootUtc != null) signals.Add("system_reboot");
|
||||||
if (state.AadJoinedWithUser != null && state.AadJoinedWithUser.Value) signals.Add("aad_user_joined");
|
var obs = state.ScenarioObservations;
|
||||||
if (state.ShellCoreWhiteGloveSuccessSeen != null && state.ShellCoreWhiteGloveSuccessSeen.Value)
|
if (obs.AadUserJoinWithUserObserved != null && obs.AadUserJoinWithUserObserved.Value) signals.Add("aad_user_joined");
|
||||||
|
if (obs.ShellCoreWhiteGloveSuccessSeen != null && obs.ShellCoreWhiteGloveSuccessSeen.Value)
|
||||||
signals.Add("whiteglove_shellcore_success");
|
signals.Add("whiteglove_shellcore_success");
|
||||||
if (state.WhiteGloveSealingPatternSeen != null && state.WhiteGloveSealingPatternSeen.Value)
|
if (obs.WhiteGloveSealingPatternSeen != null && obs.WhiteGloveSealingPatternSeen.Value)
|
||||||
signals.Add("whiteglove_sealing_pattern");
|
signals.Add("whiteglove_sealing_pattern");
|
||||||
if (state.ImeMatchedPatternId != null) signals.Add($"ime_pattern:{state.ImeMatchedPatternId.Value}");
|
if (state.ImeMatchedPatternId != null) signals.Add($"ime_pattern:{state.ImeMatchedPatternId.Value}");
|
||||||
if (state.UserAadSignInCompleteUtc != null) signals.Add("part2_user_aad_signin");
|
if (state.UserAadSignInCompleteUtc != null) signals.Add("part2_user_aad_signin");
|
||||||
@@ -149,5 +150,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
|||||||
|| s == AppInstallationState.Skipped
|
|| s == AppInstallationState.Skipped
|
||||||
|| s == AppInstallationState.Postponed
|
|| s == AppInstallationState.Postponed
|
||||||
|| s == AppInstallationState.Error;
|
|| s == AppInstallationState.Error;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Codex follow-up #5 — the final-status summary exposed the legacy
|
||||||
|
/// <c>Hypothesis.Reason ?? Level</c> string. The equivalent derivable from
|
||||||
|
/// <see cref="EnrollmentScenarioProfile"/> is the <c>Mode</c> name when known,
|
||||||
|
/// falling back to the current <see cref="EnrollmentScenarioProfile.Reason"/>
|
||||||
|
/// token, and finally to <c>"unknown"</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static string DescribeEnrollmentType(EnrollmentScenarioProfile profile)
|
||||||
|
{
|
||||||
|
if (profile.Mode != EnrollmentMode.Unknown) return profile.Mode.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(profile.Reason)) return profile.Reason!;
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
296
|
306
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1137
|
1143
|
||||||
|
|||||||
@@ -188,7 +188,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||||
});
|
});
|
||||||
var afterConfig = engine.Reduce(seed, espConfig).NewState;
|
var afterConfig = engine.Reduce(seed, espConfig).NewState;
|
||||||
Assert.True(afterConfig.SkipUserEsp!.Value);
|
Assert.True(afterConfig.ScenarioObservations.SkipUserEsp!.Value);
|
||||||
|
Assert.Equal(EspConfig.DeviceEspOnly, afterConfig.ScenarioProfile.EspConfig);
|
||||||
|
|
||||||
// Step 2 — EspPhaseChanged(FinalizingSetup) from EspAndHelloTrackerAdapter.
|
// Step 2 — EspPhaseChanged(FinalizingSetup) from EspAndHelloTrackerAdapter.
|
||||||
var finalizing = MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 2);
|
var finalizing = MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 2);
|
||||||
@@ -231,7 +232,20 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
.WithStage(stage)
|
.WithStage(stage)
|
||||||
.WithStepIndex(2)
|
.WithStepIndex(2)
|
||||||
.WithLastAppliedSignalOrdinal(1);
|
.WithLastAppliedSignalOrdinal(1);
|
||||||
if (skipUser.HasValue) builder.SkipUserEsp = new SignalFact<bool>(skipUser.Value, 0);
|
// Codex follow-up #5: the guard reads from Profile.EspConfig now. SkipUser=true
|
||||||
|
// without a skipDevice observation would leave EspConfig=Unknown — so tests that
|
||||||
|
// pre-seed "SkipUser=true" must also pre-seed skipDevice=false to land at
|
||||||
|
// DeviceEspOnly (the semantic equivalent of the legacy "SkipUserEsp.Value == true").
|
||||||
|
if (skipUser.HasValue)
|
||||||
|
{
|
||||||
|
builder.ScenarioObservations = builder.ScenarioObservations
|
||||||
|
.WithSkipUserEsp(skipUser.Value, sourceSignalOrdinal: 0)
|
||||||
|
.WithSkipDeviceEsp(value: false, sourceSignalOrdinal: 0);
|
||||||
|
builder.ScenarioProfile = builder.ScenarioProfile.With(
|
||||||
|
espConfig: EnrollmentScenarioProfileUpdater.DeriveEspConfig(skipUser.Value, false),
|
||||||
|
confidence: ProfileConfidence.Medium,
|
||||||
|
evidenceOrdinal: 0);
|
||||||
|
}
|
||||||
if (accountSetupEntered) builder.AccountSetupEnteredUtc = new SignalFact<DateTime>(Fixed.AddMinutes(-1), 1);
|
if (accountSetupEntered) builder.AccountSetupEnteredUtc = new SignalFact<DateTime>(Fixed.AddMinutes(-1), 1);
|
||||||
return builder.Build();
|
return builder.Build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
var step = engine.Reduce(midFlight, MakeSignal(kind, ordinal: 10));
|
var step = engine.Reduce(midFlight, MakeSignal(kind, ordinal: 10));
|
||||||
|
|
||||||
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
|
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
|
||||||
Assert.Same(midFlight.EnrollmentType, step.NewState.EnrollmentType);
|
// Diagnostic signals must not change any of the typed aggregates.
|
||||||
Assert.Same(midFlight.WhiteGloveSealing, step.NewState.WhiteGloveSealing);
|
Assert.Same(midFlight.ScenarioProfile, step.NewState.ScenarioProfile);
|
||||||
Assert.Same(midFlight.DeviceOnlyDeployment, step.NewState.DeviceOnlyDeployment);
|
Assert.Same(midFlight.ScenarioObservations, step.NewState.ScenarioObservations);
|
||||||
|
Assert.Same(midFlight.ClassifierOutcomes, step.NewState.ClassifierOutcomes);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -9,15 +9,17 @@ using Xunit;
|
|||||||
namespace AutopilotMonitor.DecisionCore.Tests
|
namespace AutopilotMonitor.DecisionCore.Tests
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plan §6 Fix 9 — <see cref="DecisionSignalKind.EspConfigDetected"/> populates the
|
/// Plan §6 Fix 9 + Codex follow-up #5 — <see cref="DecisionSignalKind.EspConfigDetected"/>
|
||||||
/// <see cref="DecisionState.SkipUserEsp"/> / <see cref="DecisionState.SkipDeviceEsp"/>
|
/// populates <see cref="EnrollmentScenarioObservations.SkipUserEsp"/> /
|
||||||
/// facts. Set-once semantics: later signals with the same or different payload are no-ops
|
/// <see cref="EnrollmentScenarioObservations.SkipDeviceEsp"/> raw observations and, once
|
||||||
/// once a fact is present (monotonic, analogous to <c>DeviceSetupEnteredUtc</c>).
|
/// BOTH halves are known, derives <see cref="EnrollmentScenarioProfile.EspConfig"/>.
|
||||||
|
/// Set-once semantics: later signals with the same or different payload are no-ops
|
||||||
|
/// once an observation is present (monotonic).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class EspConfigDetectedHandlerTests
|
public sealed class EspConfigDetectedHandlerTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EspConfigDetected_populates_SkipUserEsp_and_SkipDeviceEsp_from_payload()
|
public void EspConfigDetected_populates_observations_and_derives_EspConfig()
|
||||||
{
|
{
|
||||||
var engine = new DecisionEngine();
|
var engine = new DecisionEngine();
|
||||||
var state = DecisionState.CreateInitial("s", "t");
|
var state = DecisionState.CreateInitial("s", "t");
|
||||||
@@ -27,13 +29,17 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
skipUser: "true",
|
skipUser: "true",
|
||||||
skipDevice: "false"));
|
skipDevice: "false"));
|
||||||
|
|
||||||
Assert.NotNull(step.NewState.SkipUserEsp);
|
var obs = step.NewState.ScenarioObservations;
|
||||||
Assert.True(step.NewState.SkipUserEsp!.Value);
|
Assert.NotNull(obs.SkipUserEsp);
|
||||||
Assert.Equal(3, step.NewState.SkipUserEsp!.SourceSignalOrdinal);
|
Assert.True(obs.SkipUserEsp!.Value);
|
||||||
|
Assert.Equal(3, obs.SkipUserEsp!.SourceSignalOrdinal);
|
||||||
|
|
||||||
Assert.NotNull(step.NewState.SkipDeviceEsp);
|
Assert.NotNull(obs.SkipDeviceEsp);
|
||||||
Assert.False(step.NewState.SkipDeviceEsp!.Value);
|
Assert.False(obs.SkipDeviceEsp!.Value);
|
||||||
Assert.Equal(3, step.NewState.SkipDeviceEsp!.SourceSignalOrdinal);
|
Assert.Equal(3, obs.SkipDeviceEsp!.SourceSignalOrdinal);
|
||||||
|
|
||||||
|
// Both halves observed → Profile.EspConfig derived.
|
||||||
|
Assert.Equal(EspConfig.DeviceEspOnly, step.NewState.ScenarioProfile.EspConfig);
|
||||||
|
|
||||||
Assert.True(step.Transition.Taken);
|
Assert.True(step.Transition.Taken);
|
||||||
Assert.Null(step.Transition.DeadEndReason);
|
Assert.Null(step.Transition.DeadEndReason);
|
||||||
@@ -60,6 +66,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage);
|
Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage);
|
||||||
Assert.Equal(6, step.NewState.StepIndex);
|
Assert.Equal(6, step.NewState.StepIndex);
|
||||||
Assert.Equal(5, step.NewState.LastAppliedSignalOrdinal);
|
Assert.Equal(5, step.NewState.LastAppliedSignalOrdinal);
|
||||||
|
Assert.Equal(EspConfig.FullEsp, step.NewState.ScenarioProfile.EspConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -79,10 +86,14 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
skipUser: "true",
|
skipUser: "true",
|
||||||
skipDevice: "true"));
|
skipDevice: "true"));
|
||||||
|
|
||||||
Assert.False(step2.NewState.SkipUserEsp!.Value);
|
var obs = step2.NewState.ScenarioObservations;
|
||||||
Assert.Equal(1, step2.NewState.SkipUserEsp!.SourceSignalOrdinal);
|
Assert.False(obs.SkipUserEsp!.Value);
|
||||||
Assert.False(step2.NewState.SkipDeviceEsp!.Value);
|
Assert.Equal(1, obs.SkipUserEsp!.SourceSignalOrdinal);
|
||||||
Assert.Equal(1, step2.NewState.SkipDeviceEsp!.SourceSignalOrdinal);
|
Assert.False(obs.SkipDeviceEsp!.Value);
|
||||||
|
Assert.Equal(1, obs.SkipDeviceEsp!.SourceSignalOrdinal);
|
||||||
|
|
||||||
|
// Profile.EspConfig was derived on the first signal and not regressed.
|
||||||
|
Assert.Equal(EspConfig.FullEsp, step2.NewState.ScenarioProfile.EspConfig);
|
||||||
|
|
||||||
// The second signal still bumps bookkeeping (taken transition, no effects).
|
// The second signal still bumps bookkeeping (taken transition, no effects).
|
||||||
Assert.True(step2.Transition.Taken);
|
Assert.True(step2.Transition.Taken);
|
||||||
@@ -91,7 +102,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EspConfigDetected_missingKeys_leavesFactsNull()
|
public void EspConfigDetected_missingKeys_leavesFactsNull_andProfileUnknown()
|
||||||
{
|
{
|
||||||
var engine = new DecisionEngine();
|
var engine = new DecisionEngine();
|
||||||
var state = DecisionState.CreateInitial("s", "t");
|
var state = DecisionState.CreateInitial("s", "t");
|
||||||
@@ -101,14 +112,16 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
skipUser: null,
|
skipUser: null,
|
||||||
skipDevice: null));
|
skipDevice: null));
|
||||||
|
|
||||||
Assert.Null(step.NewState.SkipUserEsp);
|
var obs = step.NewState.ScenarioObservations;
|
||||||
Assert.Null(step.NewState.SkipDeviceEsp);
|
Assert.Null(obs.SkipUserEsp);
|
||||||
|
Assert.Null(obs.SkipDeviceEsp);
|
||||||
|
Assert.Equal(EspConfig.Unknown, step.NewState.ScenarioProfile.EspConfig);
|
||||||
Assert.True(step.Transition.Taken);
|
Assert.True(step.Transition.Taken);
|
||||||
Assert.Equal(1, step.NewState.StepIndex);
|
Assert.Equal(1, step.NewState.StepIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EspConfigDetected_partialPayload_setsOnlyKnownFact_leavesOtherNull()
|
public void EspConfigDetected_partialPayload_setsOnlyKnownFact_profileStaysUnknown()
|
||||||
{
|
{
|
||||||
// Realistic: registry has SkipUserStatusPage but SkipDeviceStatusPage key missing.
|
// Realistic: registry has SkipUserStatusPage but SkipDeviceStatusPage key missing.
|
||||||
var engine = new DecisionEngine();
|
var engine = new DecisionEngine();
|
||||||
@@ -119,16 +132,20 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
skipUser: "true",
|
skipUser: "true",
|
||||||
skipDevice: null));
|
skipDevice: null));
|
||||||
|
|
||||||
Assert.NotNull(step.NewState.SkipUserEsp);
|
var obs = step.NewState.ScenarioObservations;
|
||||||
Assert.True(step.NewState.SkipUserEsp!.Value);
|
Assert.NotNull(obs.SkipUserEsp);
|
||||||
Assert.Null(step.NewState.SkipDeviceEsp);
|
Assert.True(obs.SkipUserEsp!.Value);
|
||||||
|
Assert.Null(obs.SkipDeviceEsp);
|
||||||
|
// Only one half observed → Profile.EspConfig cannot be derived yet.
|
||||||
|
Assert.Equal(EspConfig.Unknown, step.NewState.ScenarioProfile.EspConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EspConfigDetected_partialFirstSignal_secondSignalCanFillMissingFact()
|
public void EspConfigDetected_partialFirstSignal_secondSignalCompletes_derivesEspConfig()
|
||||||
{
|
{
|
||||||
// First signal sets only skipUser; second signal (different ordinal) can still fill
|
// First signal sets only skipUser; second signal (different ordinal) can still fill
|
||||||
// in skipDevice because set-once is per-fact, not per-signal.
|
// in skipDevice because set-once is per-observation, not per-signal. Profile.EspConfig
|
||||||
|
// is derived once both halves are known.
|
||||||
var engine = new DecisionEngine();
|
var engine = new DecisionEngine();
|
||||||
var state = DecisionState.CreateInitial("s", "t");
|
var state = DecisionState.CreateInitial("s", "t");
|
||||||
|
|
||||||
@@ -142,13 +159,16 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
skipUser: null,
|
skipUser: null,
|
||||||
skipDevice: "false"));
|
skipDevice: "false"));
|
||||||
|
|
||||||
Assert.NotNull(step2.NewState.SkipUserEsp);
|
var obs = step2.NewState.ScenarioObservations;
|
||||||
Assert.True(step2.NewState.SkipUserEsp!.Value);
|
Assert.NotNull(obs.SkipUserEsp);
|
||||||
Assert.Equal(1, step2.NewState.SkipUserEsp!.SourceSignalOrdinal);
|
Assert.True(obs.SkipUserEsp!.Value);
|
||||||
|
Assert.Equal(1, obs.SkipUserEsp!.SourceSignalOrdinal);
|
||||||
|
|
||||||
Assert.NotNull(step2.NewState.SkipDeviceEsp);
|
Assert.NotNull(obs.SkipDeviceEsp);
|
||||||
Assert.False(step2.NewState.SkipDeviceEsp!.Value);
|
Assert.False(obs.SkipDeviceEsp!.Value);
|
||||||
Assert.Equal(2, step2.NewState.SkipDeviceEsp!.SourceSignalOrdinal);
|
Assert.Equal(2, obs.SkipDeviceEsp!.SourceSignalOrdinal);
|
||||||
|
|
||||||
|
Assert.Equal(EspConfig.DeviceEspOnly, step2.NewState.ScenarioProfile.EspConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -181,21 +201,25 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
var json = StateSerializer.Serialize(populated);
|
var json = StateSerializer.Serialize(populated);
|
||||||
var roundtripped = StateSerializer.Deserialize(json);
|
var roundtripped = StateSerializer.Deserialize(json);
|
||||||
|
|
||||||
Assert.NotNull(roundtripped.SkipUserEsp);
|
var obs = roundtripped.ScenarioObservations;
|
||||||
Assert.True(roundtripped.SkipUserEsp!.Value);
|
Assert.NotNull(obs.SkipUserEsp);
|
||||||
Assert.Equal(7, roundtripped.SkipUserEsp!.SourceSignalOrdinal);
|
Assert.True(obs.SkipUserEsp!.Value);
|
||||||
|
Assert.Equal(7, obs.SkipUserEsp!.SourceSignalOrdinal);
|
||||||
|
|
||||||
Assert.NotNull(roundtripped.SkipDeviceEsp);
|
Assert.NotNull(obs.SkipDeviceEsp);
|
||||||
Assert.False(roundtripped.SkipDeviceEsp!.Value);
|
Assert.False(obs.SkipDeviceEsp!.Value);
|
||||||
Assert.Equal(7, roundtripped.SkipDeviceEsp!.SourceSignalOrdinal);
|
Assert.Equal(7, obs.SkipDeviceEsp!.SourceSignalOrdinal);
|
||||||
|
|
||||||
|
Assert.Equal(EspConfig.DeviceEspOnly, roundtripped.ScenarioProfile.EspConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EspConfigDetected_initialState_facts_areNull()
|
public void EspConfigDetected_initialState_observations_areNull()
|
||||||
{
|
{
|
||||||
var state = DecisionState.CreateInitial("s", "t");
|
var state = DecisionState.CreateInitial("s", "t");
|
||||||
Assert.Null(state.SkipUserEsp);
|
Assert.Null(state.ScenarioObservations.SkipUserEsp);
|
||||||
Assert.Null(state.SkipDeviceEsp);
|
Assert.Null(state.ScenarioObservations.SkipDeviceEsp);
|
||||||
|
Assert.Equal(EspConfig.Unknown, state.ScenarioProfile.EspConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DecisionSignal MakeSignal(
|
private static DecisionSignal MakeSignal(
|
||||||
|
|||||||
+2
-2
@@ -180,8 +180,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.Harness
|
|||||||
state.Outcome?.ToString() ?? "null",
|
state.Outcome?.ToString() ?? "null",
|
||||||
state.StepIndex.ToString(),
|
state.StepIndex.ToString(),
|
||||||
state.LastAppliedSignalOrdinal.ToString(),
|
state.LastAppliedSignalOrdinal.ToString(),
|
||||||
state.WhiteGloveSealing.Level.ToString(),
|
state.ClassifierOutcomes.WhiteGloveSealing.Level.ToString(),
|
||||||
state.WhiteGloveSealing.Score.ToString());
|
state.ClassifierOutcomes.WhiteGloveSealing.Score.ToString());
|
||||||
|
|
||||||
using (var sha = System.Security.Cryptography.SHA256.Create())
|
using (var sha = System.Security.Cryptography.SHA256.Create())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -79,9 +79,10 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
var step = engine.Reduce(midFlight, MakeSignal(ordinal: 10, payload: MinimalPayload()));
|
var step = engine.Reduce(midFlight, MakeSignal(ordinal: 10, payload: MinimalPayload()));
|
||||||
|
|
||||||
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
|
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
|
||||||
Assert.Same(midFlight.EnrollmentType, step.NewState.EnrollmentType);
|
// Informational signals must not change any of the typed aggregates.
|
||||||
Assert.Same(midFlight.WhiteGloveSealing, step.NewState.WhiteGloveSealing);
|
Assert.Same(midFlight.ScenarioProfile, step.NewState.ScenarioProfile);
|
||||||
Assert.Same(midFlight.DeviceOnlyDeployment, step.NewState.DeviceOnlyDeployment);
|
Assert.Same(midFlight.ScenarioObservations, step.NewState.ScenarioObservations);
|
||||||
|
Assert.Same(midFlight.ClassifierOutcomes, step.NewState.ClassifierOutcomes);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
Assert.Equal(EnrollmentPhase.AccountSetup, result.FinalState.CurrentEnrollmentPhase!.Value);
|
Assert.Equal(EnrollmentPhase.AccountSetup, result.FinalState.CurrentEnrollmentPhase!.Value);
|
||||||
Assert.Empty(result.FinalState.Deadlines);
|
Assert.Empty(result.FinalState.Deadlines);
|
||||||
|
|
||||||
// EnrollmentType should reach Strong after ImeUserSessionCompleted.
|
// Codex follow-up #5 — the legacy Hypothesis EnrollmentType was absorbed into
|
||||||
Assert.Equal(HypothesisLevel.Strong, result.FinalState.EnrollmentType.Level);
|
// ScenarioProfile. Mode=Classic @ High confidence is the equivalent of the old
|
||||||
Assert.Equal("ime_user_session_completed", result.FinalState.EnrollmentType.Reason);
|
// "Strong" level after ImeUserSessionCompleted.
|
||||||
|
Assert.Equal(EnrollmentMode.Classic, result.FinalState.ScenarioProfile.Mode);
|
||||||
|
Assert.Equal(ProfileConfidence.High, result.FinalState.ScenarioProfile.Confidence);
|
||||||
|
Assert.Equal("ime_user_session_completed", result.FinalState.ScenarioProfile.Reason);
|
||||||
|
|
||||||
// 7 fixture signals + 1 auto-fired FinalizingGrace deadline (plan §5 Fix 6 — the
|
// 7 fixture signals + 1 auto-fired FinalizingGrace deadline (plan §5 Fix 6 — the
|
||||||
// reducer now parks in Finalizing after both prerequisites resolve; the harness
|
// reducer now parks in Finalizing after both prerequisites resolve; the harness
|
||||||
@@ -92,19 +95,20 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
|
Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
|
||||||
Assert.Equal("Success", result.FinalState.HelloOutcome!.Value);
|
Assert.Equal("Success", result.FinalState.HelloOutcome!.Value);
|
||||||
|
|
||||||
// AadJoinedWithUser fact is recorded from the Late-AADJ signal.
|
// AadUserJoinWithUserObserved fact is recorded from the Late-AADJ signal.
|
||||||
Assert.NotNull(result.FinalState.AadJoinedWithUser);
|
Assert.NotNull(result.FinalState.ScenarioObservations.AadUserJoinWithUserObserved);
|
||||||
Assert.True(result.FinalState.AadJoinedWithUser!.Value);
|
Assert.True(result.FinalState.ScenarioObservations.AadUserJoinWithUserObserved!.Value);
|
||||||
|
|
||||||
// EnrollmentType reason carries the late-AADJ annotation (set by the handler),
|
// Profile reason carries the last-strengthening reason token — after the IME
|
||||||
// and then advances to "ime_user_session_completed" on the IME signal.
|
// signal that is "ime_user_session_completed", not the prior late-AADJ annotation.
|
||||||
Assert.Equal("ime_user_session_completed", result.FinalState.EnrollmentType.Reason);
|
Assert.Equal("ime_user_session_completed", result.FinalState.ScenarioProfile.Reason);
|
||||||
|
|
||||||
// Hypothesis chain:
|
// Profile chain:
|
||||||
// 1. EspPhaseChanged(AccountSetup) -> Weak / account_setup_observed
|
// 1. EspPhaseChanged(AccountSetup) -> Mode=Classic @ Medium / account_setup_observed
|
||||||
// 2. AadUserJoinedLate -> Weak / late_aadj_observed (stage unchanged)
|
// 2. AadUserJoinedLate -> reason annotation only, stage unchanged
|
||||||
// 3. ImeUserSessionCompleted -> Strong / ime_user_session_completed
|
// 3. ImeUserSessionCompleted -> Mode=Classic @ High / ime_user_session_completed
|
||||||
Assert.Equal(HypothesisLevel.Strong, result.FinalState.EnrollmentType.Level);
|
Assert.Equal(EnrollmentMode.Classic, result.FinalState.ScenarioProfile.Mode);
|
||||||
|
Assert.Equal(ProfileConfidence.High, result.FinalState.ScenarioProfile.Confidence);
|
||||||
|
|
||||||
// Critical regression guard: the AadUserJoinedLate transition did NOT take the
|
// Critical regression guard: the AadUserJoinedLate transition did NOT take the
|
||||||
// session to a terminal stage (stayed on EspAccountSetup). It is taken=true (the
|
// session to a terminal stage (stayed on EspAccountSetup). It is taken=true (the
|
||||||
@@ -166,7 +170,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
Assert.Equal(SessionStage.EspAccountSetup, provTransition.ToStage);
|
Assert.Equal(SessionStage.EspAccountSetup, provTransition.ToStage);
|
||||||
|
|
||||||
// DeviceOnlyDeployment hypothesis must stay Unknown — this is a Classic UserDriven path.
|
// DeviceOnlyDeployment hypothesis must stay Unknown — this is a Classic UserDriven path.
|
||||||
Assert.Equal(HypothesisLevel.Unknown, result.FinalState.DeviceOnlyDeployment.Level);
|
Assert.Equal(HypothesisLevel.Unknown, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Level);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
// Stage flow: EspDeviceSetup -> (Reboot) preserved -> EspResumed preserved ->
|
// Stage flow: EspDeviceSetup -> (Reboot) preserved -> EspResumed preserved ->
|
||||||
// EspAccountSetup on next EspPhaseChanged -> AwaitingHello -> Completed.
|
// EspAccountSetup on next EspPhaseChanged -> AwaitingHello -> Completed.
|
||||||
// Importantly, no WhiteGloveSealed — reboot alone doesn't imply WG.
|
// Importantly, no WhiteGloveSealed — reboot alone doesn't imply WG.
|
||||||
Assert.NotEqual(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level);
|
Assert.NotEqual(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
|
||||||
Assert.Null(result.FinalState.ShellCoreWhiteGloveSuccessSeen);
|
Assert.Null(result.FinalState.ScenarioObservations.ShellCoreWhiteGloveSuccessSeen);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+10
-9
@@ -25,18 +25,19 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
// No user-presence facts should have been produced.
|
// No user-presence facts should have been produced.
|
||||||
Assert.Null(result.FinalState.HelloResolvedUtc);
|
Assert.Null(result.FinalState.HelloResolvedUtc);
|
||||||
Assert.Null(result.FinalState.DesktopArrivedUtc);
|
Assert.Null(result.FinalState.DesktopArrivedUtc);
|
||||||
Assert.Null(result.FinalState.AadJoinedWithUser);
|
Assert.Null(result.FinalState.ScenarioObservations.AadUserJoinWithUserObserved);
|
||||||
Assert.Null(result.FinalState.AccountSetupEnteredUtc);
|
Assert.Null(result.FinalState.AccountSetupEnteredUtc);
|
||||||
|
|
||||||
// DeviceOnly hypothesis confirmed as DeviceOnly (no user presence seen).
|
// DeviceOnly hypothesis confirmed as DeviceOnly (no user presence seen).
|
||||||
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.DeviceOnlyDeployment.Level);
|
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Level);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
DecisionEngine.DeviceOnlyReasons.DeviceOnly,
|
DecisionEngine.DeviceOnlyReasons.DeviceOnly,
|
||||||
result.FinalState.DeviceOnlyDeployment.Reason);
|
result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Reason);
|
||||||
|
|
||||||
// EnrollmentType upgraded to Strong by the SelfDeploying handler.
|
// Profile upgraded to Mode=SelfDeploying @ High by the SelfDeploying handler.
|
||||||
Assert.Equal(HypothesisLevel.Strong, result.FinalState.EnrollmentType.Level);
|
Assert.Equal(EnrollmentMode.SelfDeploying, result.FinalState.ScenarioProfile.Mode);
|
||||||
Assert.Equal("selfdeploying_provisioning_complete", result.FinalState.EnrollmentType.Reason);
|
Assert.Equal(ProfileConfidence.High, result.FinalState.ScenarioProfile.Confidence);
|
||||||
|
Assert.Equal("selfdeploying_provisioning_complete", result.FinalState.ScenarioProfile.Reason);
|
||||||
|
|
||||||
Assert.Equal(3, result.Transitions.Count);
|
Assert.Equal(3, result.Transitions.Count);
|
||||||
Assert.All(result.Transitions, t => Assert.True(t.Taken));
|
Assert.All(result.Transitions, t => Assert.True(t.Taken));
|
||||||
@@ -67,10 +68,10 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
|
Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
|
||||||
|
|
||||||
// DeviceOnlyDeployment goes through Strong (by deadline) -> Confirmed (by provisioning).
|
// DeviceOnlyDeployment goes through Strong (by deadline) -> Confirmed (by provisioning).
|
||||||
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.DeviceOnlyDeployment.Level);
|
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Level);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
DecisionEngine.DeviceOnlyReasons.DeviceOnly,
|
DecisionEngine.DeviceOnlyReasons.DeviceOnly,
|
||||||
result.FinalState.DeviceOnlyDeployment.Reason);
|
result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Reason);
|
||||||
|
|
||||||
// The deadline transition is present and taken (hypothesis update without stage change).
|
// The deadline transition is present and taken (hypothesis update without stage change).
|
||||||
var deadlineTransition = Assert.Single(
|
var deadlineTransition = Assert.Single(
|
||||||
@@ -98,7 +99,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
Assert.DoesNotContain(
|
Assert.DoesNotContain(
|
||||||
result.FinalState.Deadlines,
|
result.FinalState.Deadlines,
|
||||||
d => d.Name == DeadlineNames.DeviceOnlyEspDetection);
|
d => d.Name == DeadlineNames.DeviceOnlyEspDetection);
|
||||||
Assert.Equal(HypothesisLevel.Unknown, result.FinalState.DeviceOnlyDeployment.Level);
|
Assert.Equal(HypothesisLevel.Unknown, result.FinalState.ClassifierOutcomes.DeviceOnlyDeployment.Level);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -35,10 +35,10 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
|
|
||||||
Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
|
Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
|
||||||
Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome);
|
Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome);
|
||||||
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level);
|
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
|
||||||
Assert.Equal(80, result.FinalState.WhiteGloveSealing.Score);
|
Assert.Equal(80, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Score);
|
||||||
Assert.NotNull(result.FinalState.ShellCoreWhiteGloveSuccessSeen);
|
Assert.NotNull(result.FinalState.ScenarioObservations.ShellCoreWhiteGloveSuccessSeen);
|
||||||
Assert.True(result.FinalState.ShellCoreWhiteGloveSuccessSeen!.Value);
|
Assert.True(result.FinalState.ScenarioObservations.ShellCoreWhiteGloveSuccessSeen!.Value);
|
||||||
Assert.Empty(result.FinalState.Deadlines);
|
Assert.Empty(result.FinalState.Deadlines);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +51,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
|
|
||||||
Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
|
Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
|
||||||
Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome);
|
Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome);
|
||||||
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level);
|
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
|
||||||
Assert.True(result.FinalState.WhiteGloveSealing.Score >= WhiteGloveSealingClassifier.HighThreshold);
|
Assert.True(result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Score >= WhiteGloveSealingClassifier.HighThreshold);
|
||||||
|
|
||||||
// The tick fired between the two WG signals — snapshot unchanged at that point,
|
// The tick fired between the two WG signals — snapshot unchanged at that point,
|
||||||
// so it was anti-loop-skipped. The SealingPattern run and the ShellCore run both
|
// so it was anti-loop-skipped. The SealingPattern run and the ShellCore run both
|
||||||
@@ -74,12 +74,12 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
|
Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
|
||||||
|
|
||||||
// WhiteGlove verdict should be Rejected (hard-excluded by AadJoinedWithUser).
|
// WhiteGlove verdict should be Rejected (hard-excluded by AadJoinedWithUser).
|
||||||
Assert.Equal(HypothesisLevel.Rejected, result.FinalState.WhiteGloveSealing.Level);
|
Assert.Equal(HypothesisLevel.Rejected, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
|
||||||
Assert.Equal(0, result.FinalState.WhiteGloveSealing.Score);
|
Assert.Equal(0, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Score);
|
||||||
|
|
||||||
// The late-AADJ fact + Hello + Desktop are all recorded.
|
// The late-AADJ fact + Hello + Desktop are all recorded.
|
||||||
Assert.NotNull(result.FinalState.AadJoinedWithUser);
|
Assert.NotNull(result.FinalState.ScenarioObservations.AadUserJoinWithUserObserved);
|
||||||
Assert.True(result.FinalState.AadJoinedWithUser!.Value);
|
Assert.True(result.FinalState.ScenarioObservations.AadUserJoinWithUserObserved!.Value);
|
||||||
Assert.NotNull(result.FinalState.HelloResolvedUtc);
|
Assert.NotNull(result.FinalState.HelloResolvedUtc);
|
||||||
Assert.NotNull(result.FinalState.DesktopArrivedUtc);
|
Assert.NotNull(result.FinalState.DesktopArrivedUtc);
|
||||||
Assert.Equal("Success", result.FinalState.HelloOutcome!.Value);
|
Assert.Equal("Success", result.FinalState.HelloOutcome!.Value);
|
||||||
@@ -94,8 +94,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
|
|
||||||
// SealingPattern was seen but insufficient alone for Confirmed.
|
// SealingPattern was seen but insufficient alone for Confirmed.
|
||||||
Assert.NotEqual(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
|
Assert.NotEqual(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
|
||||||
Assert.Equal(HypothesisLevel.Weak, result.FinalState.WhiteGloveSealing.Level);
|
Assert.Equal(HypothesisLevel.Weak, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
|
||||||
Assert.Equal(40, result.FinalState.WhiteGloveSealing.Score);
|
Assert.Equal(40, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Score);
|
||||||
|
|
||||||
var runs = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:run", out var r) ? r : 0;
|
var runs = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:run", out var r) ? r : 0;
|
||||||
var skipped = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:skipped_by_antiloop", out var s) ? s : 0;
|
var skipped = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:skipped_by_antiloop", out var s) ? s : 0;
|
||||||
|
|||||||
+6
-6
@@ -29,8 +29,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
|
|
||||||
Assert.Equal(SessionStage.WhiteGloveCompletedPart2, result.FinalState.Stage);
|
Assert.Equal(SessionStage.WhiteGloveCompletedPart2, result.FinalState.Stage);
|
||||||
Assert.Equal(SessionOutcome.WhiteGlovePart2Complete, result.FinalState.Outcome);
|
Assert.Equal(SessionOutcome.WhiteGlovePart2Complete, result.FinalState.Outcome);
|
||||||
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGlovePart2Completion.Level);
|
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGlovePart2Completion.Level);
|
||||||
Assert.Equal(100, result.FinalState.WhiteGlovePart2Completion.Score);
|
Assert.Equal(100, result.FinalState.ClassifierOutcomes.WhiteGlovePart2Completion.Score);
|
||||||
|
|
||||||
// All four Part-2 facts should be present.
|
// All four Part-2 facts should be present.
|
||||||
Assert.NotNull(result.FinalState.UserAadSignInCompleteUtc);
|
Assert.NotNull(result.FinalState.UserAadSignInCompleteUtc);
|
||||||
@@ -39,7 +39,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
Assert.NotNull(result.FinalState.AccountSetupCompletedPart2Utc);
|
Assert.NotNull(result.FinalState.AccountSetupCompletedPart2Utc);
|
||||||
|
|
||||||
// Part-1 hypothesis still Confirmed (not reset by the Part-2 transition).
|
// Part-1 hypothesis still Confirmed (not reset by the Part-2 transition).
|
||||||
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level);
|
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
|
||||||
|
|
||||||
// SystemReboot fact recorded from SessionRecovered.
|
// SystemReboot fact recorded from SessionRecovered.
|
||||||
Assert.NotNull(result.FinalState.SystemRebootUtc);
|
Assert.NotNull(result.FinalState.SystemRebootUtc);
|
||||||
@@ -55,8 +55,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
|
|
||||||
Assert.Equal(SessionStage.Failed, result.FinalState.Stage);
|
Assert.Equal(SessionStage.Failed, result.FinalState.Stage);
|
||||||
Assert.Equal(SessionOutcome.EnrollmentFailed, result.FinalState.Outcome);
|
Assert.Equal(SessionOutcome.EnrollmentFailed, result.FinalState.Outcome);
|
||||||
Assert.Equal(HypothesisLevel.Rejected, result.FinalState.WhiteGlovePart2Completion.Level);
|
Assert.Equal(HypothesisLevel.Rejected, result.FinalState.ClassifierOutcomes.WhiteGlovePart2Completion.Level);
|
||||||
Assert.Equal("part2_user_absent", result.FinalState.WhiteGlovePart2Completion.Reason);
|
Assert.Equal("part2_user_absent", result.FinalState.ClassifierOutcomes.WhiteGlovePart2Completion.Reason);
|
||||||
|
|
||||||
// No user-signal facts captured.
|
// No user-signal facts captured.
|
||||||
Assert.Null(result.FinalState.UserAadSignInCompleteUtc);
|
Assert.Null(result.FinalState.UserAadSignInCompleteUtc);
|
||||||
@@ -65,7 +65,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
|||||||
Assert.Null(result.FinalState.AccountSetupCompletedPart2Utc);
|
Assert.Null(result.FinalState.AccountSetupCompletedPart2Utc);
|
||||||
|
|
||||||
// Part 1 verdict remains Confirmed even as Part 2 failed.
|
// Part 1 verdict remains Confirmed even as Part 2 failed.
|
||||||
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level);
|
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.ClassifierOutcomes.WhiteGloveSealing.Level);
|
||||||
Assert.Empty(result.FinalState.Deadlines);
|
Assert.Empty(result.FinalState.Deadlines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
|||||||
Assert.Equal("tenant-1", state.TenantId);
|
Assert.Equal("tenant-1", state.TenantId);
|
||||||
Assert.Equal(SessionStage.SessionStarted, state.Stage);
|
Assert.Equal(SessionStage.SessionStarted, state.Stage);
|
||||||
Assert.Null(state.Outcome);
|
Assert.Null(state.Outcome);
|
||||||
Assert.Equal(HypothesisLevel.Unknown, state.EnrollmentType.Level);
|
// Codex follow-up #5 — the legacy hypothesis fields are now aggregated into
|
||||||
Assert.Equal(HypothesisLevel.Unknown, state.WhiteGloveSealing.Level);
|
// ScenarioProfile (derived classification) and ClassifierOutcomes (verdict storage).
|
||||||
Assert.Equal(HypothesisLevel.Unknown, state.WhiteGlovePart2Completion.Level);
|
Assert.Equal(EnrollmentMode.Unknown, state.ScenarioProfile.Mode);
|
||||||
Assert.Equal(HypothesisLevel.Unknown, state.DeviceOnlyDeployment.Level);
|
Assert.Equal(HypothesisLevel.Unknown, state.ClassifierOutcomes.WhiteGloveSealing.Level);
|
||||||
|
Assert.Equal(HypothesisLevel.Unknown, state.ClassifierOutcomes.WhiteGlovePart2Completion.Level);
|
||||||
|
Assert.Equal(HypothesisLevel.Unknown, state.ClassifierOutcomes.DeviceOnlyDeployment.Level);
|
||||||
Assert.Empty(state.Deadlines);
|
Assert.Empty(state.Deadlines);
|
||||||
Assert.Equal(-1, state.LastAppliedSignalOrdinal);
|
Assert.Equal(-1, state.LastAppliedSignalOrdinal);
|
||||||
Assert.Equal(0, state.StepIndex);
|
Assert.Equal(0, state.StepIndex);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
using AutopilotMonitor.DecisionCore.State;
|
||||||
|
using AutopilotMonitor.Shared.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Basic invariants for <see cref="ClassifierOutcomes"/>. Codex follow-up #5.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClassifierOutcomesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Empty_allThreeHypothesesAreUnknown()
|
||||||
|
{
|
||||||
|
var o = ClassifierOutcomes.Empty;
|
||||||
|
|
||||||
|
Assert.Equal(HypothesisLevel.Unknown, o.WhiteGloveSealing.Level);
|
||||||
|
Assert.Equal(HypothesisLevel.Unknown, o.WhiteGlovePart2Completion.Level);
|
||||||
|
Assert.Equal(HypothesisLevel.Unknown, o.DeviceOnlyDeployment.Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithWhiteGloveSealing_setsFirstSlotOnly()
|
||||||
|
{
|
||||||
|
var h = Hypothesis.UnknownInstance.With(
|
||||||
|
level: HypothesisLevel.Confirmed,
|
||||||
|
reason: "classifier_confirmed",
|
||||||
|
score: 80,
|
||||||
|
lastUpdatedUtc: DateTime.UtcNow);
|
||||||
|
|
||||||
|
var outcomes = ClassifierOutcomes.Empty.WithWhiteGloveSealing(h);
|
||||||
|
|
||||||
|
Assert.Equal(HypothesisLevel.Confirmed, outcomes.WhiteGloveSealing.Level);
|
||||||
|
Assert.Equal(HypothesisLevel.Unknown, outcomes.WhiteGlovePart2Completion.Level);
|
||||||
|
Assert.Equal(HypothesisLevel.Unknown, outcomes.DeviceOnlyDeployment.Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithDeviceOnlyDeployment_preservesOtherSlots()
|
||||||
|
{
|
||||||
|
var wg = Hypothesis.UnknownInstance.With(level: HypothesisLevel.Weak, score: 40, lastUpdatedUtc: DateTime.UtcNow);
|
||||||
|
var outcomes = ClassifierOutcomes.Empty
|
||||||
|
.WithWhiteGloveSealing(wg)
|
||||||
|
.WithDeviceOnlyDeployment(Hypothesis.UnknownInstance.With(level: HypothesisLevel.Confirmed, score: 100, lastUpdatedUtc: DateTime.UtcNow));
|
||||||
|
|
||||||
|
Assert.Equal(HypothesisLevel.Weak, outcomes.WhiteGloveSealing.Level);
|
||||||
|
Assert.Equal(HypothesisLevel.Confirmed, outcomes.DeviceOnlyDeployment.Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_rejectsNullHypothesis()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => new ClassifierOutcomes(
|
||||||
|
whiteGloveSealing: null!,
|
||||||
|
whiteGlovePart2Completion: Hypothesis.UnknownInstance,
|
||||||
|
deviceOnlyDeployment: Hypothesis.UnknownInstance));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_nullValue_throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => ClassifierOutcomes.Empty.WithWhiteGloveSealing(null!));
|
||||||
|
Assert.Throws<ArgumentNullException>(() => ClassifierOutcomes.Empty.WithWhiteGlovePart2Completion(null!));
|
||||||
|
Assert.Throws<ArgumentNullException>(() => ClassifierOutcomes.Empty.WithDeviceOnlyDeployment(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
using AutopilotMonitor.DecisionCore.State;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Set-once + ordinal semantics for <see cref="EnrollmentScenarioObservations"/>.
|
||||||
|
/// Codex follow-up #5.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EnrollmentScenarioObservationsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Empty_hasAllNullFacts()
|
||||||
|
{
|
||||||
|
var obs = EnrollmentScenarioObservations.Empty;
|
||||||
|
|
||||||
|
Assert.Null(obs.ShellCoreWhiteGloveSuccessSeen);
|
||||||
|
Assert.Null(obs.WhiteGloveSealingPatternSeen);
|
||||||
|
Assert.Null(obs.AadUserJoinWithUserObserved);
|
||||||
|
Assert.Null(obs.SkipUserEsp);
|
||||||
|
Assert.Null(obs.SkipDeviceEsp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithShellCoreWhiteGloveSuccessSeen_setsFact_preservesOrdinal()
|
||||||
|
{
|
||||||
|
var obs = EnrollmentScenarioObservations.Empty
|
||||||
|
.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 17);
|
||||||
|
|
||||||
|
Assert.NotNull(obs.ShellCoreWhiteGloveSuccessSeen);
|
||||||
|
Assert.True(obs.ShellCoreWhiteGloveSuccessSeen!.Value);
|
||||||
|
Assert.Equal(17, obs.ShellCoreWhiteGloveSuccessSeen!.SourceSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithShellCoreWhiteGloveSuccessSeen_alreadySet_isNoOp()
|
||||||
|
{
|
||||||
|
var seeded = EnrollmentScenarioObservations.Empty
|
||||||
|
.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 3);
|
||||||
|
|
||||||
|
var repeat = seeded.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 99);
|
||||||
|
|
||||||
|
// Set-once: first ordinal wins; second call is a no-op returning the same instance.
|
||||||
|
Assert.Same(seeded, repeat);
|
||||||
|
Assert.Equal(3, repeat.ShellCoreWhiteGloveSuccessSeen!.SourceSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithAadUserJoinWithUserObserved_setsValueAndOrdinal()
|
||||||
|
{
|
||||||
|
var obs = EnrollmentScenarioObservations.Empty
|
||||||
|
.WithAadUserJoinWithUserObserved(value: true, sourceSignalOrdinal: 42);
|
||||||
|
|
||||||
|
Assert.NotNull(obs.AadUserJoinWithUserObserved);
|
||||||
|
Assert.True(obs.AadUserJoinWithUserObserved!.Value);
|
||||||
|
Assert.Equal(42, obs.AadUserJoinWithUserObserved!.SourceSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithAadUserJoinWithUserObserved_recordsFalseValue()
|
||||||
|
{
|
||||||
|
var obs = EnrollmentScenarioObservations.Empty
|
||||||
|
.WithAadUserJoinWithUserObserved(value: false, sourceSignalOrdinal: 5);
|
||||||
|
|
||||||
|
Assert.False(obs.AadUserJoinWithUserObserved!.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithSkipUserEsp_thenWithSkipDeviceEsp_bothSet_independentOrdinals()
|
||||||
|
{
|
||||||
|
var obs = EnrollmentScenarioObservations.Empty
|
||||||
|
.WithSkipUserEsp(true, sourceSignalOrdinal: 1)
|
||||||
|
.WithSkipDeviceEsp(false, sourceSignalOrdinal: 2);
|
||||||
|
|
||||||
|
Assert.True(obs.SkipUserEsp!.Value);
|
||||||
|
Assert.Equal(1, obs.SkipUserEsp!.SourceSignalOrdinal);
|
||||||
|
Assert.False(obs.SkipDeviceEsp!.Value);
|
||||||
|
Assert.Equal(2, obs.SkipDeviceEsp!.SourceSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithSkipUserEsp_alreadySet_isNoOpEvenIfValueDiffers()
|
||||||
|
{
|
||||||
|
var seeded = EnrollmentScenarioObservations.Empty
|
||||||
|
.WithSkipUserEsp(value: false, sourceSignalOrdinal: 1);
|
||||||
|
|
||||||
|
var repeat = seeded.WithSkipUserEsp(value: true, sourceSignalOrdinal: 99);
|
||||||
|
|
||||||
|
Assert.Same(seeded, repeat);
|
||||||
|
Assert.False(repeat.SkipUserEsp!.Value);
|
||||||
|
Assert.Equal(1, repeat.SkipUserEsp!.SourceSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Mutations_returnNewInstances()
|
||||||
|
{
|
||||||
|
var empty = EnrollmentScenarioObservations.Empty;
|
||||||
|
var mutated = empty.WithWhiteGloveSealingPatternSeen(7);
|
||||||
|
|
||||||
|
Assert.NotSame(empty, mutated);
|
||||||
|
Assert.Null(empty.WhiteGloveSealingPatternSeen);
|
||||||
|
Assert.NotNull(mutated.WhiteGloveSealingPatternSeen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using AutopilotMonitor.DecisionCore.State;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Typ-Invarianten für <see cref="EnrollmentScenarioProfile"/> — Codex follow-up #5.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EnrollmentScenarioProfileTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Empty_hasUnknownDefaults()
|
||||||
|
{
|
||||||
|
var p = EnrollmentScenarioProfile.Empty;
|
||||||
|
|
||||||
|
Assert.Equal(EnrollmentMode.Unknown, p.Mode);
|
||||||
|
Assert.Equal(EnrollmentJoinMode.Unknown, p.JoinMode);
|
||||||
|
Assert.Equal(EspConfig.Unknown, p.EspConfig);
|
||||||
|
Assert.Equal(PreProvisioningSide.None, p.PreProvisioningSide);
|
||||||
|
Assert.Equal(ProfileConfidence.Low, p.Confidence);
|
||||||
|
Assert.Equal(-1, p.EvidenceOrdinal);
|
||||||
|
Assert.Null(p.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_omittedParam_preservesCurrentValue()
|
||||||
|
{
|
||||||
|
var seed = EnrollmentScenarioProfile.Empty.With(
|
||||||
|
mode: EnrollmentMode.Classic,
|
||||||
|
confidence: ProfileConfidence.Medium,
|
||||||
|
reason: "account_setup_observed",
|
||||||
|
evidenceOrdinal: 5);
|
||||||
|
|
||||||
|
var patched = seed.With(confidence: ProfileConfidence.High, evidenceOrdinal: 9);
|
||||||
|
|
||||||
|
Assert.Equal(EnrollmentMode.Classic, patched.Mode);
|
||||||
|
Assert.Equal(ProfileConfidence.High, patched.Confidence);
|
||||||
|
Assert.Equal("account_setup_observed", patched.Reason);
|
||||||
|
Assert.Equal(9, patched.EvidenceOrdinal);
|
||||||
|
Assert.Equal(EnrollmentJoinMode.Unknown, patched.JoinMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_returnsNewInstance_originalUnchanged()
|
||||||
|
{
|
||||||
|
var seed = EnrollmentScenarioProfile.Empty;
|
||||||
|
var mutated = seed.With(mode: EnrollmentMode.WhiteGlove, confidence: ProfileConfidence.High);
|
||||||
|
|
||||||
|
Assert.Equal(EnrollmentMode.Unknown, seed.Mode);
|
||||||
|
Assert.Equal(EnrollmentMode.WhiteGlove, mutated.Mode);
|
||||||
|
Assert.NotSame(seed, mutated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_reasonNull_doesNotClear()
|
||||||
|
{
|
||||||
|
// Intentional: Reason is historical evidence; With(reason:null) is interpreted as
|
||||||
|
// "no change" (same semantic as Hypothesis.With).
|
||||||
|
var seed = EnrollmentScenarioProfile.Empty.With(reason: "initial");
|
||||||
|
var patched = seed.With(reason: null);
|
||||||
|
|
||||||
|
Assert.Equal("initial", patched.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ProfileConfidence.Low, ProfileConfidence.Medium, ProfileConfidence.Medium)]
|
||||||
|
[InlineData(ProfileConfidence.Low, ProfileConfidence.Low, ProfileConfidence.Low)]
|
||||||
|
[InlineData(ProfileConfidence.Medium, ProfileConfidence.Low, ProfileConfidence.Medium)]
|
||||||
|
[InlineData(ProfileConfidence.High, ProfileConfidence.Medium, ProfileConfidence.High)]
|
||||||
|
public void Confidence_monotonicHelper_picksHigher(ProfileConfidence a, ProfileConfidence b, ProfileConfidence expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, EnrollmentScenarioProfileUpdater.Max(a, b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+304
@@ -0,0 +1,304 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AutopilotMonitor.DecisionCore.Engine;
|
||||||
|
using AutopilotMonitor.DecisionCore.Signals;
|
||||||
|
using AutopilotMonitor.DecisionCore.State;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Signal → <see cref="EnrollmentScenarioProfile"/> dispatch tests for
|
||||||
|
/// <see cref="EnrollmentScenarioProfileUpdater"/>. Codex follow-up #5.
|
||||||
|
/// Covers monotonic confidence, mode preservation, and the split-payload
|
||||||
|
/// EspConfig contract.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EnrollmentScenarioProfileUpdaterTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime FixedUtc = new DateTime(2026, 4, 24, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
// ================================================================== SessionStarted
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionStarted_v2_setsDevicePreparation_mediumConfidence()
|
||||||
|
{
|
||||||
|
var signal = MakeSignal(
|
||||||
|
DecisionSignalKind.SessionStarted,
|
||||||
|
ordinal: 0,
|
||||||
|
payload: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[SignalPayloadKeys.EnrollmentType] = "v2",
|
||||||
|
[SignalPayloadKeys.IsHybridJoin] = "false",
|
||||||
|
});
|
||||||
|
|
||||||
|
var profile = EnrollmentScenarioProfileUpdater.ApplySessionStarted(
|
||||||
|
EnrollmentScenarioProfile.Empty, signal);
|
||||||
|
|
||||||
|
Assert.Equal(EnrollmentMode.DevicePreparation, profile.Mode);
|
||||||
|
Assert.Equal(ProfileConfidence.Medium, profile.Confidence);
|
||||||
|
Assert.Equal(EnrollmentJoinMode.AzureAdJoin, profile.JoinMode);
|
||||||
|
Assert.Equal("session_started:v2", profile.Reason);
|
||||||
|
Assert.Equal(0, profile.EvidenceOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionStarted_v1_leavesModeUnknown_recordsReason()
|
||||||
|
{
|
||||||
|
var signal = MakeSignal(
|
||||||
|
DecisionSignalKind.SessionStarted,
|
||||||
|
ordinal: 0,
|
||||||
|
payload: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||||
|
[SignalPayloadKeys.IsHybridJoin] = "true",
|
||||||
|
});
|
||||||
|
|
||||||
|
var profile = EnrollmentScenarioProfileUpdater.ApplySessionStarted(
|
||||||
|
EnrollmentScenarioProfile.Empty, signal);
|
||||||
|
|
||||||
|
// v1 is ambiguous (Classic / SelfDeploying / WhiteGlove); later signals resolve it.
|
||||||
|
Assert.Equal(EnrollmentMode.Unknown, profile.Mode);
|
||||||
|
Assert.Equal(EnrollmentJoinMode.HybridAzureAdJoin, profile.JoinMode);
|
||||||
|
Assert.Equal("session_started:v1", profile.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionStarted_emptyPayload_returnsSameInstance()
|
||||||
|
{
|
||||||
|
var signal = MakeSignal(DecisionSignalKind.SessionStarted, ordinal: 0, payload: null);
|
||||||
|
|
||||||
|
var profile = EnrollmentScenarioProfileUpdater.ApplySessionStarted(
|
||||||
|
EnrollmentScenarioProfile.Empty, signal);
|
||||||
|
|
||||||
|
Assert.Same(EnrollmentScenarioProfile.Empty, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== EspConfigDetected
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EspConfigDetected_bothHalvesPresent_derivesEspConfig()
|
||||||
|
{
|
||||||
|
var signal = MakeSignal(
|
||||||
|
DecisionSignalKind.EspConfigDetected,
|
||||||
|
ordinal: 3,
|
||||||
|
payload: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[SignalPayloadKeys.SkipUserEsp] = "true",
|
||||||
|
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||||
|
});
|
||||||
|
|
||||||
|
var (profile, obs) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(
|
||||||
|
EnrollmentScenarioProfile.Empty, EnrollmentScenarioObservations.Empty, signal);
|
||||||
|
|
||||||
|
Assert.Equal(EspConfig.DeviceEspOnly, profile.EspConfig);
|
||||||
|
Assert.Equal(ProfileConfidence.Medium, profile.Confidence);
|
||||||
|
Assert.True(obs.SkipUserEsp!.Value);
|
||||||
|
Assert.False(obs.SkipDeviceEsp!.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EspConfigDetected_partialFirstSignal_profileStaysUnknown_observationFilled()
|
||||||
|
{
|
||||||
|
var signal = MakeSignal(
|
||||||
|
DecisionSignalKind.EspConfigDetected,
|
||||||
|
ordinal: 1,
|
||||||
|
payload: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[SignalPayloadKeys.SkipUserEsp] = "true",
|
||||||
|
});
|
||||||
|
|
||||||
|
var (profile, obs) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(
|
||||||
|
EnrollmentScenarioProfile.Empty, EnrollmentScenarioObservations.Empty, signal);
|
||||||
|
|
||||||
|
// Profile.EspConfig cannot be derived without both halves.
|
||||||
|
Assert.Equal(EspConfig.Unknown, profile.EspConfig);
|
||||||
|
Assert.True(obs.SkipUserEsp!.Value);
|
||||||
|
Assert.Null(obs.SkipDeviceEsp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EspConfigDetected_secondSignalCompletesPair_derivesEspConfig()
|
||||||
|
{
|
||||||
|
var first = MakeSignal(
|
||||||
|
DecisionSignalKind.EspConfigDetected,
|
||||||
|
ordinal: 1,
|
||||||
|
payload: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[SignalPayloadKeys.SkipUserEsp] = "false",
|
||||||
|
});
|
||||||
|
var second = MakeSignal(
|
||||||
|
DecisionSignalKind.EspConfigDetected,
|
||||||
|
ordinal: 2,
|
||||||
|
payload: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||||
|
});
|
||||||
|
|
||||||
|
var (p1, o1) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(
|
||||||
|
EnrollmentScenarioProfile.Empty, EnrollmentScenarioObservations.Empty, first);
|
||||||
|
var (p2, o2) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(p1, o1, second);
|
||||||
|
|
||||||
|
Assert.Equal(EspConfig.Unknown, p1.EspConfig);
|
||||||
|
Assert.Equal(EspConfig.FullEsp, p2.EspConfig);
|
||||||
|
Assert.Equal(1, o2.SkipUserEsp!.SourceSignalOrdinal);
|
||||||
|
Assert.Equal(2, o2.SkipDeviceEsp!.SourceSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EspConfigDetected_setOnce_laterSignalDoesNotRegressEspConfig()
|
||||||
|
{
|
||||||
|
var seed = MakeSignal(
|
||||||
|
DecisionSignalKind.EspConfigDetected,
|
||||||
|
ordinal: 3,
|
||||||
|
payload: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[SignalPayloadKeys.SkipUserEsp] = "false",
|
||||||
|
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||||
|
});
|
||||||
|
var flipped = MakeSignal(
|
||||||
|
DecisionSignalKind.EspConfigDetected,
|
||||||
|
ordinal: 9,
|
||||||
|
payload: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[SignalPayloadKeys.SkipUserEsp] = "true",
|
||||||
|
[SignalPayloadKeys.SkipDeviceEsp] = "true",
|
||||||
|
});
|
||||||
|
|
||||||
|
var (p1, o1) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(
|
||||||
|
EnrollmentScenarioProfile.Empty, EnrollmentScenarioObservations.Empty, seed);
|
||||||
|
var (p2, _) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(p1, o1, flipped);
|
||||||
|
|
||||||
|
// Observations are set-once → Profile stays on the first derivation.
|
||||||
|
Assert.Equal(EspConfig.FullEsp, p2.EspConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, false, EspConfig.FullEsp)]
|
||||||
|
[InlineData(true, false, EspConfig.DeviceEspOnly)]
|
||||||
|
[InlineData(false, true, EspConfig.UserEspOnly)]
|
||||||
|
[InlineData(true, true, EspConfig.NoEsp)]
|
||||||
|
public void DeriveEspConfig_mapsAllFourCombinations(bool skipUser, bool skipDevice, EspConfig expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, EnrollmentScenarioProfileUpdater.DeriveEspConfig(skipUser, skipDevice));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== Mode promotions
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AccountSetupObserved_modeUnknown_promotesToClassic_medium()
|
||||||
|
{
|
||||||
|
var signal = MakeSignal(DecisionSignalKind.EspPhaseChanged, ordinal: 5, payload: null);
|
||||||
|
|
||||||
|
var profile = EnrollmentScenarioProfileUpdater.ApplyAccountSetupObserved(
|
||||||
|
EnrollmentScenarioProfile.Empty, signal);
|
||||||
|
|
||||||
|
Assert.Equal(EnrollmentMode.Classic, profile.Mode);
|
||||||
|
Assert.Equal(ProfileConfidence.Medium, profile.Confidence);
|
||||||
|
Assert.Equal("account_setup_observed", profile.Reason);
|
||||||
|
Assert.Equal(5, profile.EvidenceOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AccountSetupObserved_modeAlreadySelfDeploying_isNoOp()
|
||||||
|
{
|
||||||
|
var seed = EnrollmentScenarioProfile.Empty.With(
|
||||||
|
mode: EnrollmentMode.SelfDeploying,
|
||||||
|
confidence: ProfileConfidence.High);
|
||||||
|
var signal = MakeSignal(DecisionSignalKind.EspPhaseChanged, ordinal: 5, payload: null);
|
||||||
|
|
||||||
|
var profile = EnrollmentScenarioProfileUpdater.ApplyAccountSetupObserved(seed, signal);
|
||||||
|
|
||||||
|
Assert.Same(seed, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImeUserSessionCompleted_upgradesToClassicHigh()
|
||||||
|
{
|
||||||
|
var seed = EnrollmentScenarioProfile.Empty.With(
|
||||||
|
mode: EnrollmentMode.Classic,
|
||||||
|
confidence: ProfileConfidence.Medium);
|
||||||
|
var signal = MakeSignal(DecisionSignalKind.ImeUserSessionCompleted, ordinal: 10, payload: null);
|
||||||
|
|
||||||
|
var profile = EnrollmentScenarioProfileUpdater.ApplyImeUserSessionCompleted(seed, signal);
|
||||||
|
|
||||||
|
Assert.Equal(EnrollmentMode.Classic, profile.Mode);
|
||||||
|
Assert.Equal(ProfileConfidence.High, profile.Confidence);
|
||||||
|
Assert.Equal("ime_user_session_completed", profile.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImeUserSessionCompleted_alreadyHighDifferentMode_doesNotRegress()
|
||||||
|
{
|
||||||
|
var seed = EnrollmentScenarioProfile.Empty.With(
|
||||||
|
mode: EnrollmentMode.WhiteGlove,
|
||||||
|
confidence: ProfileConfidence.High);
|
||||||
|
var signal = MakeSignal(DecisionSignalKind.ImeUserSessionCompleted, ordinal: 10, payload: null);
|
||||||
|
|
||||||
|
var profile = EnrollmentScenarioProfileUpdater.ApplyImeUserSessionCompleted(seed, signal);
|
||||||
|
|
||||||
|
// Monotonic: Classic cannot overwrite a WhiteGlove classification at High confidence.
|
||||||
|
Assert.Same(seed, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeviceSetupProvisioningComplete_upgradesToSelfDeployingHigh()
|
||||||
|
{
|
||||||
|
var signal = MakeSignal(DecisionSignalKind.DeviceSetupProvisioningComplete, ordinal: 6, payload: null);
|
||||||
|
|
||||||
|
var profile = EnrollmentScenarioProfileUpdater.ApplyDeviceSetupProvisioningComplete(
|
||||||
|
EnrollmentScenarioProfile.Empty, signal);
|
||||||
|
|
||||||
|
Assert.Equal(EnrollmentMode.SelfDeploying, profile.Mode);
|
||||||
|
Assert.Equal(ProfileConfidence.High, profile.Confidence);
|
||||||
|
Assert.Equal("selfdeploying_provisioning_complete", profile.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WhiteGloveSealingConfirmed_setsWhiteGloveHigh_andTechnicianSide()
|
||||||
|
{
|
||||||
|
var signal = MakeSignal(DecisionSignalKind.ClassifierVerdictIssued, ordinal: 15, payload: null);
|
||||||
|
|
||||||
|
var profile = EnrollmentScenarioProfileUpdater.ApplyWhiteGloveSealingConfirmed(
|
||||||
|
EnrollmentScenarioProfile.Empty, signal);
|
||||||
|
|
||||||
|
Assert.Equal(EnrollmentMode.WhiteGlove, profile.Mode);
|
||||||
|
Assert.Equal(ProfileConfidence.High, profile.Confidence);
|
||||||
|
Assert.Equal(PreProvisioningSide.Technician, profile.PreProvisioningSide);
|
||||||
|
Assert.Equal("classifier_whiteglove_sealing_confirmed", profile.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== AadUserJoinedLate
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AadUserJoinedLate_withUserTrue_recordsReason_butLeavesJoinModeUntouched()
|
||||||
|
{
|
||||||
|
var seed = EnrollmentScenarioProfile.Empty.With(joinMode: EnrollmentJoinMode.AzureAdJoin);
|
||||||
|
var signal = MakeSignal(DecisionSignalKind.AadUserJoinedLate, ordinal: 8, payload: null);
|
||||||
|
|
||||||
|
var profile = EnrollmentScenarioProfileUpdater.ApplyAadUserJoinedLate(seed, signal, withUser: true);
|
||||||
|
|
||||||
|
// Critical Codex correction: AadUserJoinedLate is NOT a JoinMode signal.
|
||||||
|
Assert.Equal(EnrollmentJoinMode.AzureAdJoin, profile.JoinMode);
|
||||||
|
Assert.Equal("late_aadj_observed:withUser=true", profile.Reason);
|
||||||
|
Assert.Equal(8, profile.EvidenceOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== helpers
|
||||||
|
|
||||||
|
private static DecisionSignal MakeSignal(
|
||||||
|
DecisionSignalKind kind,
|
||||||
|
long ordinal,
|
||||||
|
Dictionary<string, string>? payload)
|
||||||
|
{
|
||||||
|
return new DecisionSignal(
|
||||||
|
sessionSignalOrdinal: ordinal,
|
||||||
|
sessionTraceOrdinal: ordinal,
|
||||||
|
kind: kind,
|
||||||
|
kindSchemaVersion: 1,
|
||||||
|
occurredAtUtc: FixedUtc,
|
||||||
|
sourceOrigin: "test",
|
||||||
|
evidence: new Evidence(kind: EvidenceKind.Synthetic, identifier: "test", summary: "test"),
|
||||||
|
payload: payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,8 +27,8 @@ namespace AutopilotMonitor.DecisionCore.Classifiers
|
|||||||
|
|
||||||
return classifierId switch
|
return classifierId switch
|
||||||
{
|
{
|
||||||
WhiteGloveSealingClassifier.ClassifierId => state.WhiteGloveSealing.LastClassifierVerdictId,
|
WhiteGloveSealingClassifier.ClassifierId => state.ClassifierOutcomes.WhiteGloveSealing.LastClassifierVerdictId,
|
||||||
WhiteGlovePart2CompletionClassifier.ClassifierId => state.WhiteGlovePart2Completion.LastClassifierVerdictId,
|
WhiteGlovePart2CompletionClassifier.ClassifierId => state.ClassifierOutcomes.WhiteGlovePart2Completion.LastClassifierVerdictId,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
/// <summary>On <c>EspConfigDetected</c>: "true" / "false"; missing → fact is not set.</summary>
|
/// <summary>On <c>EspConfigDetected</c>: "true" / "false"; missing → fact is not set.</summary>
|
||||||
public const string SkipDeviceEsp = "skipDeviceEsp";
|
public const string SkipDeviceEsp = "skipDeviceEsp";
|
||||||
|
|
||||||
|
/// <summary>On <c>SessionStarted</c>: literal "v1" / "v2" from <c>EnrollmentRegistryDetector.DetectEnrollmentType()</c>.</summary>
|
||||||
|
public const string EnrollmentType = "enrollmentType";
|
||||||
|
|
||||||
|
/// <summary>On <c>SessionStarted</c>: "true" / "false" from <c>EnrollmentRegistryDetector.DetectHybridJoin()</c>.</summary>
|
||||||
|
public const string IsHybridJoin = "isHybridJoin";
|
||||||
|
|
||||||
// --- InformationalEvent payload (plan §1.3, single-rail refactor) ------------
|
// --- InformationalEvent payload (plan §1.3, single-rail refactor) ------------
|
||||||
// Mirrors the EnrollmentEvent fields the reducer must reconstruct for the
|
// Mirrors the EnrollmentEvent fields the reducer must reconstruct for the
|
||||||
// EmitEventTimelineEntry effect. EventType / Source are mandatory; the rest are
|
// EmitEventTimelineEntry effect. EventType / Source are mandatory; the rest are
|
||||||
|
|||||||
@@ -72,15 +72,13 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
builder.FinalizingEnteredUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
builder.FinalizingEnteredUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weak UserDriven-v1 hypothesis — seeing AccountSetup is the canonical tell.
|
// Classic UserDriven-v1 tell — seeing AccountSetup promotes the scenario profile.
|
||||||
if (enrollmentPhase == EnrollmentPhase.AccountSetup &&
|
// Codex follow-up #5: Mode promotion is delegated to EnrollmentScenarioProfileUpdater
|
||||||
state.EnrollmentType.Level == HypothesisLevel.Unknown)
|
// so the monotonic-confidence rule stays in one place.
|
||||||
|
if (enrollmentPhase == EnrollmentPhase.AccountSetup)
|
||||||
{
|
{
|
||||||
builder.EnrollmentType = state.EnrollmentType.With(
|
builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyAccountSetupObserved(
|
||||||
level: HypothesisLevel.Weak,
|
builder.ScenarioProfile, signal);
|
||||||
reason: "account_setup_observed",
|
|
||||||
score: 40,
|
|
||||||
lastUpdatedUtc: signal.OccurredAtUtc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Device-Only ESP detection (plan §2.7): arm on first DeviceSetup, cancel on AccountSetup.
|
// Device-Only ESP detection (plan §2.7): arm on first DeviceSetup, cancel on AccountSetup.
|
||||||
@@ -297,15 +295,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
builder.ImeMatchedPatternId = new SignalFact<string>(patternId!, signal.SessionSignalOrdinal);
|
builder.ImeMatchedPatternId = new SignalFact<string>(patternId!, signal.SessionSignalOrdinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strengthen UserDriven-v1 hypothesis on user-session-complete.
|
// Classic High-confidence promotion on user-session-complete. Codex follow-up #5:
|
||||||
if (state.EnrollmentType.Level < HypothesisLevel.Strong)
|
// delegate to the updater for monotonicity.
|
||||||
{
|
builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyImeUserSessionCompleted(
|
||||||
builder.EnrollmentType = state.EnrollmentType.With(
|
builder.ScenarioProfile, signal);
|
||||||
level: HypothesisLevel.Strong,
|
|
||||||
reason: "ime_user_session_completed",
|
|
||||||
score: 75,
|
|
||||||
lastUpdatedUtc: signal.OccurredAtUtc);
|
|
||||||
}
|
|
||||||
|
|
||||||
var newState = builder.Build();
|
var newState = builder.Build();
|
||||||
|
|
||||||
@@ -320,14 +313,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handle <see cref="DecisionSignalKind.AadUserJoinedLate"/> — hypothesis-only update.
|
/// Handle <see cref="DecisionSignalKind.AadUserJoinedLate"/> — observation-only update.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Per project memory <c>feedback_aad_joined_late_not_completion</c>: this signal is a
|
/// Per project memory <c>feedback_aad_joined_late_not_completion</c>: this signal is a
|
||||||
/// classifier-state update ONLY — never a completion trigger. Stage is unchanged, no
|
/// classifier-state update ONLY — never a completion trigger. Stage is unchanged, no
|
||||||
/// terminal event is emitted, but <see cref="DecisionState.AadJoinedWithUser"/> is set
|
/// terminal event is emitted. Codex follow-up #5: the user-presence flag lives in
|
||||||
/// and the <see cref="DecisionState.EnrollmentType"/> hypothesis records the late-AADJ
|
/// <see cref="EnrollmentScenarioObservations.AadUserJoinWithUserObserved"/> (NOT
|
||||||
/// reason so downstream classifiers (WhiteGlovePart2CompletionClassifier in M3.4) can
|
/// <see cref="EnrollmentScenarioProfile.JoinMode"/> — that is strictly the
|
||||||
/// factor it in.
|
/// <c>SessionStarted.payload.isHybridJoin</c> value), and the profile's
|
||||||
|
/// <see cref="EnrollmentScenarioProfile.Reason"/> is annotated without touching Mode.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private DecisionStep HandleAadUserJoinedLateV1(DecisionState state, DecisionSignal signal)
|
private DecisionStep HandleAadUserJoinedLateV1(DecisionState state, DecisionSignal signal)
|
||||||
@@ -340,12 +334,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
var builder = state.ToBuilder()
|
var builder = state.ToBuilder()
|
||||||
.WithStepIndex(nextStep)
|
.WithStepIndex(nextStep)
|
||||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||||
builder.AadJoinedWithUser = new SignalFact<bool>(withUser, signal.SessionSignalOrdinal);
|
|
||||||
|
|
||||||
// Annotate the EnrollmentType hypothesis reason without bumping level up.
|
builder.ScenarioObservations = builder.ScenarioObservations.WithAadUserJoinWithUserObserved(
|
||||||
builder.EnrollmentType = state.EnrollmentType.With(
|
value: withUser, sourceSignalOrdinal: signal.SessionSignalOrdinal);
|
||||||
reason: $"late_aadj_observed:withUser={withUser.ToString().ToLowerInvariant()}",
|
builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyAadUserJoinedLate(
|
||||||
lastUpdatedUtc: signal.OccurredAtUtc);
|
builder.ScenarioProfile, signal, withUser);
|
||||||
|
|
||||||
var newState = builder.Build();
|
var newState = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SelfDeploying / Device-Only terminal path.
|
// SelfDeploying / Device-Only terminal path.
|
||||||
|
// Codex follow-up #5: user-presence is now an observation-level check (late-AADJ
|
||||||
|
// user flag + signal-level Hello/Desktop facts) — NOT the legacy AadJoinedWithUser
|
||||||
|
// state field. Observations replace the legacy per-fact nullable bool.
|
||||||
var hasUserPresence =
|
var hasUserPresence =
|
||||||
(state.AadJoinedWithUser != null && state.AadJoinedWithUser.Value) ||
|
(state.ScenarioObservations.AadUserJoinWithUserObserved != null
|
||||||
|
&& state.ScenarioObservations.AadUserJoinWithUserObserved.Value) ||
|
||||||
state.HelloResolvedUtc != null ||
|
state.HelloResolvedUtc != null ||
|
||||||
state.DesktopArrivedUtc != null;
|
state.DesktopArrivedUtc != null;
|
||||||
|
|
||||||
@@ -73,7 +77,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
? DeviceOnlyReasons.UserPresent
|
? DeviceOnlyReasons.UserPresent
|
||||||
: DeviceOnlyReasons.DeviceOnly;
|
: DeviceOnlyReasons.DeviceOnly;
|
||||||
|
|
||||||
var updatedHypothesis = state.DeviceOnlyDeployment.With(
|
var updatedDeviceOnly = state.ClassifierOutcomes.DeviceOnlyDeployment.With(
|
||||||
level: HypothesisLevel.Confirmed,
|
level: HypothesisLevel.Confirmed,
|
||||||
reason: deviceOnlyReason,
|
reason: deviceOnlyReason,
|
||||||
score: 100,
|
score: 100,
|
||||||
@@ -85,18 +89,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
.WithStepIndex(nextStep)
|
.WithStepIndex(nextStep)
|
||||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||||
.ClearDeadlines();
|
.ClearDeadlines();
|
||||||
builder.DeviceOnlyDeployment = updatedHypothesis;
|
builder.ClassifierOutcomes = state.ClassifierOutcomes.WithDeviceOnlyDeployment(updatedDeviceOnly);
|
||||||
|
|
||||||
// Strengthen EnrollmentType: observing DeviceSetupProvisioningComplete without
|
// Strengthen Mode: observing DeviceSetupProvisioningComplete without preceding
|
||||||
// preceding AccountSetup strongly implies SelfDeploying-v1.
|
// AccountSetup strongly implies SelfDeploying-v1. Delegated to the updater.
|
||||||
if (state.EnrollmentType.Level < HypothesisLevel.Strong)
|
builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyDeviceSetupProvisioningComplete(
|
||||||
{
|
builder.ScenarioProfile, signal);
|
||||||
builder.EnrollmentType = state.EnrollmentType.With(
|
|
||||||
level: HypothesisLevel.Strong,
|
|
||||||
reason: "selfdeploying_provisioning_complete",
|
|
||||||
score: 80,
|
|
||||||
lastUpdatedUtc: signal.OccurredAtUtc);
|
|
||||||
}
|
|
||||||
|
|
||||||
var newState = builder.Build();
|
var newState = builder.Build();
|
||||||
|
|
||||||
@@ -127,11 +125,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||||
.CancelDeadline(DeadlineNames.DeviceOnlyEspDetection);
|
.CancelDeadline(DeadlineNames.DeviceOnlyEspDetection);
|
||||||
|
|
||||||
builder.DeviceOnlyDeployment = state.DeviceOnlyDeployment.With(
|
var strongDeviceOnly = state.ClassifierOutcomes.DeviceOnlyDeployment.With(
|
||||||
level: HypothesisLevel.Strong,
|
level: HypothesisLevel.Strong,
|
||||||
reason: DeviceOnlyReasons.DeviceOnly,
|
reason: DeviceOnlyReasons.DeviceOnly,
|
||||||
score: 70,
|
score: 70,
|
||||||
lastUpdatedUtc: signal.OccurredAtUtc);
|
lastUpdatedUtc: signal.OccurredAtUtc);
|
||||||
|
builder.ClassifierOutcomes = state.ClassifierOutcomes.WithDeviceOnlyDeployment(strongDeviceOnly);
|
||||||
|
|
||||||
var newState = builder.Build();
|
var newState = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -53,13 +53,19 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
// periodically from the very start of the session — Plan §4.x M4.4.4.
|
// periodically from the very start of the session — Plan §4.x M4.4.4.
|
||||||
var classifierTick = BuildClassifierTickDeadline(signal.OccurredAtUtc);
|
var classifierTick = BuildClassifierTickDeadline(signal.OccurredAtUtc);
|
||||||
|
|
||||||
// Stage stays SessionStarted; this transition is the "we saw the start" anchor.
|
// Codex follow-up #5 — seed the scenario profile from the registry-detector payload
|
||||||
var newState = state.ToBuilder()
|
// (enrollmentType + isHybridJoin). Stage stays SessionStarted; this transition is the
|
||||||
|
// "we saw the start" anchor.
|
||||||
|
var builder = state.ToBuilder()
|
||||||
.WithStage(SessionStage.SessionStarted)
|
.WithStage(SessionStage.SessionStarted)
|
||||||
.WithStepIndex(state.StepIndex + 1)
|
.WithStepIndex(state.StepIndex + 1)
|
||||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||||
.AddDeadline(classifierTick)
|
.AddDeadline(classifierTick);
|
||||||
.Build();
|
|
||||||
|
builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplySessionStarted(
|
||||||
|
builder.ScenarioProfile, signal);
|
||||||
|
|
||||||
|
var newState = builder.Build();
|
||||||
|
|
||||||
var transition = BuildTakenTransition(
|
var transition = BuildTakenTransition(
|
||||||
before: state,
|
before: state,
|
||||||
@@ -458,15 +464,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.AutopilotProfileRead));
|
RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.AutopilotProfileRead));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handle <see cref="DecisionSignalKind.EspConfigDetected"/>. Plan §6 Fix 9.
|
/// Handle <see cref="DecisionSignalKind.EspConfigDetected"/>. Plan §6 Fix 9 +
|
||||||
|
/// Codex follow-up #5.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Populates <see cref="DecisionState.SkipUserEsp"/> / <see cref="DecisionState.SkipDeviceEsp"/>
|
/// Populates <see cref="EnrollmentScenarioObservations.SkipUserEsp"/> /
|
||||||
/// from the payload (<see cref="SignalPayloadKeys.SkipUserEsp"/> /
|
/// <see cref="EnrollmentScenarioObservations.SkipDeviceEsp"/> observations and, when
|
||||||
/// <see cref="SignalPayloadKeys.SkipDeviceEsp"/>). Set-once semantics — later signals with
|
/// both halves are known, derives <see cref="EnrollmentScenarioProfile.EspConfig"/>.
|
||||||
/// identical payload are no-ops, and keys missing from the payload never clear a fact that
|
/// Set-once semantics — later signals with identical payload are no-ops, and keys
|
||||||
/// was already set (a re-read that failed to pick up one half must not invalidate the
|
/// missing from the payload never clear a fact that was already set (a re-read that
|
||||||
/// other). Stage is unchanged; this is a fact-only signal that Fix 8's reducer guards
|
/// failed to pick up one half must not invalidate the other). Stage is unchanged;
|
||||||
/// read.
|
/// this is a fact-only signal that Fix 8's reducer guards read via
|
||||||
|
/// <see cref="EnrollmentScenarioProfile.EspConfig"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private DecisionStep HandleEspConfigDetectedV1(DecisionState state, DecisionSignal signal)
|
private DecisionStep HandleEspConfigDetectedV1(DecisionState state, DecisionSignal signal)
|
||||||
@@ -476,22 +484,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
.WithStepIndex(nextStep)
|
.WithStepIndex(nextStep)
|
||||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||||
|
|
||||||
if (signal.Payload != null)
|
var (newProfile, newObservations) = EnrollmentScenarioProfileUpdater.ApplyEspConfigDetected(
|
||||||
{
|
builder.ScenarioProfile, builder.ScenarioObservations, signal);
|
||||||
if (state.SkipUserEsp == null
|
builder.ScenarioProfile = newProfile;
|
||||||
&& signal.Payload.TryGetValue(SignalPayloadKeys.SkipUserEsp, out var rawSkipUser)
|
builder.ScenarioObservations = newObservations;
|
||||||
&& bool.TryParse(rawSkipUser, out var skipUser))
|
|
||||||
{
|
|
||||||
builder.SkipUserEsp = new SignalFact<bool>(skipUser, signal.SessionSignalOrdinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.SkipDeviceEsp == null
|
|
||||||
&& signal.Payload.TryGetValue(SignalPayloadKeys.SkipDeviceEsp, out var rawSkipDevice)
|
|
||||||
&& bool.TryParse(rawSkipDevice, out var skipDevice))
|
|
||||||
{
|
|
||||||
builder.SkipDeviceEsp = new SignalFact<bool>(skipDevice, signal.SessionSignalOrdinal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var newState = builder.Build();
|
var newState = builder.Build();
|
||||||
var transition = BuildTakenTransition(
|
var transition = BuildTakenTransition(
|
||||||
@@ -595,19 +591,29 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
/// on Classic V1 paths. Returns <c>true</c> only when the promotion is legitimate:
|
/// on Classic V1 paths. Returns <c>true</c> only when the promotion is legitimate:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item>AccountSetup has already been entered (the post-Account-ESP final exit case), or</item>
|
/// <item>AccountSetup has already been entered (the post-Account-ESP final exit case), or</item>
|
||||||
/// <item>SkipUser is explicitly <c>true</c> (the Account-ESP phase is skipped; first
|
/// <item>The profile's <see cref="EnrollmentScenarioProfile.EspConfig"/> resolves to
|
||||||
/// esp_exiting IS the final exit on device-only / full-skip flows).</item>
|
/// a shape where Account-ESP never runs (<see cref="EspConfig.DeviceEspOnly"/>
|
||||||
|
/// = skipUser=true, or <see cref="EspConfig.NoEsp"/> = skipUser=true AND
|
||||||
|
/// skipDevice=true). In those shapes the first esp_exiting IS the final exit.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// Otherwise returns <c>false</c> — a FinalizingSetup / EspExiting signal arriving
|
/// Otherwise returns <c>false</c> — a FinalizingSetup / EspExiting signal arriving
|
||||||
/// before AccountSetup on a non-SkipUser enrollment is either a collector bug or the
|
/// before AccountSetup on a non-SkipUser enrollment is either a collector bug or the
|
||||||
/// Device-ESP intermediate exit that Fix 7 otherwise swallows at the tracker layer.
|
/// Device-ESP intermediate exit that Fix 7 otherwise swallows at the tracker layer.
|
||||||
/// Keeping this helper in the reducer means even a regression in Fix 7 cannot drive a
|
/// Keeping this helper in the reducer means even a regression in Fix 7 cannot drive a
|
||||||
/// premature <c>AwaitingHello</c> + HelloSafety arm.
|
/// premature <c>AwaitingHello</c> + HelloSafety arm.
|
||||||
|
/// <para>
|
||||||
|
/// Codex follow-up #5: the legacy <c>SkipUserEsp?.Value == true</c> check now reads
|
||||||
|
/// <see cref="DecisionState.ScenarioProfile"/> (derived enum) instead of the
|
||||||
|
/// half-fact. Semantically equivalent: DeviceEspOnly ≡ skipUser=true && skipDevice=false,
|
||||||
|
/// NoEsp ≡ skipUser=true && skipDevice=true. An <see cref="EspConfig.Unknown"/> profile
|
||||||
|
/// (EspConfigDetected not yet seen, or only one half parsed) blocks the promotion.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool ShouldTransitionToAwaitingHello(DecisionState state)
|
private static bool ShouldTransitionToAwaitingHello(DecisionState state)
|
||||||
{
|
{
|
||||||
if (state.AccountSetupEnteredUtc != null) return true;
|
if (state.AccountSetupEnteredUtc != null) return true;
|
||||||
if (state.SkipUserEsp?.Value == true) return true;
|
var esp = state.ScenarioProfile.EspConfig;
|
||||||
|
if (esp == EspConfig.DeviceEspOnly || esp == EspConfig.NoEsp) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
var builder = state.ToBuilder()
|
var builder = state.ToBuilder()
|
||||||
.WithStepIndex(nextStep)
|
.WithStepIndex(nextStep)
|
||||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||||
builder.ShellCoreWhiteGloveSuccessSeen = new SignalFact<bool>(true, signal.SessionSignalOrdinal);
|
builder.ScenarioObservations = builder.ScenarioObservations.WithShellCoreWhiteGloveSuccessSeen(signal.SessionSignalOrdinal);
|
||||||
|
|
||||||
var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal);
|
var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal);
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
var builder = state.ToBuilder()
|
var builder = state.ToBuilder()
|
||||||
.WithStepIndex(nextStep)
|
.WithStepIndex(nextStep)
|
||||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||||
builder.WhiteGloveSealingPatternSeen = new SignalFact<bool>(true, signal.SessionSignalOrdinal);
|
builder.ScenarioObservations = builder.ScenarioObservations.WithWhiteGloveSealingPatternSeen(signal.SessionSignalOrdinal);
|
||||||
|
|
||||||
var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal);
|
var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal);
|
||||||
|
|
||||||
@@ -105,15 +105,20 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
|
|
||||||
if (classifier == WhiteGloveSealingClassifier.ClassifierId)
|
if (classifier == WhiteGloveSealingClassifier.ClassifierId)
|
||||||
{
|
{
|
||||||
builder.WhiteGloveSealing = state.WhiteGloveSealing.With(
|
var updatedSealing = state.ClassifierOutcomes.WhiteGloveSealing.With(
|
||||||
level: level,
|
level: level,
|
||||||
reason: reason,
|
reason: reason,
|
||||||
score: score,
|
score: score,
|
||||||
lastUpdatedUtc: signal.OccurredAtUtc,
|
lastUpdatedUtc: signal.OccurredAtUtc,
|
||||||
lastClassifierVerdictId: inputHash);
|
lastClassifierVerdictId: inputHash);
|
||||||
|
builder.ClassifierOutcomes = state.ClassifierOutcomes.WithWhiteGloveSealing(updatedSealing);
|
||||||
|
|
||||||
if (level == HypothesisLevel.Confirmed)
|
if (level == HypothesisLevel.Confirmed)
|
||||||
{
|
{
|
||||||
|
// Mirror the WG decision in the profile: Mode=WhiteGlove @ High confidence.
|
||||||
|
builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyWhiteGloveSealingConfirmed(
|
||||||
|
builder.ScenarioProfile, signal);
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.WithStage(SessionStage.WhiteGloveSealed)
|
.WithStage(SessionStage.WhiteGloveSealed)
|
||||||
.WithOutcome(SessionOutcome.WhiteGlovePart1Sealed)
|
.WithOutcome(SessionOutcome.WhiteGlovePart1Sealed)
|
||||||
@@ -242,18 +247,26 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
/// Build a <see cref="WhiteGloveSealingSnapshot"/> from the current <see cref="DecisionState"/>.
|
/// Build a <see cref="WhiteGloveSealingSnapshot"/> from the current <see cref="DecisionState"/>.
|
||||||
/// Exposed to the effect runner so the verdict snapshot carried with the
|
/// Exposed to the effect runner so the verdict snapshot carried with the
|
||||||
/// <see cref="DecisionEffectKind.RunClassifier"/> effect is deterministic from state.
|
/// <see cref="DecisionEffectKind.RunClassifier"/> effect is deterministic from state.
|
||||||
|
/// <para>
|
||||||
|
/// Codex follow-up #5 — the snapshot fields are unchanged, only their sources moved:
|
||||||
|
/// the three Boolean signal-observation inputs come from
|
||||||
|
/// <see cref="DecisionState.ScenarioObservations"/>, the device-only input from
|
||||||
|
/// <see cref="DecisionState.ClassifierOutcomes"/>. The input-hash canonicalization in
|
||||||
|
/// <see cref="WhiteGloveSealingSnapshot.ComputeInputHash"/> is therefore byte-stable
|
||||||
|
/// across the refactor, which preserves the anti-loop semantics.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static WhiteGloveSealingSnapshot BuildWhiteGloveSealingSnapshot(DecisionState state) =>
|
internal static WhiteGloveSealingSnapshot BuildWhiteGloveSealingSnapshot(DecisionState state) =>
|
||||||
new WhiteGloveSealingSnapshot(
|
new WhiteGloveSealingSnapshot(
|
||||||
shellCoreWhiteGloveSuccessSeen: state.ShellCoreWhiteGloveSuccessSeen?.Value == true,
|
shellCoreWhiteGloveSuccessSeen: state.ScenarioObservations.ShellCoreWhiteGloveSuccessSeen?.Value == true,
|
||||||
whiteGloveSealingPatternSeen: state.WhiteGloveSealingPatternSeen?.Value == true,
|
whiteGloveSealingPatternSeen: state.ScenarioObservations.WhiteGloveSealingPatternSeen?.Value == true,
|
||||||
aadJoinedWithUser: state.AadJoinedWithUser?.Value == true,
|
aadJoinedWithUser: state.ScenarioObservations.AadUserJoinWithUserObserved?.Value == true,
|
||||||
desktopArrived: state.DesktopArrivedUtc != null,
|
desktopArrived: state.DesktopArrivedUtc != null,
|
||||||
helloResolved: state.HelloResolvedUtc != null,
|
helloResolved: state.HelloResolvedUtc != null,
|
||||||
hasAccountSetupActivity: state.AccountSetupEnteredUtc != null,
|
hasAccountSetupActivity: state.AccountSetupEnteredUtc != null,
|
||||||
isDeviceOnlyDeploymentHypothesis:
|
isDeviceOnlyDeploymentHypothesis:
|
||||||
state.DeviceOnlyDeployment.Level >= HypothesisLevel.Strong &&
|
state.ClassifierOutcomes.DeviceOnlyDeployment.Level >= HypothesisLevel.Strong &&
|
||||||
state.DeviceOnlyDeployment.Reason == DeviceOnlyReasons.DeviceOnly,
|
state.ClassifierOutcomes.DeviceOnlyDeployment.Reason == DeviceOnlyReasons.DeviceOnly,
|
||||||
systemRebootUtc: state.SystemRebootUtc?.Value,
|
systemRebootUtc: state.SystemRebootUtc?.Value,
|
||||||
currentEnrollmentPhase: state.CurrentEnrollmentPhase?.Value);
|
currentEnrollmentPhase: state.CurrentEnrollmentPhase?.Value);
|
||||||
|
|
||||||
|
|||||||
@@ -116,11 +116,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||||
.ClearDeadlines();
|
.ClearDeadlines();
|
||||||
|
|
||||||
builder.WhiteGlovePart2Completion = state.WhiteGlovePart2Completion.With(
|
var rejectedPart2 = state.ClassifierOutcomes.WhiteGlovePart2Completion.With(
|
||||||
level: HypothesisLevel.Rejected,
|
level: HypothesisLevel.Rejected,
|
||||||
reason: "part2_user_absent",
|
reason: "part2_user_absent",
|
||||||
score: 0,
|
score: 0,
|
||||||
lastUpdatedUtc: signal.OccurredAtUtc);
|
lastUpdatedUtc: signal.OccurredAtUtc);
|
||||||
|
builder.ClassifierOutcomes = state.ClassifierOutcomes.WithWhiteGlovePart2Completion(rejectedPart2);
|
||||||
|
|
||||||
var newState = builder.Build();
|
var newState = builder.Build();
|
||||||
|
|
||||||
@@ -219,12 +220,13 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
.WithStepIndex(nextStep)
|
.WithStepIndex(nextStep)
|
||||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||||
|
|
||||||
builder.WhiteGlovePart2Completion = state.WhiteGlovePart2Completion.With(
|
var updatedPart2 = state.ClassifierOutcomes.WhiteGlovePart2Completion.With(
|
||||||
level: level,
|
level: level,
|
||||||
reason: reason,
|
reason: reason,
|
||||||
score: score,
|
score: score,
|
||||||
lastUpdatedUtc: signal.OccurredAtUtc,
|
lastUpdatedUtc: signal.OccurredAtUtc,
|
||||||
lastClassifierVerdictId: inputHash);
|
lastClassifierVerdictId: inputHash);
|
||||||
|
builder.ClassifierOutcomes = state.ClassifierOutcomes.WithWhiteGlovePart2Completion(updatedPart2);
|
||||||
|
|
||||||
if (level == HypothesisLevel.Confirmed)
|
if (level == HypothesisLevel.Confirmed)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.DecisionCore.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Container for the reducer-side classifier verdict + anti-loop state. Codex follow-up #5 —
|
||||||
|
/// replaces the legacy <c>WhiteGloveSealing</c> / <c>WhiteGlovePart2Completion</c> /
|
||||||
|
/// <c>DeviceOnlyDeployment</c> <see cref="Hypothesis"/> fields on <c>DecisionState</c> with a
|
||||||
|
/// single aggregate. Scoring logic, weights and thresholds live in the classifier
|
||||||
|
/// implementations (<c>WhiteGloveSealingClassifier</c>, <c>WhiteGlovePart2CompletionClassifier</c>)
|
||||||
|
/// and are not touched by this refactor — only the verdict storage moved.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Invariants</b>:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>All three <see cref="Hypothesis"/> values are non-null; <see cref="Empty"/> uses <see cref="Hypothesis.UnknownInstance"/>.</item>
|
||||||
|
/// <item>Immutable; the <c>With…</c> methods return new instances.</item>
|
||||||
|
/// <item><see cref="DeviceOnlyDeployment"/> has no dedicated classifier — it's updated by the reducer (<c>DeviceSetupProvisioningComplete</c> handler + <c>DeviceOnlyEspDetection</c> deadline) but is modeled here for symmetry, because the WhiteGlove classifier reads it as an input.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClassifierOutcomes
|
||||||
|
{
|
||||||
|
public static readonly ClassifierOutcomes Empty = new ClassifierOutcomes(
|
||||||
|
whiteGloveSealing: Hypothesis.UnknownInstance,
|
||||||
|
whiteGlovePart2Completion: Hypothesis.UnknownInstance,
|
||||||
|
deviceOnlyDeployment: Hypothesis.UnknownInstance);
|
||||||
|
|
||||||
|
public ClassifierOutcomes(
|
||||||
|
Hypothesis whiteGloveSealing,
|
||||||
|
Hypothesis whiteGlovePart2Completion,
|
||||||
|
Hypothesis deviceOnlyDeployment)
|
||||||
|
{
|
||||||
|
WhiteGloveSealing = whiteGloveSealing ?? throw new ArgumentNullException(nameof(whiteGloveSealing));
|
||||||
|
WhiteGlovePart2Completion = whiteGlovePart2Completion ?? throw new ArgumentNullException(nameof(whiteGlovePart2Completion));
|
||||||
|
DeviceOnlyDeployment = deviceOnlyDeployment ?? throw new ArgumentNullException(nameof(deviceOnlyDeployment));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verdict from <c>WhiteGloveSealingClassifier</c>. Plan §2.4.</summary>
|
||||||
|
public Hypothesis WhiteGloveSealing { get; }
|
||||||
|
|
||||||
|
/// <summary>Verdict from <c>WhiteGlovePart2CompletionClassifier</c>. Plan §2.4 / §M3.4.</summary>
|
||||||
|
public Hypothesis WhiteGlovePart2Completion { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reducer-maintained (no dedicated classifier) device-only hypothesis. Inputs into the
|
||||||
|
/// WhiteGlove sealing snapshot as <c>IsDeviceOnlyDeploymentHypothesis</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Hypothesis DeviceOnlyDeployment { get; }
|
||||||
|
|
||||||
|
public ClassifierOutcomes WithWhiteGloveSealing(Hypothesis value) =>
|
||||||
|
new ClassifierOutcomes(
|
||||||
|
value ?? throw new ArgumentNullException(nameof(value)),
|
||||||
|
WhiteGlovePart2Completion,
|
||||||
|
DeviceOnlyDeployment);
|
||||||
|
|
||||||
|
public ClassifierOutcomes WithWhiteGlovePart2Completion(Hypothesis value) =>
|
||||||
|
new ClassifierOutcomes(
|
||||||
|
WhiteGloveSealing,
|
||||||
|
value ?? throw new ArgumentNullException(nameof(value)),
|
||||||
|
DeviceOnlyDeployment);
|
||||||
|
|
||||||
|
public ClassifierOutcomes WithDeviceOnlyDeployment(Hypothesis value) =>
|
||||||
|
new ClassifierOutcomes(
|
||||||
|
WhiteGloveSealing,
|
||||||
|
WhiteGlovePart2Completion,
|
||||||
|
value ?? throw new ArgumentNullException(nameof(value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,18 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
/// Reducer contract: <c>(newState, transition, effects) = engine.Reduce(oldState, signal)</c>.
|
/// Reducer contract: <c>(newState, transition, effects) = engine.Reduce(oldState, signal)</c>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// Codex follow-up #5 — the legacy 9-field hypothesis/fact mosaic
|
||||||
|
/// (<c>EnrollmentType</c>, <c>WhiteGloveSealing</c>, <c>WhiteGlovePart2Completion</c>,
|
||||||
|
/// <c>DeviceOnlyDeployment</c>, <c>SkipUserEsp</c>, <c>SkipDeviceEsp</c>,
|
||||||
|
/// <c>AadJoinedWithUser</c>, <c>ShellCoreWhiteGloveSuccessSeen</c>,
|
||||||
|
/// <c>WhiteGloveSealingPatternSeen</c>) has been replaced by three structured aggregates:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="ScenarioProfile"/> — the derived semantic classification.</item>
|
||||||
|
/// <item><see cref="ScenarioObservations"/> — raw per-signal observations feeding the classifier.</item>
|
||||||
|
/// <item><see cref="ClassifierOutcomes"/> — classifier verdicts + anti-loop state.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
/// Agent-process lifecycle flags (crash, admin actions, boot-time, heartbeat) live
|
/// Agent-process lifecycle flags (crash, admin actions, boot-time, heartbeat) live
|
||||||
/// in <c>agent-lifecycle.json</c>, not here (L.11 separation).
|
/// in <c>agent-lifecycle.json</c>, not here (L.11 separation).
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -25,10 +37,6 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
string tenantId,
|
string tenantId,
|
||||||
SessionStage stage,
|
SessionStage stage,
|
||||||
SessionOutcome? outcome,
|
SessionOutcome? outcome,
|
||||||
Hypothesis enrollmentType,
|
|
||||||
Hypothesis whiteGloveSealing,
|
|
||||||
Hypothesis whiteGlovePart2Completion,
|
|
||||||
Hypothesis deviceOnlyDeployment,
|
|
||||||
SignalFact<EnrollmentPhase>? currentEnrollmentPhase,
|
SignalFact<EnrollmentPhase>? currentEnrollmentPhase,
|
||||||
SignalFact<DateTime>? deviceSetupEnteredUtc,
|
SignalFact<DateTime>? deviceSetupEnteredUtc,
|
||||||
SignalFact<DateTime>? accountSetupEnteredUtc,
|
SignalFact<DateTime>? accountSetupEnteredUtc,
|
||||||
@@ -38,20 +46,18 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
SignalFact<DateTime>? helloResolvedUtc,
|
SignalFact<DateTime>? helloResolvedUtc,
|
||||||
SignalFact<DateTime>? systemRebootUtc,
|
SignalFact<DateTime>? systemRebootUtc,
|
||||||
SignalFact<string>? helloOutcome,
|
SignalFact<string>? helloOutcome,
|
||||||
SignalFact<bool>? aadJoinedWithUser,
|
|
||||||
SignalFact<string>? imeMatchedPatternId,
|
SignalFact<string>? imeMatchedPatternId,
|
||||||
SignalFact<bool>? shellCoreWhiteGloveSuccessSeen,
|
|
||||||
SignalFact<bool>? whiteGloveSealingPatternSeen,
|
|
||||||
SignalFact<DateTime>? userAadSignInCompleteUtc,
|
SignalFact<DateTime>? userAadSignInCompleteUtc,
|
||||||
SignalFact<DateTime>? helloResolvedPart2Utc,
|
SignalFact<DateTime>? helloResolvedPart2Utc,
|
||||||
SignalFact<DateTime>? desktopArrivedPart2Utc,
|
SignalFact<DateTime>? desktopArrivedPart2Utc,
|
||||||
SignalFact<DateTime>? accountSetupCompletedPart2Utc,
|
SignalFact<DateTime>? accountSetupCompletedPart2Utc,
|
||||||
SignalFact<bool>? skipUserEsp,
|
|
||||||
SignalFact<bool>? skipDeviceEsp,
|
|
||||||
IReadOnlyList<ActiveDeadline> deadlines,
|
IReadOnlyList<ActiveDeadline> deadlines,
|
||||||
long lastAppliedSignalOrdinal,
|
long lastAppliedSignalOrdinal,
|
||||||
int stepIndex,
|
int stepIndex,
|
||||||
AppInstallFacts? appInstallFacts = null,
|
AppInstallFacts? appInstallFacts = null,
|
||||||
|
EnrollmentScenarioProfile? scenarioProfile = null,
|
||||||
|
EnrollmentScenarioObservations? scenarioObservations = null,
|
||||||
|
ClassifierOutcomes? classifierOutcomes = null,
|
||||||
string? schemaVersion = null)
|
string? schemaVersion = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(sessionId))
|
if (string.IsNullOrEmpty(sessionId))
|
||||||
@@ -68,10 +74,6 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
TenantId = tenantId;
|
TenantId = tenantId;
|
||||||
Stage = stage;
|
Stage = stage;
|
||||||
Outcome = outcome;
|
Outcome = outcome;
|
||||||
EnrollmentType = enrollmentType ?? throw new ArgumentNullException(nameof(enrollmentType));
|
|
||||||
WhiteGloveSealing = whiteGloveSealing ?? throw new ArgumentNullException(nameof(whiteGloveSealing));
|
|
||||||
WhiteGlovePart2Completion = whiteGlovePart2Completion ?? throw new ArgumentNullException(nameof(whiteGlovePart2Completion));
|
|
||||||
DeviceOnlyDeployment = deviceOnlyDeployment ?? throw new ArgumentNullException(nameof(deviceOnlyDeployment));
|
|
||||||
CurrentEnrollmentPhase = currentEnrollmentPhase;
|
CurrentEnrollmentPhase = currentEnrollmentPhase;
|
||||||
DeviceSetupEnteredUtc = deviceSetupEnteredUtc;
|
DeviceSetupEnteredUtc = deviceSetupEnteredUtc;
|
||||||
AccountSetupEnteredUtc = accountSetupEnteredUtc;
|
AccountSetupEnteredUtc = accountSetupEnteredUtc;
|
||||||
@@ -81,20 +83,18 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
HelloResolvedUtc = helloResolvedUtc;
|
HelloResolvedUtc = helloResolvedUtc;
|
||||||
SystemRebootUtc = systemRebootUtc;
|
SystemRebootUtc = systemRebootUtc;
|
||||||
HelloOutcome = helloOutcome;
|
HelloOutcome = helloOutcome;
|
||||||
AadJoinedWithUser = aadJoinedWithUser;
|
|
||||||
ImeMatchedPatternId = imeMatchedPatternId;
|
ImeMatchedPatternId = imeMatchedPatternId;
|
||||||
ShellCoreWhiteGloveSuccessSeen = shellCoreWhiteGloveSuccessSeen;
|
|
||||||
WhiteGloveSealingPatternSeen = whiteGloveSealingPatternSeen;
|
|
||||||
UserAadSignInCompleteUtc = userAadSignInCompleteUtc;
|
UserAadSignInCompleteUtc = userAadSignInCompleteUtc;
|
||||||
HelloResolvedPart2Utc = helloResolvedPart2Utc;
|
HelloResolvedPart2Utc = helloResolvedPart2Utc;
|
||||||
DesktopArrivedPart2Utc = desktopArrivedPart2Utc;
|
DesktopArrivedPart2Utc = desktopArrivedPart2Utc;
|
||||||
AccountSetupCompletedPart2Utc = accountSetupCompletedPart2Utc;
|
AccountSetupCompletedPart2Utc = accountSetupCompletedPart2Utc;
|
||||||
SkipUserEsp = skipUserEsp;
|
|
||||||
SkipDeviceEsp = skipDeviceEsp;
|
|
||||||
Deadlines = deadlines ?? throw new ArgumentNullException(nameof(deadlines));
|
Deadlines = deadlines ?? throw new ArgumentNullException(nameof(deadlines));
|
||||||
LastAppliedSignalOrdinal = lastAppliedSignalOrdinal;
|
LastAppliedSignalOrdinal = lastAppliedSignalOrdinal;
|
||||||
StepIndex = stepIndex;
|
StepIndex = stepIndex;
|
||||||
AppInstallFacts = appInstallFacts ?? AppInstallFacts.Empty;
|
AppInstallFacts = appInstallFacts ?? AppInstallFacts.Empty;
|
||||||
|
ScenarioProfile = scenarioProfile ?? EnrollmentScenarioProfile.Empty;
|
||||||
|
ScenarioObservations = scenarioObservations ?? EnrollmentScenarioObservations.Empty;
|
||||||
|
ClassifierOutcomes = classifierOutcomes ?? ClassifierOutcomes.Empty;
|
||||||
SchemaVersion = schemaVersion ?? CurrentSchemaVersion;
|
SchemaVersion = schemaVersion ?? CurrentSchemaVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,15 +108,6 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
/// <summary>Null when the session is non-terminal.</summary>
|
/// <summary>Null when the session is non-terminal.</summary>
|
||||||
public SessionOutcome? Outcome { get; }
|
public SessionOutcome? Outcome { get; }
|
||||||
|
|
||||||
// --- Hypothesen ---
|
|
||||||
public Hypothesis EnrollmentType { get; }
|
|
||||||
|
|
||||||
public Hypothesis WhiteGloveSealing { get; }
|
|
||||||
|
|
||||||
public Hypothesis WhiteGlovePart2Completion { get; }
|
|
||||||
|
|
||||||
public Hypothesis DeviceOnlyDeployment { get; }
|
|
||||||
|
|
||||||
// --- Enrollment-Phase (end-user reality, separate from Stage) ---
|
// --- Enrollment-Phase (end-user reality, separate from Stage) ---
|
||||||
public SignalFact<EnrollmentPhase>? CurrentEnrollmentPhase { get; }
|
public SignalFact<EnrollmentPhase>? CurrentEnrollmentPhase { get; }
|
||||||
|
|
||||||
@@ -137,29 +128,14 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
|
|
||||||
public SignalFact<string>? HelloOutcome { get; }
|
public SignalFact<string>? HelloOutcome { get; }
|
||||||
|
|
||||||
public SignalFact<bool>? AadJoinedWithUser { get; }
|
|
||||||
|
|
||||||
public SignalFact<string>? ImeMatchedPatternId { get; }
|
public SignalFact<string>? ImeMatchedPatternId { get; }
|
||||||
|
|
||||||
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveShellCoreSuccess"/> has fired. WG Part-1 indicator.</summary>
|
|
||||||
public SignalFact<bool>? ShellCoreWhiteGloveSuccessSeen { get; }
|
|
||||||
|
|
||||||
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveSealingPatternDetected"/> has fired. Signal-correlated WG path.</summary>
|
|
||||||
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; }
|
|
||||||
|
|
||||||
// --- WhiteGlove Part 2 (post-reboot user sign-in) facts ---
|
// --- WhiteGlove Part 2 (post-reboot user sign-in) facts ---
|
||||||
public SignalFact<DateTime>? UserAadSignInCompleteUtc { get; }
|
public SignalFact<DateTime>? UserAadSignInCompleteUtc { get; }
|
||||||
public SignalFact<DateTime>? HelloResolvedPart2Utc { get; }
|
public SignalFact<DateTime>? HelloResolvedPart2Utc { get; }
|
||||||
public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; }
|
public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; }
|
||||||
public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; }
|
public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; }
|
||||||
|
|
||||||
// --- ESP skip-flag facts from EspConfigDetected (plan §6 Fix 9). SkipUser=true means the
|
|
||||||
// Account-ESP phase never runs on this enrollment, so AwaitingHello is reachable directly
|
|
||||||
// after Device-ESP. When null the configuration has not been observed yet — guards that
|
|
||||||
// rely on an explicit bool must treat null as "unknown" rather than "false".
|
|
||||||
public SignalFact<bool>? SkipUserEsp { get; }
|
|
||||||
public SignalFact<bool>? SkipDeviceEsp { get; }
|
|
||||||
|
|
||||||
public IReadOnlyList<ActiveDeadline> Deadlines { get; }
|
public IReadOnlyList<ActiveDeadline> Deadlines { get; }
|
||||||
|
|
||||||
public long LastAppliedSignalOrdinal { get; }
|
public long LastAppliedSignalOrdinal { get; }
|
||||||
@@ -173,6 +149,27 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public AppInstallFacts AppInstallFacts { get; }
|
public AppInstallFacts AppInstallFacts { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed enrollment-scenario classification (Mode / JoinMode / EspConfig /
|
||||||
|
/// PreProvisioningSide / Confidence). Codex follow-up #5. Never null; defaults to
|
||||||
|
/// <see cref="EnrollmentScenarioProfile.Empty"/>.
|
||||||
|
/// </summary>
|
||||||
|
public EnrollmentScenarioProfile ScenarioProfile { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raw per-signal observations feeding the WhiteGlove sealing classifier and downstream
|
||||||
|
/// guards. Codex follow-up #5. Never null; defaults to
|
||||||
|
/// <see cref="EnrollmentScenarioObservations.Empty"/>.
|
||||||
|
/// </summary>
|
||||||
|
public EnrollmentScenarioObservations ScenarioObservations { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifier verdict storage + anti-loop state (WhiteGlove sealing, WhiteGlove Part 2,
|
||||||
|
/// device-only deployment). Codex follow-up #5. Never null; defaults to
|
||||||
|
/// <see cref="ClassifierOutcomes.Empty"/>.
|
||||||
|
/// </summary>
|
||||||
|
public ClassifierOutcomes ClassifierOutcomes { get; }
|
||||||
|
|
||||||
public string SchemaVersion { get; }
|
public string SchemaVersion { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -192,10 +189,6 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
tenantId: tenantId,
|
tenantId: tenantId,
|
||||||
stage: SessionStage.SessionStarted,
|
stage: SessionStage.SessionStarted,
|
||||||
outcome: null,
|
outcome: null,
|
||||||
enrollmentType: Hypothesis.UnknownInstance,
|
|
||||||
whiteGloveSealing: Hypothesis.UnknownInstance,
|
|
||||||
whiteGlovePart2Completion: Hypothesis.UnknownInstance,
|
|
||||||
deviceOnlyDeployment: Hypothesis.UnknownInstance,
|
|
||||||
currentEnrollmentPhase: null,
|
currentEnrollmentPhase: null,
|
||||||
deviceSetupEnteredUtc: null,
|
deviceSetupEnteredUtc: null,
|
||||||
accountSetupEnteredUtc: null,
|
accountSetupEnteredUtc: null,
|
||||||
@@ -205,19 +198,17 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
helloResolvedUtc: null,
|
helloResolvedUtc: null,
|
||||||
systemRebootUtc: null,
|
systemRebootUtc: null,
|
||||||
helloOutcome: null,
|
helloOutcome: null,
|
||||||
aadJoinedWithUser: null,
|
|
||||||
imeMatchedPatternId: null,
|
imeMatchedPatternId: null,
|
||||||
shellCoreWhiteGloveSuccessSeen: null,
|
|
||||||
whiteGloveSealingPatternSeen: null,
|
|
||||||
userAadSignInCompleteUtc: null,
|
userAadSignInCompleteUtc: null,
|
||||||
helloResolvedPart2Utc: null,
|
helloResolvedPart2Utc: null,
|
||||||
desktopArrivedPart2Utc: null,
|
desktopArrivedPart2Utc: null,
|
||||||
accountSetupCompletedPart2Utc: null,
|
accountSetupCompletedPart2Utc: null,
|
||||||
skipUserEsp: null,
|
|
||||||
skipDeviceEsp: null,
|
|
||||||
deadlines: Array.Empty<ActiveDeadline>(),
|
deadlines: Array.Empty<ActiveDeadline>(),
|
||||||
lastAppliedSignalOrdinal: -1,
|
lastAppliedSignalOrdinal: -1,
|
||||||
stepIndex: 0,
|
stepIndex: 0,
|
||||||
appInstallFacts: AppInstallFacts.Empty);
|
appInstallFacts: AppInstallFacts.Empty,
|
||||||
|
scenarioProfile: EnrollmentScenarioProfile.Empty,
|
||||||
|
scenarioObservations: EnrollmentScenarioObservations.Empty,
|
||||||
|
classifierOutcomes: ClassifierOutcomes.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
TenantId = source.TenantId;
|
TenantId = source.TenantId;
|
||||||
Stage = source.Stage;
|
Stage = source.Stage;
|
||||||
Outcome = source.Outcome;
|
Outcome = source.Outcome;
|
||||||
EnrollmentType = source.EnrollmentType;
|
|
||||||
WhiteGloveSealing = source.WhiteGloveSealing;
|
|
||||||
WhiteGlovePart2Completion = source.WhiteGlovePart2Completion;
|
|
||||||
DeviceOnlyDeployment = source.DeviceOnlyDeployment;
|
|
||||||
CurrentEnrollmentPhase = source.CurrentEnrollmentPhase;
|
CurrentEnrollmentPhase = source.CurrentEnrollmentPhase;
|
||||||
DeviceSetupEnteredUtc = source.DeviceSetupEnteredUtc;
|
DeviceSetupEnteredUtc = source.DeviceSetupEnteredUtc;
|
||||||
AccountSetupEnteredUtc = source.AccountSetupEnteredUtc;
|
AccountSetupEnteredUtc = source.AccountSetupEnteredUtc;
|
||||||
@@ -36,20 +32,18 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
HelloResolvedUtc = source.HelloResolvedUtc;
|
HelloResolvedUtc = source.HelloResolvedUtc;
|
||||||
SystemRebootUtc = source.SystemRebootUtc;
|
SystemRebootUtc = source.SystemRebootUtc;
|
||||||
HelloOutcome = source.HelloOutcome;
|
HelloOutcome = source.HelloOutcome;
|
||||||
AadJoinedWithUser = source.AadJoinedWithUser;
|
|
||||||
ImeMatchedPatternId = source.ImeMatchedPatternId;
|
ImeMatchedPatternId = source.ImeMatchedPatternId;
|
||||||
ShellCoreWhiteGloveSuccessSeen = source.ShellCoreWhiteGloveSuccessSeen;
|
|
||||||
WhiteGloveSealingPatternSeen = source.WhiteGloveSealingPatternSeen;
|
|
||||||
UserAadSignInCompleteUtc = source.UserAadSignInCompleteUtc;
|
UserAadSignInCompleteUtc = source.UserAadSignInCompleteUtc;
|
||||||
HelloResolvedPart2Utc = source.HelloResolvedPart2Utc;
|
HelloResolvedPart2Utc = source.HelloResolvedPart2Utc;
|
||||||
DesktopArrivedPart2Utc = source.DesktopArrivedPart2Utc;
|
DesktopArrivedPart2Utc = source.DesktopArrivedPart2Utc;
|
||||||
AccountSetupCompletedPart2Utc = source.AccountSetupCompletedPart2Utc;
|
AccountSetupCompletedPart2Utc = source.AccountSetupCompletedPart2Utc;
|
||||||
SkipUserEsp = source.SkipUserEsp;
|
|
||||||
SkipDeviceEsp = source.SkipDeviceEsp;
|
|
||||||
Deadlines = new List<ActiveDeadline>(source.Deadlines);
|
Deadlines = new List<ActiveDeadline>(source.Deadlines);
|
||||||
LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
|
LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
|
||||||
StepIndex = source.StepIndex;
|
StepIndex = source.StepIndex;
|
||||||
AppInstallFacts = source.AppInstallFacts;
|
AppInstallFacts = source.AppInstallFacts;
|
||||||
|
ScenarioProfile = source.ScenarioProfile;
|
||||||
|
ScenarioObservations = source.ScenarioObservations;
|
||||||
|
ClassifierOutcomes = source.ClassifierOutcomes;
|
||||||
SchemaVersion = source.SchemaVersion;
|
SchemaVersion = source.SchemaVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,10 +51,6 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
public string TenantId { get; set; }
|
public string TenantId { get; set; }
|
||||||
public SessionStage Stage { get; set; }
|
public SessionStage Stage { get; set; }
|
||||||
public SessionOutcome? Outcome { 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<EnrollmentPhase>? CurrentEnrollmentPhase { get; set; }
|
||||||
public SignalFact<DateTime>? DeviceSetupEnteredUtc { get; set; }
|
public SignalFact<DateTime>? DeviceSetupEnteredUtc { get; set; }
|
||||||
public SignalFact<DateTime>? AccountSetupEnteredUtc { get; set; }
|
public SignalFact<DateTime>? AccountSetupEnteredUtc { get; set; }
|
||||||
@@ -70,20 +60,18 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
public SignalFact<DateTime>? HelloResolvedUtc { get; set; }
|
public SignalFact<DateTime>? HelloResolvedUtc { get; set; }
|
||||||
public SignalFact<DateTime>? SystemRebootUtc { get; set; }
|
public SignalFact<DateTime>? SystemRebootUtc { get; set; }
|
||||||
public SignalFact<string>? HelloOutcome { get; set; }
|
public SignalFact<string>? HelloOutcome { get; set; }
|
||||||
public SignalFact<bool>? AadJoinedWithUser { get; set; }
|
|
||||||
public SignalFact<string>? ImeMatchedPatternId { get; set; }
|
public SignalFact<string>? ImeMatchedPatternId { get; set; }
|
||||||
public SignalFact<bool>? ShellCoreWhiteGloveSuccessSeen { get; set; }
|
|
||||||
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; set; }
|
|
||||||
public SignalFact<DateTime>? UserAadSignInCompleteUtc { get; set; }
|
public SignalFact<DateTime>? UserAadSignInCompleteUtc { get; set; }
|
||||||
public SignalFact<DateTime>? HelloResolvedPart2Utc { get; set; }
|
public SignalFact<DateTime>? HelloResolvedPart2Utc { get; set; }
|
||||||
public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; set; }
|
public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; set; }
|
||||||
public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; set; }
|
public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; set; }
|
||||||
public SignalFact<bool>? SkipUserEsp { get; set; }
|
|
||||||
public SignalFact<bool>? SkipDeviceEsp { get; set; }
|
|
||||||
public List<ActiveDeadline> Deadlines { get; set; }
|
public List<ActiveDeadline> Deadlines { get; set; }
|
||||||
public long LastAppliedSignalOrdinal { get; set; }
|
public long LastAppliedSignalOrdinal { get; set; }
|
||||||
public int StepIndex { get; set; }
|
public int StepIndex { get; set; }
|
||||||
public AppInstallFacts AppInstallFacts { get; set; } = AppInstallFacts.Empty;
|
public AppInstallFacts AppInstallFacts { get; set; } = AppInstallFacts.Empty;
|
||||||
|
public EnrollmentScenarioProfile ScenarioProfile { get; set; } = EnrollmentScenarioProfile.Empty;
|
||||||
|
public EnrollmentScenarioObservations ScenarioObservations { get; set; } = EnrollmentScenarioObservations.Empty;
|
||||||
|
public ClassifierOutcomes ClassifierOutcomes { get; set; } = ClassifierOutcomes.Empty;
|
||||||
public string SchemaVersion { get; set; }
|
public string SchemaVersion { get; set; }
|
||||||
|
|
||||||
// ---------- fluent helpers for the most common reducer operations ----------
|
// ---------- fluent helpers for the most common reducer operations ----------
|
||||||
@@ -133,16 +121,30 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DecisionStateBuilder WithScenarioProfile(EnrollmentScenarioProfile profile)
|
||||||
|
{
|
||||||
|
ScenarioProfile = profile ?? throw new ArgumentNullException(nameof(profile));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DecisionStateBuilder WithScenarioObservations(EnrollmentScenarioObservations observations)
|
||||||
|
{
|
||||||
|
ScenarioObservations = observations ?? throw new ArgumentNullException(nameof(observations));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DecisionStateBuilder WithClassifierOutcomes(ClassifierOutcomes outcomes)
|
||||||
|
{
|
||||||
|
ClassifierOutcomes = outcomes ?? throw new ArgumentNullException(nameof(outcomes));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public DecisionState Build() =>
|
public DecisionState Build() =>
|
||||||
new DecisionState(
|
new DecisionState(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
tenantId: TenantId,
|
tenantId: TenantId,
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
outcome: Outcome,
|
outcome: Outcome,
|
||||||
enrollmentType: EnrollmentType,
|
|
||||||
whiteGloveSealing: WhiteGloveSealing,
|
|
||||||
whiteGlovePart2Completion: WhiteGlovePart2Completion,
|
|
||||||
deviceOnlyDeployment: DeviceOnlyDeployment,
|
|
||||||
currentEnrollmentPhase: CurrentEnrollmentPhase,
|
currentEnrollmentPhase: CurrentEnrollmentPhase,
|
||||||
deviceSetupEnteredUtc: DeviceSetupEnteredUtc,
|
deviceSetupEnteredUtc: DeviceSetupEnteredUtc,
|
||||||
accountSetupEnteredUtc: AccountSetupEnteredUtc,
|
accountSetupEnteredUtc: AccountSetupEnteredUtc,
|
||||||
@@ -152,20 +154,18 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
helloResolvedUtc: HelloResolvedUtc,
|
helloResolvedUtc: HelloResolvedUtc,
|
||||||
systemRebootUtc: SystemRebootUtc,
|
systemRebootUtc: SystemRebootUtc,
|
||||||
helloOutcome: HelloOutcome,
|
helloOutcome: HelloOutcome,
|
||||||
aadJoinedWithUser: AadJoinedWithUser,
|
|
||||||
imeMatchedPatternId: ImeMatchedPatternId,
|
imeMatchedPatternId: ImeMatchedPatternId,
|
||||||
shellCoreWhiteGloveSuccessSeen: ShellCoreWhiteGloveSuccessSeen,
|
|
||||||
whiteGloveSealingPatternSeen: WhiteGloveSealingPatternSeen,
|
|
||||||
userAadSignInCompleteUtc: UserAadSignInCompleteUtc,
|
userAadSignInCompleteUtc: UserAadSignInCompleteUtc,
|
||||||
helloResolvedPart2Utc: HelloResolvedPart2Utc,
|
helloResolvedPart2Utc: HelloResolvedPart2Utc,
|
||||||
desktopArrivedPart2Utc: DesktopArrivedPart2Utc,
|
desktopArrivedPart2Utc: DesktopArrivedPart2Utc,
|
||||||
accountSetupCompletedPart2Utc: AccountSetupCompletedPart2Utc,
|
accountSetupCompletedPart2Utc: AccountSetupCompletedPart2Utc,
|
||||||
skipUserEsp: SkipUserEsp,
|
|
||||||
skipDeviceEsp: SkipDeviceEsp,
|
|
||||||
deadlines: Deadlines.ToArray(),
|
deadlines: Deadlines.ToArray(),
|
||||||
lastAppliedSignalOrdinal: LastAppliedSignalOrdinal,
|
lastAppliedSignalOrdinal: LastAppliedSignalOrdinal,
|
||||||
stepIndex: StepIndex,
|
stepIndex: StepIndex,
|
||||||
appInstallFacts: AppInstallFacts,
|
appInstallFacts: AppInstallFacts,
|
||||||
|
scenarioProfile: ScenarioProfile,
|
||||||
|
scenarioObservations: ScenarioObservations,
|
||||||
|
classifierOutcomes: ClassifierOutcomes,
|
||||||
schemaVersion: SchemaVersion);
|
schemaVersion: SchemaVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace AutopilotMonitor.DecisionCore.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Directory-join method observed at session start. Codex follow-up #5 —
|
||||||
|
/// dimension of <see cref="EnrollmentScenarioProfile"/>. Derived from the
|
||||||
|
/// <c>isHybridJoin</c> payload on the <c>SessionStarted</c> signal
|
||||||
|
/// (<c>EnrollmentRegistryDetector.DetectHybridJoin</c>).
|
||||||
|
/// <para>
|
||||||
|
/// This is NOT the same concept as the legacy <c>AadJoinedWithUser</c> fact, which
|
||||||
|
/// tracked a late-AADJ user-presence signal for the WhiteGlove hard-excluder weight.
|
||||||
|
/// That observation lives in <see cref="EnrollmentScenarioObservations.AadUserJoinWithUserObserved"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public enum EnrollmentJoinMode
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
AzureAdJoin = 1,
|
||||||
|
HybridAzureAdJoin = 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace AutopilotMonitor.DecisionCore.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// High-level Autopilot enrollment mode inferred from registry + signals.
|
||||||
|
/// Codex follow-up #5 — dimension of <see cref="EnrollmentScenarioProfile"/>.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Classic"/>, <see cref="SelfDeploying"/> and <see cref="WhiteGlove"/> are
|
||||||
|
/// the three v1 sub-modes the reducer distinguishes through later signals (the
|
||||||
|
/// <c>SessionStarted</c> registry probe only separates v1/v2). <see cref="DevicePreparation"/>
|
||||||
|
/// is Windows Autopilot Device Preparation (v2 / WDP) — no ESP, no pre-reboot user stage.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public enum EnrollmentMode
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Classic = 1,
|
||||||
|
DevicePreparation = 2,
|
||||||
|
SelfDeploying = 3,
|
||||||
|
WhiteGlove = 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
namespace AutopilotMonitor.DecisionCore.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Raw per-signal observations that feed the WhiteGlove sealing classifier and downstream
|
||||||
|
/// guards. Codex follow-up #5 — replaces the legacy per-flag <see cref="SignalFact{T}"/>
|
||||||
|
/// fields (<c>ShellCoreWhiteGloveSuccessSeen</c>, <c>WhiteGloveSealingPatternSeen</c>,
|
||||||
|
/// <c>AadJoinedWithUser</c>, <c>SkipUserEsp</c>, <c>SkipDeviceEsp</c>) with a single aggregate.
|
||||||
|
/// These are **evidence**, not classification — the derived classification lives in
|
||||||
|
/// <see cref="EnrollmentScenarioProfile"/>.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Invariants</b>:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Immutable; the <c>With…</c> methods return new instances.</item>
|
||||||
|
/// <item>Set-once semantics for Boolean flags: once observed, later identical signals are
|
||||||
|
/// no-ops (the first-sighting ordinal is preserved as evidence).</item>
|
||||||
|
/// <item><see cref="AadUserJoinWithUserObserved"/> is the late-AADJ user-presence flag
|
||||||
|
/// (payload <c>aadJoinedWithUser</c>) — NOT the <see cref="EnrollmentJoinMode"/>.
|
||||||
|
/// See <see cref="EnrollmentJoinMode"/> remarks.</item>
|
||||||
|
/// <item><see cref="SkipUserEsp"/> / <see cref="SkipDeviceEsp"/> are the raw half-facts
|
||||||
|
/// from <see cref="Signals.DecisionSignalKind.EspConfigDetected"/>. The derived
|
||||||
|
/// <see cref="EnrollmentScenarioProfile.EspConfig"/> is only set when BOTH halves
|
||||||
|
/// are observed (signals can arrive partial — first skipUser-only, later skipDevice).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EnrollmentScenarioObservations
|
||||||
|
{
|
||||||
|
public static readonly EnrollmentScenarioObservations Empty = new EnrollmentScenarioObservations(
|
||||||
|
shellCoreWhiteGloveSuccessSeen: null,
|
||||||
|
whiteGloveSealingPatternSeen: null,
|
||||||
|
aadUserJoinWithUserObserved: null,
|
||||||
|
skipUserEsp: null,
|
||||||
|
skipDeviceEsp: null);
|
||||||
|
|
||||||
|
public EnrollmentScenarioObservations(
|
||||||
|
SignalFact<bool>? shellCoreWhiteGloveSuccessSeen,
|
||||||
|
SignalFact<bool>? whiteGloveSealingPatternSeen,
|
||||||
|
SignalFact<bool>? aadUserJoinWithUserObserved,
|
||||||
|
SignalFact<bool>? skipUserEsp,
|
||||||
|
SignalFact<bool>? skipDeviceEsp)
|
||||||
|
{
|
||||||
|
ShellCoreWhiteGloveSuccessSeen = shellCoreWhiteGloveSuccessSeen;
|
||||||
|
WhiteGloveSealingPatternSeen = whiteGloveSealingPatternSeen;
|
||||||
|
AadUserJoinWithUserObserved = aadUserJoinWithUserObserved;
|
||||||
|
SkipUserEsp = skipUserEsp;
|
||||||
|
SkipDeviceEsp = skipDeviceEsp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveShellCoreSuccess"/> has fired.</summary>
|
||||||
|
public SignalFact<bool>? ShellCoreWhiteGloveSuccessSeen { get; }
|
||||||
|
|
||||||
|
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveSealingPatternDetected"/> has fired.</summary>
|
||||||
|
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payload-carrying observation from <see cref="Signals.DecisionSignalKind.AadUserJoinedLate"/>.
|
||||||
|
/// <c>true</c> = late AADJ observed with a user-side principal (hard-excluder for
|
||||||
|
/// the WhiteGlove classifier); <c>false</c> = late AADJ observed but device-only.
|
||||||
|
/// Independent of <see cref="EnrollmentJoinMode"/>, which reflects the
|
||||||
|
/// <c>SessionStarted</c> registry hint.
|
||||||
|
/// </summary>
|
||||||
|
public SignalFact<bool>? AadUserJoinWithUserObserved { get; }
|
||||||
|
|
||||||
|
/// <summary>Raw payload half-fact from <see cref="Signals.DecisionSignalKind.EspConfigDetected"/>.</summary>
|
||||||
|
public SignalFact<bool>? SkipUserEsp { get; }
|
||||||
|
|
||||||
|
/// <summary>Raw payload half-fact from <see cref="Signals.DecisionSignalKind.EspConfigDetected"/>.</summary>
|
||||||
|
public SignalFact<bool>? SkipDeviceEsp { get; }
|
||||||
|
|
||||||
|
public EnrollmentScenarioObservations WithShellCoreWhiteGloveSuccessSeen(long sourceSignalOrdinal) =>
|
||||||
|
ShellCoreWhiteGloveSuccessSeen != null
|
||||||
|
? this
|
||||||
|
: new EnrollmentScenarioObservations(
|
||||||
|
new SignalFact<bool>(true, sourceSignalOrdinal),
|
||||||
|
WhiteGloveSealingPatternSeen,
|
||||||
|
AadUserJoinWithUserObserved,
|
||||||
|
SkipUserEsp,
|
||||||
|
SkipDeviceEsp);
|
||||||
|
|
||||||
|
public EnrollmentScenarioObservations WithWhiteGloveSealingPatternSeen(long sourceSignalOrdinal) =>
|
||||||
|
WhiteGloveSealingPatternSeen != null
|
||||||
|
? this
|
||||||
|
: new EnrollmentScenarioObservations(
|
||||||
|
ShellCoreWhiteGloveSuccessSeen,
|
||||||
|
new SignalFact<bool>(true, sourceSignalOrdinal),
|
||||||
|
AadUserJoinWithUserObserved,
|
||||||
|
SkipUserEsp,
|
||||||
|
SkipDeviceEsp);
|
||||||
|
|
||||||
|
public EnrollmentScenarioObservations WithAadUserJoinWithUserObserved(bool value, long sourceSignalOrdinal) =>
|
||||||
|
AadUserJoinWithUserObserved != null
|
||||||
|
? this
|
||||||
|
: new EnrollmentScenarioObservations(
|
||||||
|
ShellCoreWhiteGloveSuccessSeen,
|
||||||
|
WhiteGloveSealingPatternSeen,
|
||||||
|
new SignalFact<bool>(value, sourceSignalOrdinal),
|
||||||
|
SkipUserEsp,
|
||||||
|
SkipDeviceEsp);
|
||||||
|
|
||||||
|
public EnrollmentScenarioObservations WithSkipUserEsp(bool value, long sourceSignalOrdinal) =>
|
||||||
|
SkipUserEsp != null
|
||||||
|
? this
|
||||||
|
: new EnrollmentScenarioObservations(
|
||||||
|
ShellCoreWhiteGloveSuccessSeen,
|
||||||
|
WhiteGloveSealingPatternSeen,
|
||||||
|
AadUserJoinWithUserObserved,
|
||||||
|
new SignalFact<bool>(value, sourceSignalOrdinal),
|
||||||
|
SkipDeviceEsp);
|
||||||
|
|
||||||
|
public EnrollmentScenarioObservations WithSkipDeviceEsp(bool value, long sourceSignalOrdinal) =>
|
||||||
|
SkipDeviceEsp != null
|
||||||
|
? this
|
||||||
|
: new EnrollmentScenarioObservations(
|
||||||
|
ShellCoreWhiteGloveSuccessSeen,
|
||||||
|
WhiteGloveSealingPatternSeen,
|
||||||
|
AadUserJoinWithUserObserved,
|
||||||
|
SkipUserEsp,
|
||||||
|
new SignalFact<bool>(value, sourceSignalOrdinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
namespace AutopilotMonitor.DecisionCore.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Typed aggregate of the derived enrollment-scenario classification. Codex follow-up #5 —
|
||||||
|
/// replaces the legacy <c>Hypothesis EnrollmentType</c> and the <c>SkipUserEsp</c> /
|
||||||
|
/// <c>SkipDeviceEsp</c> facts, and adds an explicit <see cref="JoinMode"/> +
|
||||||
|
/// <see cref="PreProvisioningSide"/> dimension. This is the **semantic truth** of the
|
||||||
|
/// enrollment — raw per-signal evidence lives in <see cref="EnrollmentScenarioObservations"/>
|
||||||
|
/// and classifier verdicts live in <see cref="ClassifierOutcomes"/>.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Invariants</b>:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Immutable; use <see cref="With"/> to produce a mutated copy.</item>
|
||||||
|
/// <item>Confidence monotonicity is the caller's responsibility (see <see cref="EnrollmentScenarioProfileUpdater"/>) — the type itself does not enforce it, keeping deserialization after recovery straightforward.</item>
|
||||||
|
/// <item><see cref="EvidenceOrdinal"/> is the <see cref="Signals.DecisionSignal.SessionSignalOrdinal"/> of the last signal that strengthened the profile; <c>-1</c> when <see cref="Empty"/>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EnrollmentScenarioProfile
|
||||||
|
{
|
||||||
|
public static readonly EnrollmentScenarioProfile Empty = new EnrollmentScenarioProfile(
|
||||||
|
mode: EnrollmentMode.Unknown,
|
||||||
|
joinMode: EnrollmentJoinMode.Unknown,
|
||||||
|
espConfig: EspConfig.Unknown,
|
||||||
|
preProvisioningSide: PreProvisioningSide.None,
|
||||||
|
confidence: ProfileConfidence.Low,
|
||||||
|
evidenceOrdinal: -1,
|
||||||
|
reason: null);
|
||||||
|
|
||||||
|
public EnrollmentScenarioProfile(
|
||||||
|
EnrollmentMode mode,
|
||||||
|
EnrollmentJoinMode joinMode,
|
||||||
|
EspConfig espConfig,
|
||||||
|
PreProvisioningSide preProvisioningSide,
|
||||||
|
ProfileConfidence confidence,
|
||||||
|
long evidenceOrdinal,
|
||||||
|
string? reason)
|
||||||
|
{
|
||||||
|
Mode = mode;
|
||||||
|
JoinMode = joinMode;
|
||||||
|
EspConfig = espConfig;
|
||||||
|
PreProvisioningSide = preProvisioningSide;
|
||||||
|
Confidence = confidence;
|
||||||
|
EvidenceOrdinal = evidenceOrdinal;
|
||||||
|
Reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EnrollmentMode Mode { get; }
|
||||||
|
|
||||||
|
public EnrollmentJoinMode JoinMode { get; }
|
||||||
|
|
||||||
|
public EspConfig EspConfig { get; }
|
||||||
|
|
||||||
|
public PreProvisioningSide PreProvisioningSide { get; }
|
||||||
|
|
||||||
|
public ProfileConfidence Confidence { get; }
|
||||||
|
|
||||||
|
/// <summary>Signal ordinal that most recently strengthened the profile, or <c>-1</c> if never.</summary>
|
||||||
|
public long EvidenceOrdinal { get; }
|
||||||
|
|
||||||
|
/// <summary>Short token describing the last strengthening reason (analog to <see cref="Hypothesis.Reason"/>).</summary>
|
||||||
|
public string? Reason { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produce a copy with the specified dimensions replaced. Omitting a parameter keeps the
|
||||||
|
/// current value — including <see cref="Reason"/>, which means callers cannot clear the
|
||||||
|
/// reason to <c>null</c> via <see cref="With"/> (intentional: once set, reasons are
|
||||||
|
/// historical evidence).
|
||||||
|
/// </summary>
|
||||||
|
public EnrollmentScenarioProfile With(
|
||||||
|
EnrollmentMode? mode = null,
|
||||||
|
EnrollmentJoinMode? joinMode = null,
|
||||||
|
EspConfig? espConfig = null,
|
||||||
|
PreProvisioningSide? preProvisioningSide = null,
|
||||||
|
ProfileConfidence? confidence = null,
|
||||||
|
long? evidenceOrdinal = null,
|
||||||
|
string? reason = null) =>
|
||||||
|
new EnrollmentScenarioProfile(
|
||||||
|
mode ?? Mode,
|
||||||
|
joinMode ?? JoinMode,
|
||||||
|
espConfig ?? EspConfig,
|
||||||
|
preProvisioningSide ?? PreProvisioningSide,
|
||||||
|
confidence ?? Confidence,
|
||||||
|
evidenceOrdinal ?? EvidenceOrdinal,
|
||||||
|
reason ?? Reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
using System;
|
||||||
|
using AutopilotMonitor.DecisionCore.Engine;
|
||||||
|
using AutopilotMonitor.DecisionCore.Signals;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.DecisionCore.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pure static mutator that maps a <see cref="DecisionSignal"/> to a new
|
||||||
|
/// <see cref="EnrollmentScenarioProfile"/>. Codex follow-up #5 — keeps the signal →
|
||||||
|
/// profile rules out of the reducer handlers so they can be unit-tested in isolation
|
||||||
|
/// from the full reducer pipeline.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Monotonic confidence</b>: these helpers never regress <see cref="EnrollmentScenarioProfile.Confidence"/>
|
||||||
|
/// or <see cref="EnrollmentScenarioProfile.Mode"/>. A new signal can only raise confidence
|
||||||
|
/// or swap mode when the current classification is <see cref="EnrollmentMode.Unknown"/> /
|
||||||
|
/// below the incoming strength. That keeps replay deterministic even if signals arrive
|
||||||
|
/// out of order during recovery.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class EnrollmentScenarioProfileUpdater
|
||||||
|
{
|
||||||
|
// ================================================================== SessionStarted
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seed the profile from a <see cref="DecisionSignalKind.SessionStarted"/> signal.
|
||||||
|
/// Payload keys: <see cref="SignalPayloadKeys.EnrollmentType"/> (literal "v1" / "v2"
|
||||||
|
/// from the registry detector), <see cref="SignalPayloadKeys.IsHybridJoin"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static EnrollmentScenarioProfile ApplySessionStarted(
|
||||||
|
EnrollmentScenarioProfile current,
|
||||||
|
DecisionSignal signal)
|
||||||
|
{
|
||||||
|
if (current == null) throw new ArgumentNullException(nameof(current));
|
||||||
|
if (signal == null) throw new ArgumentNullException(nameof(signal));
|
||||||
|
|
||||||
|
var mode = current.Mode;
|
||||||
|
var joinMode = current.JoinMode;
|
||||||
|
var confidence = current.Confidence;
|
||||||
|
var reason = current.Reason;
|
||||||
|
var evidenceOrdinal = current.EvidenceOrdinal;
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
if (signal.Payload != null)
|
||||||
|
{
|
||||||
|
if (signal.Payload.TryGetValue(SignalPayloadKeys.EnrollmentType, out var rawType)
|
||||||
|
&& !string.IsNullOrEmpty(rawType)
|
||||||
|
&& current.Mode == EnrollmentMode.Unknown)
|
||||||
|
{
|
||||||
|
// Registry detector distinguishes v1 from v2 only. v1 stays Unknown here —
|
||||||
|
// the finer UserDriven / SelfDeploying / WhiteGlove classification comes
|
||||||
|
// from later signals (AccountSetup phase, ImeUserSessionCompleted,
|
||||||
|
// DeviceSetupProvisioningComplete, classifier verdicts).
|
||||||
|
if (string.Equals(rawType, "v2", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
mode = EnrollmentMode.DevicePreparation;
|
||||||
|
confidence = Max(confidence, ProfileConfidence.Medium);
|
||||||
|
reason = "session_started:v2";
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// v1 (or any other): keep Mode=Unknown but record the reason.
|
||||||
|
reason = $"session_started:{rawType}";
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.Payload.TryGetValue(SignalPayloadKeys.IsHybridJoin, out var rawHybrid)
|
||||||
|
&& bool.TryParse(rawHybrid, out var isHybrid)
|
||||||
|
&& current.JoinMode == EnrollmentJoinMode.Unknown)
|
||||||
|
{
|
||||||
|
joinMode = isHybrid ? EnrollmentJoinMode.HybridAzureAdJoin : EnrollmentJoinMode.AzureAdJoin;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return current;
|
||||||
|
|
||||||
|
return current.With(
|
||||||
|
mode: mode,
|
||||||
|
joinMode: joinMode,
|
||||||
|
confidence: confidence,
|
||||||
|
reason: reason,
|
||||||
|
evidenceOrdinal: signal.SessionSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== EspConfigDetected
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply the <c>skipUserEsp</c> / <c>skipDeviceEsp</c> payload on
|
||||||
|
/// <see cref="DecisionSignalKind.EspConfigDetected"/> to the observations +
|
||||||
|
/// derived profile. Returns both new values as a tuple — the signal can arrive
|
||||||
|
/// **partially** (e.g. first call with <c>skipUser</c> only, second with
|
||||||
|
/// <c>skipDevice</c>), so the updater fills whichever half is present + parseable
|
||||||
|
/// and only derives <see cref="EnrollmentScenarioProfile.EspConfig"/> once BOTH
|
||||||
|
/// halves are known.
|
||||||
|
/// <para>
|
||||||
|
/// Set-once: existing <see cref="EnrollmentScenarioObservations.SkipUserEsp"/> /
|
||||||
|
/// <see cref="EnrollmentScenarioObservations.SkipDeviceEsp"/> facts are never
|
||||||
|
/// overwritten by a later signal with the same (or different) payload.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static (EnrollmentScenarioProfile profile, EnrollmentScenarioObservations observations) ApplyEspConfigDetected(
|
||||||
|
EnrollmentScenarioProfile currentProfile,
|
||||||
|
EnrollmentScenarioObservations currentObservations,
|
||||||
|
DecisionSignal signal)
|
||||||
|
{
|
||||||
|
if (currentProfile == null) throw new ArgumentNullException(nameof(currentProfile));
|
||||||
|
if (currentObservations == null) throw new ArgumentNullException(nameof(currentObservations));
|
||||||
|
if (signal == null) throw new ArgumentNullException(nameof(signal));
|
||||||
|
|
||||||
|
var newObservations = currentObservations;
|
||||||
|
if (signal.Payload != null)
|
||||||
|
{
|
||||||
|
if (signal.Payload.TryGetValue(SignalPayloadKeys.SkipUserEsp, out var rawUser)
|
||||||
|
&& bool.TryParse(rawUser, out var skipUser))
|
||||||
|
{
|
||||||
|
newObservations = newObservations.WithSkipUserEsp(skipUser, signal.SessionSignalOrdinal);
|
||||||
|
}
|
||||||
|
if (signal.Payload.TryGetValue(SignalPayloadKeys.SkipDeviceEsp, out var rawDevice)
|
||||||
|
&& bool.TryParse(rawDevice, out var skipDevice))
|
||||||
|
{
|
||||||
|
newObservations = newObservations.WithSkipDeviceEsp(skipDevice, signal.SessionSignalOrdinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var newProfile = currentProfile;
|
||||||
|
if (currentProfile.EspConfig == EspConfig.Unknown
|
||||||
|
&& newObservations.SkipUserEsp != null
|
||||||
|
&& newObservations.SkipDeviceEsp != null)
|
||||||
|
{
|
||||||
|
var derived = DeriveEspConfig(
|
||||||
|
newObservations.SkipUserEsp.Value,
|
||||||
|
newObservations.SkipDeviceEsp.Value);
|
||||||
|
|
||||||
|
newProfile = currentProfile.With(
|
||||||
|
espConfig: derived,
|
||||||
|
confidence: Max(currentProfile.Confidence, ProfileConfidence.Medium),
|
||||||
|
reason: $"esp_config_detected:{derived}",
|
||||||
|
evidenceOrdinal: signal.SessionSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (newProfile, newObservations);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static EspConfig DeriveEspConfig(bool skipUser, bool skipDevice) =>
|
||||||
|
(skipUser, skipDevice) switch
|
||||||
|
{
|
||||||
|
(false, false) => EspConfig.FullEsp,
|
||||||
|
(true, false) => EspConfig.DeviceEspOnly,
|
||||||
|
(false, true) => EspConfig.UserEspOnly,
|
||||||
|
(true, true) => EspConfig.NoEsp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ================================================================== EspPhaseChanged
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Observing <see cref="Shared.Models.EnrollmentPhase.AccountSetup"/> for the first time
|
||||||
|
/// is the canonical Classic UserDriven-v1 tell (mirrors the legacy
|
||||||
|
/// <c>account_setup_observed</c> hypothesis update). Only promotes Mode when it is still
|
||||||
|
/// <see cref="EnrollmentMode.Unknown"/> — a WhiteGlove or SelfDeploying classification
|
||||||
|
/// from an earlier signal wins.
|
||||||
|
/// </summary>
|
||||||
|
public static EnrollmentScenarioProfile ApplyAccountSetupObserved(
|
||||||
|
EnrollmentScenarioProfile current,
|
||||||
|
DecisionSignal signal)
|
||||||
|
{
|
||||||
|
if (current == null) throw new ArgumentNullException(nameof(current));
|
||||||
|
if (signal == null) throw new ArgumentNullException(nameof(signal));
|
||||||
|
|
||||||
|
if (current.Mode != EnrollmentMode.Unknown) return current;
|
||||||
|
|
||||||
|
return current.With(
|
||||||
|
mode: EnrollmentMode.Classic,
|
||||||
|
confidence: Max(current.Confidence, ProfileConfidence.Medium),
|
||||||
|
reason: "account_setup_observed",
|
||||||
|
evidenceOrdinal: signal.SessionSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== ImeUserSessionCompleted
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="DecisionSignalKind.ImeUserSessionCompleted"/> is a strong UserDriven-v1
|
||||||
|
/// indicator. Upgrades Mode to <see cref="EnrollmentMode.Classic"/> at
|
||||||
|
/// <see cref="ProfileConfidence.High"/>, UNLESS a prior signal has already confirmed a
|
||||||
|
/// different mode at High confidence (monotonic rule).
|
||||||
|
/// </summary>
|
||||||
|
public static EnrollmentScenarioProfile ApplyImeUserSessionCompleted(
|
||||||
|
EnrollmentScenarioProfile current,
|
||||||
|
DecisionSignal signal)
|
||||||
|
{
|
||||||
|
if (current == null) throw new ArgumentNullException(nameof(current));
|
||||||
|
if (signal == null) throw new ArgumentNullException(nameof(signal));
|
||||||
|
|
||||||
|
if (current.Confidence == ProfileConfidence.High && current.Mode != EnrollmentMode.Classic
|
||||||
|
&& current.Mode != EnrollmentMode.Unknown)
|
||||||
|
{
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.With(
|
||||||
|
mode: EnrollmentMode.Classic,
|
||||||
|
confidence: ProfileConfidence.High,
|
||||||
|
reason: "ime_user_session_completed",
|
||||||
|
evidenceOrdinal: signal.SessionSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== DeviceSetupProvisioningComplete
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/> without a preceding
|
||||||
|
/// AccountSetup is a strong SelfDeploying-v1 indicator (caller must guard that invariant).
|
||||||
|
/// Upgrades Mode to <see cref="EnrollmentMode.SelfDeploying"/> at
|
||||||
|
/// <see cref="ProfileConfidence.High"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static EnrollmentScenarioProfile ApplyDeviceSetupProvisioningComplete(
|
||||||
|
EnrollmentScenarioProfile current,
|
||||||
|
DecisionSignal signal)
|
||||||
|
{
|
||||||
|
if (current == null) throw new ArgumentNullException(nameof(current));
|
||||||
|
if (signal == null) throw new ArgumentNullException(nameof(signal));
|
||||||
|
|
||||||
|
if (current.Confidence == ProfileConfidence.High && current.Mode != EnrollmentMode.SelfDeploying
|
||||||
|
&& current.Mode != EnrollmentMode.Unknown)
|
||||||
|
{
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.With(
|
||||||
|
mode: EnrollmentMode.SelfDeploying,
|
||||||
|
confidence: ProfileConfidence.High,
|
||||||
|
reason: "selfdeploying_provisioning_complete",
|
||||||
|
evidenceOrdinal: signal.SessionSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== WhiteGlove verdict
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the WhiteGlove sealing classifier issues a <see cref="HypothesisLevel.Confirmed"/>
|
||||||
|
/// verdict, the profile mirrors the decision: Mode=<see cref="EnrollmentMode.WhiteGlove"/>
|
||||||
|
/// at <see cref="ProfileConfidence.High"/>, PreProvisioningSide=<see cref="PreProvisioningSide.Technician"/>
|
||||||
|
/// (Part 1 is by definition the technician side). Non-Confirmed verdicts leave the profile
|
||||||
|
/// alone — the verdict itself is stored in <see cref="ClassifierOutcomes.WhiteGloveSealing"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static EnrollmentScenarioProfile ApplyWhiteGloveSealingConfirmed(
|
||||||
|
EnrollmentScenarioProfile current,
|
||||||
|
DecisionSignal signal)
|
||||||
|
{
|
||||||
|
if (current == null) throw new ArgumentNullException(nameof(current));
|
||||||
|
if (signal == null) throw new ArgumentNullException(nameof(signal));
|
||||||
|
|
||||||
|
return current.With(
|
||||||
|
mode: EnrollmentMode.WhiteGlove,
|
||||||
|
preProvisioningSide: PreProvisioningSide.Technician,
|
||||||
|
confidence: ProfileConfidence.High,
|
||||||
|
reason: "classifier_whiteglove_sealing_confirmed",
|
||||||
|
evidenceOrdinal: signal.SessionSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== AadUserJoinedLate
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="DecisionSignalKind.AadUserJoinedLate"/> is a classifier-state update only
|
||||||
|
/// (see <c>feedback_aad_joined_late_not_completion</c>). It does NOT touch
|
||||||
|
/// <see cref="EnrollmentScenarioProfile.JoinMode"/> — that comes from
|
||||||
|
/// <c>SessionStarted.payload.isHybridJoin</c>. The payload-carrying presence flag is
|
||||||
|
/// stored in <see cref="EnrollmentScenarioObservations.AadUserJoinWithUserObserved"/>;
|
||||||
|
/// the profile only records the reason annotation.
|
||||||
|
/// </summary>
|
||||||
|
public static EnrollmentScenarioProfile ApplyAadUserJoinedLate(
|
||||||
|
EnrollmentScenarioProfile current,
|
||||||
|
DecisionSignal signal,
|
||||||
|
bool withUser)
|
||||||
|
{
|
||||||
|
if (current == null) throw new ArgumentNullException(nameof(current));
|
||||||
|
if (signal == null) throw new ArgumentNullException(nameof(signal));
|
||||||
|
|
||||||
|
return current.With(
|
||||||
|
reason: $"late_aadj_observed:withUser={(withUser ? "true" : "false")}",
|
||||||
|
evidenceOrdinal: signal.SessionSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== helpers
|
||||||
|
|
||||||
|
internal static ProfileConfidence Max(ProfileConfidence a, ProfileConfidence b) =>
|
||||||
|
a >= b ? a : b;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace AutopilotMonitor.DecisionCore.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ESP (Enrollment Status Page) phase coverage for this enrollment. Codex follow-up #5 —
|
||||||
|
/// dimension of <see cref="EnrollmentScenarioProfile"/>. Derived from the
|
||||||
|
/// <c>skipUserEsp</c> / <c>skipDeviceEsp</c> payload on the <c>EspConfigDetected</c>
|
||||||
|
/// signal. Replaces the legacy <c>SkipUserEsp</c> / <c>SkipDeviceEsp</c>
|
||||||
|
/// <see cref="SignalFact{T}"/> facts.
|
||||||
|
/// </summary>
|
||||||
|
public enum EspConfig
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
/// <summary>Both Device-ESP and Account-ESP run (the default).</summary>
|
||||||
|
FullEsp = 1,
|
||||||
|
/// <summary>Device-ESP is skipped; Account-ESP still runs (<c>skipDeviceEsp=true</c>).</summary>
|
||||||
|
UserEspOnly = 2,
|
||||||
|
/// <summary>Account-ESP is skipped; Device-ESP still runs (<c>skipUserEsp=true</c>).</summary>
|
||||||
|
DeviceEspOnly = 3,
|
||||||
|
/// <summary>Both phases are skipped (<c>skipUserEsp=true</c> AND <c>skipDeviceEsp=true</c>).</summary>
|
||||||
|
NoEsp = 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace AutopilotMonitor.DecisionCore.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies the pre-provisioning side when the enrollment is a WhiteGlove flow.
|
||||||
|
/// Codex follow-up #5 — dimension of <see cref="EnrollmentScenarioProfile"/>.
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="None"/> — non-WhiteGlove flow or pre-provisioning side not yet known.</item>
|
||||||
|
/// <item><see cref="Technician"/> — Part-1 (pre-reboot, technician at IT bench).</item>
|
||||||
|
/// <item><see cref="User"/> — Part-2 (post-reboot, end-user sign-in at their workplace).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public enum PreProvisioningSide
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Technician = 1,
|
||||||
|
User = 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace AutopilotMonitor.DecisionCore.State
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Overall confidence in the current <see cref="EnrollmentScenarioProfile"/> classification.
|
||||||
|
/// Codex follow-up #5. Monotonic by convention: the updater never regresses confidence —
|
||||||
|
/// a later signal with weaker evidence cannot overwrite a stronger classification.
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="Low"/> — initial / registry-derived hint (e.g. <c>SessionStarted</c> only).</item>
|
||||||
|
/// <item><see cref="Medium"/> — corroborating signal observed (e.g. ESP phase change, profile read).</item>
|
||||||
|
/// <item><see cref="High"/> — decisive signal (e.g. IME pattern match, classifier Confirmed).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public enum ProfileConfidence
|
||||||
|
{
|
||||||
|
Low = 0,
|
||||||
|
Medium = 1,
|
||||||
|
High = 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user