mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
refactor(decisioncore): WhiteGlove Part 1 handlers + sealing classifier + 4 scenarios (M3.3)
Plan §4 M3.3: the first kernel-service classifier goes live (plan §2.4 L.8),
the effect-based classifier-run contract is wired end-to-end, and the anti-loop
via InputHash is proven by a dedicated fixture.
Classifier (WhiteGloveSealingClassifier : IClassifier):
- Scoring ported 1:1 from Legacy WhiteGloveClassifier (same weights, same
thresholds, same asymmetric-conservative rule: only Confirmed drives the
WhiteGloveSealed stage).
- Positive weights: ShellCoreWhiteGloveSuccess=80, SealingPattern=40,
DeviceOnlyDeployment=15, SystemReboot=15.
- Hard excluders (-100 each): AadJoinedWithUser, DesktopArrived, HelloResolved.
Soft exclude: AccountSetupActivity=-40.
- WhiteGloveSealingSnapshot is the classifier input type and computes a
deterministic SHA256-short InputHash over WG-relevant fields only — so
the anti-loop triggers on meaningful fact changes, not on unrelated state.
Handlers (DecisionEngine.WhiteGlove.cs):
- WhiteGloveShellCoreSuccess records fact; emits RunClassifier effect;
arms ClassifierTick if not already.
- WhiteGloveSealingPatternDetected mirror handler for the IME-pattern path.
- ClassifierVerdictIssued applies the verdict to WhiteGloveSealing
hypothesis; on Confirmed transitions to
WhiteGloveSealed stage +
Outcome=WhiteGlovePart1Sealed and emits
the whiteglove_complete event.
- HandleClassifierTickDeadlineFired (shared router) re-emits RunClassifier
and re-arms itself unless terminal.
ClassifierAwareReplayHarness (tests-only):
- Loops per-step effects: any RunClassifier effect is resolved synchronously
via the registered IClassifier; the verdict is wrapped in a
ClassifierVerdictIssued signal and fed back into Reduce. Anti-loop skips
the call when snapshot hash == state.WhiteGloveSealing.LastClassifierVerdictId.
- Exposes ClassifierRunStats for regression tests.
DecisionState extension: two new SignalFact<bool> fields capture the WG
sightings (ShellCoreWhiteGloveSuccessSeen, WhiteGloveSealingPatternSeen). The
classifier reads them via WhiteGloveSealingSnapshot.
Scenario fixtures (4, committed-anonymized):
- whiteglove-inline-v1.jsonl Confirmed on ShellCore alone (80).
- whiteglove-signal-correlated-v1.jsonl Weak after pattern, ClassifierTick
anti-loop-skipped, Confirmed after
ShellCore escalates the score.
- whiteglove-false-positive-v1.jsonl SealingPattern + late-AADJ hard-
excludes -> Rejected; session still
completes via Classic Hello+Desktop.
- whiteglove-antiloop-v1.jsonl Single pattern + two ticks; runs=1,
skipped_by_antiloop=2.
Tests: 4 scenario tests in WhiteGlovePart1ScenarioTests, all passing.
DecisionCore.Tests: 49 (was 45). Legacy 507 + Functions 557 unchanged.
ReducerFailSafeTests + HarnessTests updated to use AppInstallCompleted as
the still-unhandled sentinel (WhiteGloveShellCoreSuccess is now wired).
Plan: plans/REFACTOR_AGENT_V2.md §4 M3 sub-milestone M3.3 + §2.4 classifier kernel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
9
|
||||
10
|
||||
|
||||
@@ -1 +1 @@
|
||||
1051
|
||||
1052
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests.Harness
|
||||
{
|
||||
/// <summary>
|
||||
/// Replay harness that also executes <see cref="DecisionEffectKind.RunClassifier"/>
|
||||
/// effects, synthesizing the resulting <see cref="DecisionSignalKind.ClassifierVerdictIssued"/>
|
||||
/// signal and feeding it back into the reducer. Emulates the production EffectRunner
|
||||
/// for the pure-synchronous classifier case used in M3.3 / M3.4 (plan §2.4).
|
||||
/// <para>
|
||||
/// Anti-loop (plan §2.4): a classifier is skipped when the snapshot's
|
||||
/// <see cref="WhiteGloveSealingSnapshot.ComputeInputHash"/> matches the last
|
||||
/// <see cref="Hypothesis.LastClassifierVerdictId"/> recorded for that classifier — tested
|
||||
/// end-to-end by the Anti-Loop scenario.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ClassifierAwareReplayHarness
|
||||
{
|
||||
private readonly IDecisionEngine _engine;
|
||||
private readonly IReadOnlyDictionary<string, IClassifier> _classifiers;
|
||||
|
||||
public ClassifierAwareReplayHarness(
|
||||
IDecisionEngine engine,
|
||||
IReadOnlyDictionary<string, IClassifier> classifiers)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
_classifiers = classifiers ?? throw new ArgumentNullException(nameof(classifiers));
|
||||
}
|
||||
|
||||
public ReplayResult Replay(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
IReadOnlyList<DecisionSignal> signals)
|
||||
{
|
||||
if (signals == null) throw new ArgumentNullException(nameof(signals));
|
||||
|
||||
var state = DecisionState.CreateInitial(sessionId, tenantId);
|
||||
var allTransitions = new List<DecisionTransition>(signals.Count * 2);
|
||||
ClassifierRunStats = new Dictionary<string, int>();
|
||||
|
||||
long nextSyntheticOrdinal = long.MinValue; // reassigned once we know the max real ordinal.
|
||||
foreach (var s in signals)
|
||||
{
|
||||
if (s.SessionSignalOrdinal + 1 > nextSyntheticOrdinal)
|
||||
{
|
||||
nextSyntheticOrdinal = s.SessionSignalOrdinal + 1;
|
||||
}
|
||||
}
|
||||
if (nextSyntheticOrdinal == long.MinValue) nextSyntheticOrdinal = 0;
|
||||
|
||||
foreach (var signal in signals)
|
||||
{
|
||||
var step = _engine.Reduce(state, signal);
|
||||
state = step.NewState;
|
||||
allTransitions.Add(step.Transition);
|
||||
|
||||
// Process effects: the only effect kind the harness acts on is RunClassifier;
|
||||
// scheduling / cancellation / event emission are bookkept by state + the
|
||||
// transition record.
|
||||
foreach (var effect in step.Effects)
|
||||
{
|
||||
if (effect.Kind != DecisionEffectKind.RunClassifier) continue;
|
||||
if (string.IsNullOrEmpty(effect.ClassifierId)) continue;
|
||||
if (!_classifiers.TryGetValue(effect.ClassifierId!, out var classifier)) continue;
|
||||
|
||||
// Anti-loop: skip if snapshot hash matches the last verdict hash for this
|
||||
// classifier. For M3.3 we only track WhiteGloveSealing on state, so the
|
||||
// check is tight. Other classifiers will get their own state fields in M3.4+.
|
||||
var snapshot = effect.ClassifierSnapshot
|
||||
?? throw new InvalidOperationException(
|
||||
$"RunClassifier effect for '{effect.ClassifierId}' missing snapshot.");
|
||||
|
||||
string snapshotHash;
|
||||
if (snapshot is WhiteGloveSealingSnapshot wgSnap)
|
||||
{
|
||||
snapshotHash = wgSnap.ComputeInputHash();
|
||||
if (state.WhiteGloveSealing.LastClassifierVerdictId == snapshotHash)
|
||||
{
|
||||
Increment(effect.ClassifierId!, "skipped_by_antiloop");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No per-classifier state tracking yet; run unconditionally.
|
||||
snapshotHash = string.Empty;
|
||||
}
|
||||
|
||||
var verdict = classifier.Classify(snapshot);
|
||||
Increment(effect.ClassifierId!, "run");
|
||||
|
||||
var verdictSignal = new DecisionSignal(
|
||||
sessionSignalOrdinal: nextSyntheticOrdinal,
|
||||
sessionTraceOrdinal: nextSyntheticOrdinal,
|
||||
kind: DecisionSignalKind.ClassifierVerdictIssued,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: signal.OccurredAtUtc,
|
||||
sourceOrigin: $"harness:classifier:{effect.ClassifierId}",
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Synthetic,
|
||||
identifier: $"classifier:{effect.ClassifierId}:{verdict.InputHash}",
|
||||
summary: $"{effect.ClassifierId}={verdict.Level}/{verdict.Score}"),
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
["classifier"] = verdict.ClassifierId,
|
||||
["level"] = verdict.Level.ToString(),
|
||||
["score"] = verdict.Score.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["reason"] = verdict.Reason,
|
||||
["inputHash"] = verdict.InputHash,
|
||||
});
|
||||
nextSyntheticOrdinal++;
|
||||
|
||||
var verdictStep = _engine.Reduce(state, verdictSignal);
|
||||
state = verdictStep.NewState;
|
||||
allTransitions.Add(verdictStep.Transition);
|
||||
}
|
||||
}
|
||||
|
||||
return new ReplayResult(
|
||||
finalState: state,
|
||||
transitions: allTransitions,
|
||||
finalStepHash: ComputeStepHash(state));
|
||||
}
|
||||
|
||||
/// <summary>Run counts per classifier (keys: "run", "skipped_by_antiloop"). For test assertions.</summary>
|
||||
public IDictionary<string, int> ClassifierRunStats { get; private set; } = new Dictionary<string, int>();
|
||||
|
||||
private void Increment(string classifierId, string bucket)
|
||||
{
|
||||
var key = $"{classifierId}:{bucket}";
|
||||
ClassifierRunStats[key] = ClassifierRunStats.TryGetValue(key, out var v) ? v + 1 : 1;
|
||||
}
|
||||
|
||||
private static string ComputeStepHash(DecisionState state)
|
||||
{
|
||||
var canonical = string.Join(
|
||||
"|",
|
||||
state.SessionId,
|
||||
state.TenantId,
|
||||
state.Stage.ToString(),
|
||||
state.Outcome?.ToString() ?? "null",
|
||||
state.StepIndex.ToString(),
|
||||
state.LastAppliedSignalOrdinal.ToString(),
|
||||
state.WhiteGloveSealing.Level.ToString(),
|
||||
state.WhiteGloveSealing.Score.ToString());
|
||||
|
||||
using (var sha = System.Security.Cryptography.SHA256.Create())
|
||||
{
|
||||
var bytes = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(canonical));
|
||||
var sb = new System.Text.StringBuilder(16);
|
||||
for (int i = 0; i < 8; i++) sb.Append(bytes[i].ToString("x2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,16 +88,16 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
// An unhandled signal advances bookkeeping but leaves stage/outcome alone
|
||||
// and records a DeadEndReason — plan §2.5 fail-safe shape for non-handlers.
|
||||
// WhiteGloveShellCoreSuccess is unhandled until M3.3.
|
||||
// AppInstallCompleted is scheduled for v11.1; still unhandled in M3.3.
|
||||
var harness = new ReplayHarness(new DecisionEngine());
|
||||
var signal = new DecisionSignal(
|
||||
sessionSignalOrdinal: 0,
|
||||
sessionTraceOrdinal: 0,
|
||||
kind: DecisionSignalKind.WhiteGloveShellCoreSuccess,
|
||||
kind: DecisionSignalKind.AppInstallCompleted,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
sourceOrigin: "harness",
|
||||
evidence: new Evidence(EvidenceKind.Raw, "WG:test", "test"));
|
||||
evidence: new Evidence(EvidenceKind.Raw, "App:test", "test"));
|
||||
|
||||
var result = harness.Replay("s", "t", new List<DecisionSignal> { signal });
|
||||
|
||||
|
||||
@@ -57,16 +57,16 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
[Fact]
|
||||
public void Reduce_unhandledKind_producesDeadEnd_withVersionedReason()
|
||||
{
|
||||
// Pick a kind that no sub-milestone has wired yet. WhiteGloveShellCoreSuccess
|
||||
// comes online in M3.3 — update this test then.
|
||||
// Pick a kind that no sub-milestone has wired yet. AppInstallCompleted is
|
||||
// scheduled for v11.1 app-install observability; update this test when wired.
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t");
|
||||
var signal = MakeSignal(DecisionSignalKind.WhiteGloveShellCoreSuccess, schemaVersion: 1);
|
||||
var signal = MakeSignal(DecisionSignalKind.AppInstallCompleted, schemaVersion: 1);
|
||||
|
||||
var step = engine.Reduce(state, signal);
|
||||
|
||||
Assert.False(step.Transition.Taken);
|
||||
Assert.Equal("unhandled_signal_kind:WhiteGloveShellCoreSuccess:v1", step.Transition.DeadEndReason);
|
||||
Assert.Equal("unhandled_signal_kind:AppInstallCompleted:v1", step.Transition.DeadEndReason);
|
||||
Assert.Equal(SessionStage.SessionStarted, step.NewState.Stage);
|
||||
Assert.Equal(1, step.NewState.StepIndex);
|
||||
}
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.DecisionCore.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan §4 M3.3 — WhiteGlove Part 1 scenarios. Uses the classifier-aware harness so
|
||||
/// RunClassifier effects are executed synchronously and the resulting verdicts feed
|
||||
/// back into the reducer.
|
||||
/// </summary>
|
||||
public sealed class WhiteGlovePart1ScenarioTests : ScenarioTestBase
|
||||
{
|
||||
private static ClassifierAwareReplayHarness NewHarness() =>
|
||||
new ClassifierAwareReplayHarness(
|
||||
new DecisionEngine(),
|
||||
new Dictionary<string, IClassifier>
|
||||
{
|
||||
[WhiteGloveSealingClassifier.ClassifierId] = new WhiteGloveSealingClassifier(),
|
||||
});
|
||||
|
||||
private static ReplayResult RunWgFixture(string fixture, string sessionId, string tenantId)
|
||||
{
|
||||
var signals = LoadFixture(fixture);
|
||||
return NewHarness().Replay(sessionId, tenantId, signals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGloveInline_shellCoreAlone_sealsWhiteGlove()
|
||||
{
|
||||
var result = RunWgFixture("whiteglove-inline-v1.jsonl", "session-anon-0010", "tenant-anon-0010");
|
||||
|
||||
Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
|
||||
Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome);
|
||||
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level);
|
||||
Assert.Equal(80, result.FinalState.WhiteGloveSealing.Score);
|
||||
Assert.NotNull(result.FinalState.ShellCoreWhiteGloveSuccessSeen);
|
||||
Assert.True(result.FinalState.ShellCoreWhiteGloveSuccessSeen!.Value);
|
||||
Assert.Empty(result.FinalState.Deadlines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGloveSignalCorrelated_patternThenShellCore_sealsAfterSecondSignal()
|
||||
{
|
||||
var harness = NewHarness();
|
||||
var signals = LoadFixture("whiteglove-signal-correlated-v1.jsonl");
|
||||
var result = harness.Replay("session-anon-0011", "tenant-anon-0011", signals);
|
||||
|
||||
Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
|
||||
Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome);
|
||||
Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level);
|
||||
Assert.True(result.FinalState.WhiteGloveSealing.Score >= WhiteGloveSealingClassifier.HighThreshold);
|
||||
|
||||
// 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
|
||||
// produced fresh verdicts.
|
||||
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;
|
||||
Assert.Equal(2, runs);
|
||||
Assert.Equal(1, skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGloveFalsePositive_LateAadjAfterSealingPattern_rejectsWg_completesClassic()
|
||||
{
|
||||
var result = RunWgFixture("whiteglove-false-positive-v1.jsonl", "session-anon-0012", "tenant-anon-0012");
|
||||
|
||||
// The session must NOT end in WhiteGloveSealed.
|
||||
Assert.NotEqual(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
|
||||
Assert.Equal(SessionStage.Completed, result.FinalState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
|
||||
|
||||
// WhiteGlove verdict should be Rejected (hard-excluded by AadJoinedWithUser).
|
||||
Assert.Equal(HypothesisLevel.Rejected, result.FinalState.WhiteGloveSealing.Level);
|
||||
Assert.Equal(0, result.FinalState.WhiteGloveSealing.Score);
|
||||
|
||||
// The late-AADJ fact + Hello + Desktop are all recorded.
|
||||
Assert.NotNull(result.FinalState.AadJoinedWithUser);
|
||||
Assert.True(result.FinalState.AadJoinedWithUser!.Value);
|
||||
Assert.NotNull(result.FinalState.HelloResolvedUtc);
|
||||
Assert.NotNull(result.FinalState.DesktopArrivedUtc);
|
||||
Assert.Equal("Success", result.FinalState.HelloOutcome!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifierAntiLoop_sameSnapshot_runsOnceThenSkips()
|
||||
{
|
||||
var harness = NewHarness();
|
||||
var signals = LoadFixture("whiteglove-antiloop-v1.jsonl");
|
||||
var result = harness.Replay("session-anon-0013", "tenant-anon-0013", signals);
|
||||
|
||||
// SealingPattern was seen but insufficient alone for Confirmed.
|
||||
Assert.NotEqual(SessionStage.WhiteGloveSealed, result.FinalState.Stage);
|
||||
Assert.Equal(HypothesisLevel.Weak, result.FinalState.WhiteGloveSealing.Level);
|
||||
Assert.Equal(40, result.FinalState.WhiteGloveSealing.Score);
|
||||
|
||||
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;
|
||||
Assert.Equal(1, runs);
|
||||
Assert.Equal(2, skipped);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Classifiers
|
||||
{
|
||||
/// <summary>
|
||||
/// Scoring-based WhiteGlove Part 1 classifier. Plan §2.4 / §M3.3.
|
||||
/// <para>
|
||||
/// Scoring rules ported 1:1 from the Legacy <c>WhiteGloveClassifier</c>
|
||||
/// (<c>src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs</c>)
|
||||
/// — same weights, same thresholds, same asymmetric-conservative decision rule. The
|
||||
/// refactor change is the <see cref="IClassifier"/> contract, the snapshot input type,
|
||||
/// and the deterministic <see cref="WhiteGloveSealingSnapshot.ComputeInputHash"/> anti-loop.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asymmetric-conservative: only <see cref="HypothesisLevel.Confirmed"/> drives the
|
||||
/// WhiteGloveSealed stage. <see cref="HypothesisLevel.Weak"/> verdicts are recorded for
|
||||
/// observability but treated as NOT WhiteGlove — false-positive WG keeps the agent
|
||||
/// pending when it should exit; false-negative emits completion early and the later
|
||||
/// user sign-in triggers a new session (much lower cost).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WhiteGloveSealingClassifier : IClassifier
|
||||
{
|
||||
public const string ClassifierId = "whiteglove-sealing";
|
||||
|
||||
// Thresholds (tunable after production data — preserve Legacy baseline)
|
||||
internal const int HighThreshold = 70; // >= 70 -> Confirmed
|
||||
internal const int LowThreshold = 30; // 30..69 -> Weak (observability only)
|
||||
|
||||
// Positive-signal weights (port from Legacy)
|
||||
internal const int WeightShellCoreWhiteGloveSuccess = 80;
|
||||
internal const int WeightWhiteGloveSealingPattern = 40;
|
||||
internal const int WeightDeviceOnlyDeployment = 15;
|
||||
internal const int WeightSystemRebootObserved = 15;
|
||||
|
||||
// Negative-signal weights (hard excluders)
|
||||
internal const int WeightAadJoinedWithUser = -100;
|
||||
internal const int WeightDesktopArrived = -100;
|
||||
internal const int WeightHelloResolved = -100;
|
||||
internal const int WeightAccountSetupActive = -40;
|
||||
|
||||
public string Id => ClassifierId;
|
||||
public Type SnapshotType => typeof(WhiteGloveSealingSnapshot);
|
||||
|
||||
public ClassifierVerdict Classify(object snapshot)
|
||||
{
|
||||
if (snapshot == null) throw new ArgumentNullException(nameof(snapshot));
|
||||
if (!(snapshot is WhiteGloveSealingSnapshot s))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Expected {nameof(WhiteGloveSealingSnapshot)}, got {snapshot.GetType().Name}.",
|
||||
nameof(snapshot));
|
||||
}
|
||||
|
||||
var factors = new List<string>();
|
||||
int score = 0;
|
||||
|
||||
// Hard excluders — any one flips the verdict to definitive NOT WhiteGlove.
|
||||
if (s.AadJoinedWithUser) { score += WeightAadJoinedWithUser; factors.Add($"aad_joined_with_user:{WeightAadJoinedWithUser:+#;-#;0}"); }
|
||||
if (s.DesktopArrived) { score += WeightDesktopArrived; factors.Add($"desktop_arrived:{WeightDesktopArrived:+#;-#;0}"); }
|
||||
if (s.HelloResolved) { score += WeightHelloResolved; factors.Add($"hello_resolved:{WeightHelloResolved:+#;-#;0}"); }
|
||||
if (s.HasAccountSetupActivity)
|
||||
{
|
||||
score += WeightAccountSetupActive;
|
||||
factors.Add($"account_setup_activity:{WeightAccountSetupActive:+#;-#;0}");
|
||||
}
|
||||
|
||||
// Positive signals.
|
||||
if (s.ShellCoreWhiteGloveSuccessSeen)
|
||||
{
|
||||
score += WeightShellCoreWhiteGloveSuccess;
|
||||
factors.Add($"shellcore_wg_success:{WeightShellCoreWhiteGloveSuccess:+#;-#;0}");
|
||||
}
|
||||
if (s.WhiteGloveSealingPatternSeen)
|
||||
{
|
||||
score += WeightWhiteGloveSealingPattern;
|
||||
factors.Add($"sealing_pattern:{WeightWhiteGloveSealingPattern:+#;-#;0}");
|
||||
}
|
||||
if (s.IsDeviceOnlyDeploymentHypothesis)
|
||||
{
|
||||
score += WeightDeviceOnlyDeployment;
|
||||
factors.Add($"device_only_deployment:{WeightDeviceOnlyDeployment:+#;-#;0}");
|
||||
}
|
||||
if (s.SystemRebootUtc.HasValue)
|
||||
{
|
||||
score += WeightSystemRebootObserved;
|
||||
factors.Add($"system_reboot:{WeightSystemRebootObserved:+#;-#;0}");
|
||||
}
|
||||
|
||||
// Cap to [0, 100].
|
||||
if (score < 0) score = 0;
|
||||
if (score > 100) score = 100;
|
||||
|
||||
HypothesisLevel level;
|
||||
if (score >= HighThreshold) level = HypothesisLevel.Confirmed;
|
||||
else if (score >= LowThreshold) level = HypothesisLevel.Weak;
|
||||
else level = HypothesisLevel.Rejected;
|
||||
|
||||
return new ClassifierVerdict(
|
||||
classifierId: ClassifierId,
|
||||
level: level,
|
||||
score: score,
|
||||
contributingFactors: factors,
|
||||
reason: $"confidence={level} score={score}",
|
||||
inputHash: s.ComputeInputHash());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Classifiers
|
||||
{
|
||||
/// <summary>
|
||||
/// Input snapshot for <see cref="WhiteGloveSealingClassifier"/>. Plan §2.4 input-hash.
|
||||
/// <para>
|
||||
/// Mirrors the WG-relevant fields from <c>DecisionState</c> that participate in scoring.
|
||||
/// Extra fields (unrelated facts, deadlines, etc.) intentionally excluded — including them
|
||||
/// would cause unnecessary re-runs on the anti-loop guard.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WhiteGloveSealingSnapshot
|
||||
{
|
||||
public WhiteGloveSealingSnapshot(
|
||||
bool shellCoreWhiteGloveSuccessSeen,
|
||||
bool whiteGloveSealingPatternSeen,
|
||||
bool aadJoinedWithUser,
|
||||
bool desktopArrived,
|
||||
bool helloResolved,
|
||||
bool hasAccountSetupActivity,
|
||||
bool isDeviceOnlyDeploymentHypothesis,
|
||||
DateTime? systemRebootUtc,
|
||||
EnrollmentPhase? currentEnrollmentPhase)
|
||||
{
|
||||
ShellCoreWhiteGloveSuccessSeen = shellCoreWhiteGloveSuccessSeen;
|
||||
WhiteGloveSealingPatternSeen = whiteGloveSealingPatternSeen;
|
||||
AadJoinedWithUser = aadJoinedWithUser;
|
||||
DesktopArrived = desktopArrived;
|
||||
HelloResolved = helloResolved;
|
||||
HasAccountSetupActivity = hasAccountSetupActivity;
|
||||
IsDeviceOnlyDeploymentHypothesis = isDeviceOnlyDeploymentHypothesis;
|
||||
SystemRebootUtc = systemRebootUtc;
|
||||
CurrentEnrollmentPhase = currentEnrollmentPhase;
|
||||
}
|
||||
|
||||
public bool ShellCoreWhiteGloveSuccessSeen { get; }
|
||||
public bool WhiteGloveSealingPatternSeen { get; }
|
||||
public bool AadJoinedWithUser { get; }
|
||||
public bool DesktopArrived { get; }
|
||||
public bool HelloResolved { get; }
|
||||
public bool HasAccountSetupActivity { get; }
|
||||
public bool IsDeviceOnlyDeploymentHypothesis { get; }
|
||||
public DateTime? SystemRebootUtc { get; }
|
||||
public EnrollmentPhase? CurrentEnrollmentPhase { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic 16-hex-char SHA256 prefix over the snapshot. Anti-loop guard:
|
||||
/// the classifier is skipped if this hash equals the last recorded
|
||||
/// <see cref="State.Hypothesis.LastClassifierVerdictId"/>. Plan §2.4.
|
||||
/// </summary>
|
||||
public string ComputeInputHash()
|
||||
{
|
||||
var canonical = string.Join(
|
||||
"|",
|
||||
"whiteglove-sealing-v1",
|
||||
ShellCoreWhiteGloveSuccessSeen ? "1" : "0",
|
||||
WhiteGloveSealingPatternSeen ? "1" : "0",
|
||||
AadJoinedWithUser ? "1" : "0",
|
||||
DesktopArrived ? "1" : "0",
|
||||
HelloResolved ? "1" : "0",
|
||||
HasAccountSetupActivity ? "1" : "0",
|
||||
IsDeviceOnlyDeploymentHypothesis ? "1" : "0",
|
||||
SystemRebootUtc?.ToString("O") ?? "null",
|
||||
CurrentEnrollmentPhase?.ToString() ?? "null");
|
||||
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(canonical));
|
||||
var sb = new StringBuilder(16);
|
||||
for (int i = 0; i < 8; i++) sb.Append(bytes[i].ToString("x2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
return HandleHelloSafetyDeadlineFired(state, signal);
|
||||
case DeadlineNames.DeviceOnlyEspDetection:
|
||||
return HandleDeviceOnlyEspDetectionDeadlineFired(state, signal);
|
||||
case DeadlineNames.ClassifierTick:
|
||||
return HandleClassifierTickDeadlineFired(state, signal);
|
||||
default:
|
||||
// Deadline name not recognized in this sub-milestone. Cancel it from state
|
||||
// and record a neutral taken transition — M3.3+ adds handlers for
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Engine
|
||||
{
|
||||
// WhiteGlove Part 1 handlers + classifier-verdict routing. Plan §2.5 / §2.4.
|
||||
public sealed partial class DecisionEngine
|
||||
{
|
||||
// Plan §2.6 — ClassifierTick recurrence. 30 s is the legacy polling cadence.
|
||||
internal static readonly TimeSpan s_classifierTickInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.WhiteGloveShellCoreSuccess"/>. Records the
|
||||
/// <see cref="DecisionState.ShellCoreWhiteGloveSuccessSeen"/> fact and emits a
|
||||
/// <see cref="DecisionEffectKind.RunClassifier"/> effect for the WhiteGlove-sealing
|
||||
/// classifier. Also arms the <see cref="DeadlineNames.ClassifierTick"/> deadline
|
||||
/// if not already armed.
|
||||
/// </summary>
|
||||
private DecisionStep HandleWhiteGloveShellCoreSuccessV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var nextStep = state.StepIndex + 1;
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||
builder.ShellCoreWhiteGloveSuccessSeen = new SignalFact<bool>(true, signal.SessionSignalOrdinal);
|
||||
|
||||
var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal);
|
||||
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: newState.Stage,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.WhiteGloveShellCoreSuccess));
|
||||
|
||||
return new DecisionStep(newState, transition, effects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.WhiteGloveSealingPatternDetected"/>. Signal-
|
||||
/// correlated WhiteGlove path (IME-pattern based). Records the fact and emits a
|
||||
/// <see cref="DecisionEffectKind.RunClassifier"/> effect.
|
||||
/// </summary>
|
||||
private DecisionStep HandleWhiteGloveSealingPatternDetectedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var nextStep = state.StepIndex + 1;
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||
builder.WhiteGloveSealingPatternSeen = new SignalFact<bool>(true, signal.SessionSignalOrdinal);
|
||||
|
||||
var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal);
|
||||
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: newState.Stage,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.WhiteGloveSealingPatternDetected));
|
||||
|
||||
return new DecisionStep(newState, transition, effects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.ClassifierVerdictIssued"/>. Produced by the
|
||||
/// effect runner after it executed a <see cref="DecisionEffectKind.RunClassifier"/>
|
||||
/// effect. The payload carries the verdict; the handler updates
|
||||
/// <see cref="DecisionState.WhiteGloveSealing"/> (plan §2.3 hypothesis) and, on
|
||||
/// <see cref="HypothesisLevel.Confirmed"/>, transitions to
|
||||
/// <see cref="SessionStage.WhiteGloveSealed"/> + <see cref="SessionOutcome.WhiteGlovePart1Sealed"/>
|
||||
/// and emits the <c>whiteglove_complete</c> event.
|
||||
/// </summary>
|
||||
private DecisionStep HandleClassifierVerdictIssuedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var p = signal.Payload ?? new Dictionary<string, string>();
|
||||
var classifier = GetPayload(p, "classifier", "unknown");
|
||||
var levelRaw = GetPayload(p, "level", HypothesisLevel.Unknown.ToString());
|
||||
var scoreRaw = GetPayload(p, "score", "0");
|
||||
var reason = GetPayload(p, "reason", classifier);
|
||||
var inputHash = GetPayload(p, "inputHash", string.Empty);
|
||||
|
||||
if (!Enum.TryParse<HypothesisLevel>(levelRaw, ignoreCase: true, out var level))
|
||||
{
|
||||
level = HypothesisLevel.Unknown;
|
||||
}
|
||||
if (!int.TryParse(scoreRaw, out var score))
|
||||
{
|
||||
score = 0;
|
||||
}
|
||||
|
||||
var nextStep = state.StepIndex + 1;
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||
|
||||
// Only the sealing classifier is wired in M3.3 — M3.4 adds Part 2.
|
||||
if (classifier == WhiteGloveSealingClassifier.ClassifierId)
|
||||
{
|
||||
builder.WhiteGloveSealing = state.WhiteGloveSealing.With(
|
||||
level: level,
|
||||
reason: reason,
|
||||
score: score,
|
||||
lastUpdatedUtc: signal.OccurredAtUtc,
|
||||
lastClassifierVerdictId: inputHash);
|
||||
|
||||
if (level == HypothesisLevel.Confirmed)
|
||||
{
|
||||
builder
|
||||
.WithStage(SessionStage.WhiteGloveSealed)
|
||||
.WithOutcome(SessionOutcome.WhiteGlovePart1Sealed)
|
||||
.ClearDeadlines();
|
||||
|
||||
var sealedState = builder.Build();
|
||||
var sealedTransition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: SessionStage.WhiteGloveSealed,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: $"ClassifierVerdictIssued:{classifier}:Confirmed");
|
||||
|
||||
var sealedEffects = new[]
|
||||
{
|
||||
new DecisionEffect(
|
||||
DecisionEffectKind.EmitEventTimelineEntry,
|
||||
parameters: new Dictionary<string, string> { ["eventType"] = "whiteglove_complete" }),
|
||||
};
|
||||
|
||||
return new DecisionStep(sealedState, sealedTransition, sealedEffects);
|
||||
}
|
||||
}
|
||||
|
||||
var newState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: state.Stage,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: $"ClassifierVerdictIssued:{classifier}:{level}");
|
||||
|
||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
// ============================================================ classifier-tick support
|
||||
|
||||
/// <summary>
|
||||
/// Classifier-tick deadline fired. Re-evaluates the WhiteGlove classifier with the
|
||||
/// current snapshot (picks up late-arriving facts the original handlers did not see)
|
||||
/// and re-arms itself unless a terminal stage has been reached.
|
||||
/// </summary>
|
||||
private DecisionStep HandleClassifierTickDeadlineFired(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var nextStep = state.StepIndex + 1;
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||
.CancelDeadline(DeadlineNames.ClassifierTick);
|
||||
|
||||
var effects = new List<DecisionEffect>
|
||||
{
|
||||
BuildRunClassifierEffect(state),
|
||||
};
|
||||
|
||||
var reachedTerminal = state.Stage == SessionStage.Completed
|
||||
|| state.Stage == SessionStage.Failed
|
||||
|| state.Stage == SessionStage.WhiteGloveSealed
|
||||
|| state.Stage == SessionStage.WhiteGloveCompletedPart2;
|
||||
|
||||
if (!reachedTerminal)
|
||||
{
|
||||
var rearm = BuildClassifierTickDeadline(signal.OccurredAtUtc);
|
||||
builder.AddDeadline(rearm);
|
||||
effects.Add(new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: rearm));
|
||||
}
|
||||
|
||||
var newState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: state.Stage,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: $"DeadlineFired:{DeadlineNames.ClassifierTick}");
|
||||
|
||||
return new DecisionStep(newState, transition, effects.ToArray());
|
||||
}
|
||||
|
||||
// ============================================================ internal helpers
|
||||
|
||||
/// <summary>
|
||||
/// Attach WhiteGlove-classifier effects (RunClassifier + optional ClassifierTick arm)
|
||||
/// to a just-built state. Shared between <see cref="HandleWhiteGloveShellCoreSuccessV1"/>
|
||||
/// and <see cref="HandleWhiteGloveSealingPatternDetectedV1"/>.
|
||||
/// </summary>
|
||||
private (DecisionState NewState, DecisionEffect[] Effects) AttachWhiteGloveClassifierEffects(
|
||||
DecisionState state,
|
||||
DecisionSignal triggerSignal)
|
||||
{
|
||||
var effects = new List<DecisionEffect> { BuildRunClassifierEffect(state) };
|
||||
|
||||
// Arm ClassifierTick on first WG-relevant signal if not already scheduled.
|
||||
var hasTick = false;
|
||||
foreach (var d in state.Deadlines)
|
||||
{
|
||||
if (d.Name == DeadlineNames.ClassifierTick) { hasTick = true; break; }
|
||||
}
|
||||
|
||||
if (!hasTick)
|
||||
{
|
||||
var tick = BuildClassifierTickDeadline(triggerSignal.OccurredAtUtc);
|
||||
state = state.ToBuilder().AddDeadline(tick).Build();
|
||||
effects.Add(new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: tick));
|
||||
}
|
||||
|
||||
return (state, effects.ToArray());
|
||||
}
|
||||
|
||||
private static DecisionEffect BuildRunClassifierEffect(DecisionState state) =>
|
||||
new DecisionEffect(
|
||||
kind: DecisionEffectKind.RunClassifier,
|
||||
classifierId: WhiteGloveSealingClassifier.ClassifierId,
|
||||
classifierSnapshot: BuildWhiteGloveSealingSnapshot(state));
|
||||
|
||||
private static ActiveDeadline BuildClassifierTickDeadline(DateTime fromUtc) =>
|
||||
new ActiveDeadline(
|
||||
name: DeadlineNames.ClassifierTick,
|
||||
dueAtUtc: fromUtc.Add(s_classifierTickInterval),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.ClassifierTick,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="WhiteGloveSealingSnapshot"/> from the current <see cref="DecisionState"/>.
|
||||
/// Exposed to the effect runner so the verdict snapshot carried with the
|
||||
/// <see cref="DecisionEffectKind.RunClassifier"/> effect is deterministic from state.
|
||||
/// </summary>
|
||||
internal static WhiteGloveSealingSnapshot BuildWhiteGloveSealingSnapshot(DecisionState state) =>
|
||||
new WhiteGloveSealingSnapshot(
|
||||
shellCoreWhiteGloveSuccessSeen: state.ShellCoreWhiteGloveSuccessSeen?.Value == true,
|
||||
whiteGloveSealingPatternSeen: state.WhiteGloveSealingPatternSeen?.Value == true,
|
||||
aadJoinedWithUser: state.AadJoinedWithUser?.Value == true,
|
||||
desktopArrived: state.DesktopArrivedUtc != null,
|
||||
helloResolved: state.HelloResolvedUtc != null,
|
||||
hasAccountSetupActivity: state.AccountSetupEnteredUtc != null,
|
||||
isDeviceOnlyDeploymentHypothesis:
|
||||
state.DeviceOnlyDeployment.Level >= HypothesisLevel.Strong &&
|
||||
state.DeviceOnlyDeployment.Reason == DeviceOnlyReasons.DeviceOnly,
|
||||
systemRebootUtc: state.SystemRebootUtc?.Value,
|
||||
currentEnrollmentPhase: state.CurrentEnrollmentPhase?.Value);
|
||||
|
||||
private static string GetPayload(IReadOnlyDictionary<string, string> p, string key, string fallback) =>
|
||||
p.TryGetValue(key, out var v) ? v : fallback;
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// ----- SelfDeploying + Device-Only (DecisionEngine.SelfDeploying.cs) -----
|
||||
(DecisionSignalKind.DeviceSetupProvisioningComplete, 1) => HandleDeviceSetupProvisioningCompleteV1(state, signal),
|
||||
|
||||
// ----- Not yet wired (WhiteGlove in M3.3/M3.4, …) -----
|
||||
// ----- WhiteGlove Part 1 (DecisionEngine.WhiteGlove.cs) -----
|
||||
(DecisionSignalKind.WhiteGloveShellCoreSuccess, 1) => HandleWhiteGloveShellCoreSuccessV1(state, signal),
|
||||
(DecisionSignalKind.WhiteGloveSealingPatternDetected, 1) => HandleWhiteGloveSealingPatternDetectedV1(state, signal),
|
||||
(DecisionSignalKind.ClassifierVerdictIssued, 1) => HandleClassifierVerdictIssuedV1(state, signal),
|
||||
|
||||
// ----- Not yet wired (WhiteGlove Part 2 in M3.4, edge in M3.5) -----
|
||||
_ => HandleUnhandledSignal(state, signal),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
SignalFact<string>? helloOutcome,
|
||||
SignalFact<bool>? aadJoinedWithUser,
|
||||
SignalFact<string>? imeMatchedPatternId,
|
||||
SignalFact<bool>? shellCoreWhiteGloveSuccessSeen,
|
||||
SignalFact<bool>? whiteGloveSealingPatternSeen,
|
||||
IReadOnlyList<ActiveDeadline> deadlines,
|
||||
long lastAppliedSignalOrdinal,
|
||||
int stepIndex,
|
||||
@@ -74,6 +76,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
HelloOutcome = helloOutcome;
|
||||
AadJoinedWithUser = aadJoinedWithUser;
|
||||
ImeMatchedPatternId = imeMatchedPatternId;
|
||||
ShellCoreWhiteGloveSuccessSeen = shellCoreWhiteGloveSuccessSeen;
|
||||
WhiteGloveSealingPatternSeen = whiteGloveSealingPatternSeen;
|
||||
Deadlines = deadlines ?? throw new ArgumentNullException(nameof(deadlines));
|
||||
LastAppliedSignalOrdinal = lastAppliedSignalOrdinal;
|
||||
StepIndex = stepIndex;
|
||||
@@ -123,6 +127,12 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
|
||||
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; }
|
||||
|
||||
public IReadOnlyList<ActiveDeadline> Deadlines { get; }
|
||||
|
||||
public long LastAppliedSignalOrdinal { get; }
|
||||
@@ -163,6 +173,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
helloOutcome: null,
|
||||
aadJoinedWithUser: null,
|
||||
imeMatchedPatternId: null,
|
||||
shellCoreWhiteGloveSuccessSeen: null,
|
||||
whiteGloveSealingPatternSeen: null,
|
||||
deadlines: Array.Empty<ActiveDeadline>(),
|
||||
lastAppliedSignalOrdinal: -1,
|
||||
stepIndex: 0);
|
||||
|
||||
@@ -38,6 +38,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
HelloOutcome = source.HelloOutcome;
|
||||
AadJoinedWithUser = source.AadJoinedWithUser;
|
||||
ImeMatchedPatternId = source.ImeMatchedPatternId;
|
||||
ShellCoreWhiteGloveSuccessSeen = source.ShellCoreWhiteGloveSuccessSeen;
|
||||
WhiteGloveSealingPatternSeen = source.WhiteGloveSealingPatternSeen;
|
||||
Deadlines = new List<ActiveDeadline>(source.Deadlines);
|
||||
LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
|
||||
StepIndex = source.StepIndex;
|
||||
@@ -63,6 +65,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
public SignalFact<string>? HelloOutcome { get; set; }
|
||||
public SignalFact<bool>? AadJoinedWithUser { get; set; }
|
||||
public SignalFact<string>? ImeMatchedPatternId { get; set; }
|
||||
public SignalFact<bool>? ShellCoreWhiteGloveSuccessSeen { get; set; }
|
||||
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; set; }
|
||||
public List<ActiveDeadline> Deadlines { get; set; }
|
||||
public long LastAppliedSignalOrdinal { get; set; }
|
||||
public int StepIndex { get; set; }
|
||||
@@ -130,6 +134,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
helloOutcome: HelloOutcome,
|
||||
aadJoinedWithUser: AadJoinedWithUser,
|
||||
imeMatchedPatternId: ImeMatchedPatternId,
|
||||
shellCoreWhiteGloveSuccessSeen: ShellCoreWhiteGloveSuccessSeen,
|
||||
whiteGloveSealingPatternSeen: WhiteGloveSealingPatternSeen,
|
||||
deadlines: Deadlines.ToArray(),
|
||||
lastAppliedSignalOrdinal: LastAppliedSignalOrdinal,
|
||||
stepIndex: StepIndex,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Scenario: Classifier Anti-Loop — same snapshot produces a single verdict
|
||||
# SealingPattern fires once (snapshot A -> classifier runs -> Weak). Two subsequent
|
||||
# ClassifierTick deadlines fire without any intervening fact change — the harness must
|
||||
# skip both (anti-loop via InputHash match). End state WhiteGloveSealing=Weak.
|
||||
# Expected: ClassifierRunStats["whiteglove-sealing:run"] == 1,
|
||||
# ClassifierRunStats["whiteglove-sealing:skipped_by_antiloop"] == 2.
|
||||
# Plan: §4 M3.3 — Classifier-Anti-Loop / §2.4 InputHash dedup.
|
||||
{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-antiloop:started","Summary":"Session start anchor"}}
|
||||
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}}
|
||||
{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"WhiteGloveSealingPatternDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:02:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"whiteglove-sealing-pattern-v1","Summary":"IME WG sealing pattern matched","DerivationInputs":{"patternId":"wg-sealing-v1"}}}
|
||||
{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:02:30Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:classifier_tick:fired:1","Summary":"Classifier tick 1 — same snapshot"},"Payload":{"deadline":"classifier_tick"}}
|
||||
{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:03:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:classifier_tick:fired:2","Summary":"Classifier tick 2 — same snapshot"},"Payload":{"deadline":"classifier_tick"}}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Scenario: WhiteGlove False-Positive defense — Late-AADJ rejects WG
|
||||
# SealingPattern gives +40 (Weak), then AadUserJoinedLate fires (hard excluder -100).
|
||||
# A ClassifierTick re-runs the classifier with the updated snapshot -> score capped to 0
|
||||
# -> Rejected. The session continues through the Classic UserDriven-v1 completion path,
|
||||
# ending in EnrollmentComplete (NOT WhiteGloveSealed).
|
||||
# Expected: Final Stage=Completed, Outcome=EnrollmentComplete,
|
||||
# WhiteGloveSealing.Level=Rejected.
|
||||
# Plan: §4 M3.3 — WG-False-Positive-Abwehr; project memory
|
||||
# feedback_aad_joined_late_not_completion (Late-AADJ never completes prematurely).
|
||||
{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-false-positive:started","Summary":"Session start anchor"}}
|
||||
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}}
|
||||
{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"WhiteGloveSealingPatternDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:02:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"whiteglove-sealing-pattern-v1","Summary":"IME WG sealing pattern matched","DerivationInputs":{"patternId":"wg-sealing-v1"}}}
|
||||
{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"AadUserJoinedLate","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:03:00Z","SourceOrigin":"AadJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"aad-join-watcher-v1","Summary":"AADJ observed with user present","DerivationInputs":{"user":"user-anon-0006"}},"Payload":{"aadJoinedWithUser":"true"}}
|
||||
{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:03:30Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:classifier_tick:fired:1","Summary":"Classifier tick re-runs after AadUserJoinedLate"},"Payload":{"deadline":"classifier_tick"}}
|
||||
{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:10:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"rawValue":"2"}},"Payload":{"phase":"AccountSetup"}}
|
||||
{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"EspExiting","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:13:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-exit-detector-v1","Summary":"ESP exit observed","DerivationInputs":{"rawValue":"exit"}}}
|
||||
{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"HelloResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:14:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:55","Summary":"Hello for Business enrolled"},"Payload":{"outcome":"Success"}}
|
||||
{"SessionSignalOrdinal":8,"SessionTraceOrdinal":8,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:15:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user","DerivationInputs":{"user":"user-anon-0006","explorerPid":"4245"}}}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Scenario: WhiteGlove Part 1 — Inline Classifier (ShellCore success alone is decisive)
|
||||
# ShellCoreWhiteGloveSuccess (Event 62407) fires; classifier sees one strong positive
|
||||
# signal, no hard-excluders -> score 80 -> Confirmed -> WhiteGloveSealed.
|
||||
# Expected: Stage=WhiteGloveSealed, Outcome=WhiteGlovePart1Sealed,
|
||||
# WhiteGloveSealing.Level=Confirmed (score >= 70).
|
||||
# Plan: §4 M3.3 — WG-Inline-Classifier.
|
||||
{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-inline:started","Summary":"Session start anchor"}}
|
||||
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T09:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}}
|
||||
{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"WhiteGloveShellCoreSuccess","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T09:02:00Z","SourceOrigin":"ShellCoreTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62407:recordId:104","Summary":"Shell Core white-glove success (Event 62407)","RawPointer":"blob://events/shell-core/104"}}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Scenario: WhiteGlove Part 1 — Signal-correlated (sealing-pattern + ShellCore together)
|
||||
# SealingPattern alone scores 40 (Weak). A subsequent ShellCoreWhiteGloveSuccess changes the
|
||||
# snapshot so the classifier re-runs (not anti-loop-skipped) and hits the confirmed threshold.
|
||||
# Between the two WG signals a ClassifierTick fires with no new facts — proves the tick is
|
||||
# safely idempotent under anti-loop.
|
||||
# Expected: Stage=WhiteGloveSealed after ShellCore step. Classifier runs twice (SealingPattern
|
||||
# then ShellCore); one ClassifierTick is anti-loop-skipped.
|
||||
# Plan: §4 M3.3 — WG-Signal-Correlated.
|
||||
{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-signal-correlated:started","Summary":"Session start anchor"}}
|
||||
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}}
|
||||
{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"WhiteGloveSealingPatternDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:02:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"whiteglove-sealing-pattern-v1","Summary":"IME WG sealing pattern matched","DerivationInputs":{"patternId":"wg-sealing-v1","imeLogLineHash":"0xabcd"}}}
|
||||
{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:02:30Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:classifier_tick:fired:1","Summary":"Classifier tick fired (no snapshot change)"},"Payload":{"deadline":"classifier_tick"}}
|
||||
{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"WhiteGloveShellCoreSuccess","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:05:00Z","SourceOrigin":"ShellCoreTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62407:recordId:201","Summary":"Shell Core white-glove success (Event 62407)","RawPointer":"blob://events/shell-core/201"}}
|
||||
Reference in New Issue
Block a user