diff --git a/docs/agent/cmtrace-time-resolution.md b/docs/agent/cmtrace-time-resolution.md index b8ea4080..e877fe95 100644 --- a/docs/agent/cmtrace-time-resolution.md +++ b/docs/agent/cmtrace-time-resolution.md @@ -94,7 +94,11 @@ batch, the counter reconcile's existing event-partition scan additionally collec rest (`CmTraceSkewTripwire`, hooked in `EventIngestProcessor`). If `median(Δ_IME) − median(Δ_other)` is a clean 15-minute multiple (residual < 2 min — the same constants as the anchoring grid guard above) with ≥ 20 samples over ≥ 3 distinct -upload batches per side, a `CmTraceTimeSkewRegression` ops event fires (category Agent, +upload batches per side, and ≥ 80 % of the individual IME deltas (relative to the other +side's median) themselves sit within 2 min of some grid multiple — added after the 2026-08-28 +soak, when relaunched agents re-tailing the IME log produced one burst holding most of a +session's IME samples with a continuum of ages whose median hit the grid by chance (7 false +fires in a week, 0 true) — a `CmTraceTimeSkewRegression` ops event fires (category Agent, warning), carrying all numbers plus the session's `sourceOffsetOrigin` histogram. Bias-dominated sessions are suppressed (writer-declared offsets cannot be an anchoring regression); `measuredWriterOffsetMinutes` is never consulted (sticky after era diff --git a/docs/log.md b/docs/log.md index 6ad30b8d..ca041513 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-08-28 + +* **Update**: `CmTraceSkewTripwire` - the backend regression tripwire additionally requires >= 80 % of the individual IME deltas (relative to the other side's median) to sit within 2 min of some 15-minute grid multiple before `CmTraceTimeSkewRegression` fires; the ops event and its details now carry that fraction. Motivation: the first soak week produced 7 fires, all backlog replays - a relaunched agent re-tailing the IME log uploads one burst holding most of the session's IME samples with a continuum of ages whose median hit the grid by chance (session d832560a: 508 of 627 samples, 46...733 min, median 91). Era-mixing regressions (every line on the grid) still fire. Doc: `docs/agent/cmtrace-time-resolution.md`. + ## 2026-08-27 (2) * **Update**: `SecurityValidator` / `DeviceAssociationValidator` - Microsoft released Windows Autopilot device association on 2026-08-25 (learn.microsoft.com/autopilot/device-preparation/device-association/overview: TPM-backed UEFI marker, device-targeted device preparation policies, associated devices are marked corporate-owned by Intune with no corporate identifier upload). The DevPrep association lookup therefore leaves shadow mode and becomes the third accepting method of the device-validation gate, evaluated inline after the Autopilot and Corporate Identifier lookups and before the Cloud PC fallback; a match sets `ValidatedBy = DeviceAssociation`, a miss joins the combined 403 detail, a transient failure the 503 Retry-After - the same contract as its siblings, so enabling it can only widen acceptance, never reject a device the other methods would have taken. `ValidateDeviceAssociation` counts toward "at least one validation enabled" and is no longer GA-only anywhere (`UpdateTenantConfigurationFunction` revert, `TenantConfigPatchService.GaOnlyFields`, the Global-Admin gate on the settings toggle); the toggle sits under Device Preparation next to Corporate Identifier and follows the shared consent rule (first serial-based validation carries the consent, any further one is a plain persisted toggle). Known gap carried over from the shadow phase: the `/beta/deviceManagement/tenantAssociatedDevices` endpoint is still undocumented in Graph reference and every production lookup so far answered `matched=False` for Autopilot v1 devices, i.e. the positive match path is unproven in the field - first associated enrollment must be checked for `ValidatedBy = DeviceAssociation` on the session row. Landing hero carries a release pill linking to the customer docs; dashboard tip added. Backend before web; customer docs updated in `autopilotmonitor-docs`. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CmTraceSkewTripwireTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CmTraceSkewTripwireTests.cs index 7e059b2c..b0b9f066 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/CmTraceSkewTripwireTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CmTraceSkewTripwireTests.cs @@ -119,6 +119,53 @@ public class CmTraceSkewTripwireTests Assert.Null(CmTraceSkewTripwire.Evaluate(scan)); } + [Fact] + public void Silent_OnBacklogReplayBurstWhoseMedianLandsOnGrid() + { + // Field case 2026-08-28 (session d832560a): the agent died on auth failures, the relaunch + // re-tailed the IME log and uploaded 508 lines in one burst with a continuum of ages + // (46…733 min, quartiles 52 / 92 / 100) — median 91 min, i.e. 6×15 by pure chance. + // The other side was live. The per-sample grid-conformity guard must keep this silent. + var scan = MakeScan(imeDeltaMinutes: 0.1, otherDeltaMinutes: 0.1, imeSamples: 100, imeBatches: 80); + void AddRamp(double from, double to, int count) + { + for (int i = 0; i < count; i++) + scan.ImeDeltaMinutes.Add(from + i * (to - from) / count); + } + AddRamp(46.0, 88.0, 125); + AddRamp(88.0, 100.0, 250); + AddRamp(100.0, 733.0, 125); + + var medianIme = scan.ImeDeltaMinutes.OrderBy(v => v).ElementAt(scan.ImeDeltaMinutes.Count / 2); + Assert.InRange(medianIme, 88.0, 92.0); // sanity: the median really does sit near 6×15 + + Assert.Null(CmTraceSkewTripwire.Evaluate(scan)); + Assert.True(CmTraceSkewTripwire.ComputeGridConformantFraction(scan.ImeDeltaMinutes, 0.1) < 0.5); + } + + [Fact] + public void Fires_OnTwoWriterErasBothOnGrid() + { + // e9753578 shape: one file mixing PDT (−420) and CEST (+120) lines relative to the agent + // zone. Every line is individually on the grid, so the conformity guard must NOT mask it. + var scan = MakeScan(imeDeltaMinutes: -420.0, otherDeltaMinutes: 0.0, imeSamples: 65); + for (int i = 0; i < 35; i++) + scan.ImeDeltaMinutes.Add(120.0); + + var result = CmTraceSkewTripwire.Evaluate(scan); + Assert.NotNull(result); + Assert.Equal(-28, result!.GridSteps); + Assert.Equal(1.0, result.GridConformantFraction, 3); + } + + [Fact] + public void GridConformantFraction_CountsZeroAndAnyMultiple() + { + var deltas = new List { 0.5, 15.9, -30.1, 7.5, 37.5 }; // last two are off-grid + Assert.Equal(0.6, CmTraceSkewTripwire.ComputeGridConformantFraction(deltas, 0.0), 3); + Assert.Equal(0, CmTraceSkewTripwire.ComputeGridConformantFraction(new List(), 0.0)); + } + // ── Bias suppression predicate ───────────────────────────────────────── [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions/Services/CmTraceSkewTripwire.cs b/src/Backend/AutopilotMonitor.Functions/Services/CmTraceSkewTripwire.cs index 83b2c588..b155ca63 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/CmTraceSkewTripwire.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/CmTraceSkewTripwire.cs @@ -15,6 +15,12 @@ namespace AutopilotMonitor.Functions.Services /// mis-conversion shifts only the IME side and lands on the 15-minute offset grid. /// Replayed old log lines produce large OFF-grid deltas — the grid-residual test is what /// separates the two (field measurement 2026-08-20: without it, 48 instead of 26 true hits). + /// The median alone is not enough, though: when a dead/relaunched agent re-tails the IME log, + /// one upload burst can carry the majority of the session's IME samples with a CONTINUUM of + /// ages (field 2026-08-28, session d832560a: 508 of 627 samples, 46…733 min) whose median + /// lands on the grid by chance. A genuine zone mis-conversion shifts every line by the same + /// grid multiple, so the grid-conformity test is applied per sample as well: the session + /// only counts when the bulk of the IME deltas individually sit on the grid. /// /// Goal state: this never fires. Any hit is either a case the per-line anchoring does not /// cover or a detector bug — both are actionable findings, never noise to tune away. @@ -30,6 +36,11 @@ namespace AutopilotMonitor.Functions.Services // ReceivedAt is stamped per upload batch, not per event; a side backed by fewer // distinct batches yields a batch-composition artifact, not a session property. internal const int MinDistinctBatchesPerSide = 3; + // Share of IME samples whose own delta (relative to the other side's median) must sit + // within ResidualToleranceMinutes of SOME grid multiple (zero included). A replay + // continuum hits that window only ~2·2/15 ≈ 27 % of the time by chance; a real skew, + // even one mixing two writer eras, hits it for essentially every line. + internal const double MinGridConformantFraction = 0.8; internal sealed class Result { @@ -42,6 +53,7 @@ namespace AutopilotMonitor.Functions.Services public int OtherSampleCount { get; set; } public int ImeBatchCount { get; set; } public int OtherBatchCount { get; set; } + public double GridConformantFraction { get; set; } } /// @@ -67,6 +79,10 @@ namespace AutopilotMonitor.Functions.Services if (Math.Abs(steps) < 1 || residual >= ResidualToleranceMinutes) return null; + var conformant = ComputeGridConformantFraction(scan.ImeDeltaMinutes, medianOther); + if (conformant < MinGridConformantFraction) + return null; + return new Result { MedianImeDeltaMinutes = medianIme, @@ -78,9 +94,31 @@ namespace AutopilotMonitor.Functions.Services OtherSampleCount = scan.OtherDeltaMinutes.Count, ImeBatchCount = scan.ImeDistinctBatchCount, OtherBatchCount = scan.OtherDistinctBatchCount, + GridConformantFraction = conformant, }; } + /// + /// Fraction of samples whose delta minus lies within + /// of any multiple of + /// (zero included). + /// + internal static double ComputeGridConformantFraction(IReadOnlyList deltas, double reference) + { + if (deltas.Count == 0) + return 0; + + int hits = 0; + foreach (var delta in deltas) + { + var rel = delta - reference; + var nearest = Math.Round(rel / GridMinutes, MidpointRounding.AwayFromZero) * GridMinutes; + if (Math.Abs(rel - nearest) < ResidualToleranceMinutes) + hits++; + } + return (double)hits / deltas.Count; + } + /// /// True when the session's IME events are dominated by writer-declared ("bias") /// offsets — a bias offset comes verbatim from the log line's own zone marker and diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs index 4e58a473..c8ea6eea 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs @@ -596,7 +596,7 @@ namespace AutopilotMonitor.Functions.Services histogram.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Key} {kv.Value}")); var message = $"IME-derived events skewed {result.DiffMinutes:+0.0;-0.0} min vs other sources " + - $"({result.GridSteps}x15min grid, residual {result.ResidualMinutes:0.0} min; " + + $"({result.GridSteps}x15min grid, residual {result.ResidualMinutes:0.0} min, {result.GridConformantFraction:P0} of IME samples on grid; " + $"median dIME {result.MedianImeDeltaMinutes:0.0} / dOther {result.MedianOtherDeltaMinutes:0.0} min; " + $"{result.ImeSampleCount}/{result.OtherSampleCount} samples over {result.ImeBatchCount}/{result.OtherBatchCount} batches; " + $"origins: {(origins.Length > 0 ? origins : "none")})"; @@ -608,6 +608,7 @@ namespace AutopilotMonitor.Functions.Services diffMinutes = Math.Round(result.DiffMinutes, 2), gridSteps = result.GridSteps, residualMinutes = Math.Round(result.ResidualMinutes, 2), + gridConformantFraction = Math.Round(result.GridConformantFraction, 3), medianImeDeltaMinutes = Math.Round(result.MedianImeDeltaMinutes, 2), medianOtherDeltaMinutes = Math.Round(result.MedianOtherDeltaMinutes, 2), imeSampleCount = result.ImeSampleCount,