From 15e60889d35d5087534f84d366e67db8ab5f39c0 Mon Sep 17 00:00:00 2001 From: xping-admin Date: Mon, 7 Sep 2026 15:10:04 +0200 Subject: [PATCH] fix(cli): count a test's blocking rate in sessions, not retry attempts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BlockingRateOf divided blocking failures by failed executions. Both counts were per attempt, so a retry-masked session — one the test failed several times and then passed — put several failures in the denominator and none in the numerator. Four failed attempts in one green session and one failure in a red one scored 0.20 where the occasions say 0.50: the more a test retried, the less blocking it looked, and it ranked below a test that fails once per build. The term carries 0.20 of the impact weight and sits directly beneath RunFrequencyOf, which #176 already moved to sessions, so the two disagreed about their unit inside one scorer. The numerator was also asking the wrong question. _sessionsWithFinalFailures is session-wide — true when any test ended the session red — so a test whose every failure was masked scored 1.00 as long as a neighbour failed finally in the same session, the opposite of the separation the method's own remark claims. Both counts are now sessions: the sessions the test ended red, read off its deciding attempt via RunsOf, over the sessions it failed in at all. A failed run implies a failed attempt in that session, so the ratio needs no clamping. _sessionsWithFinalFailures and its only feeder, SessionOutcomes.HasFinalFailure, are removed. Closes #181. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TMQcZLBGFTd8wz3mcfn9vc --- .../Report/Indexes/SessionOutcomes.cs | 16 --- src/Xping.Cli/Report/Indexes/TestIndex.cs | 53 ++++++--- .../Xping.Cli.Tests/Report/TestIndexTests.cs | 106 +++++++++++++++++- 3 files changed, 143 insertions(+), 32 deletions(-) diff --git a/src/Xping.Cli/Report/Indexes/SessionOutcomes.cs b/src/Xping.Cli/Report/Indexes/SessionOutcomes.cs index a6fcc6d..7f570b1 100644 --- a/src/Xping.Cli/Report/Indexes/SessionOutcomes.cs +++ b/src/Xping.Cli/Report/Indexes/SessionOutcomes.cs @@ -39,22 +39,6 @@ public static (int Tests, int Failures) Tally(TestSession session) return (tests, failures); } - /// - /// Returns whether a session ended with at least one test failing on its final attempt. - /// - /// The session to inspect. - /// when the session ended red. - public static bool HasFinalFailure(TestSession session) - { - foreach (var outcome in FinalOutcomes(session).Values) - { - if (outcome.Outcome.IsFailure()) - return true; - } - - return false; - } - /// /// Reduces a session to one outcome per test, taken from its last attempt. /// diff --git a/src/Xping.Cli/Report/Indexes/TestIndex.cs b/src/Xping.Cli/Report/Indexes/TestIndex.cs index 4ff42e9..a329e61 100644 --- a/src/Xping.Cli/Report/Indexes/TestIndex.cs +++ b/src/Xping.Cli/Report/Indexes/TestIndex.cs @@ -43,7 +43,6 @@ internal sealed class TestIndex private readonly Dictionary _sessionsRunIn; private readonly Dictionary _references; private readonly Dictionary _sessionPositions; - private readonly HashSet _sessionsWithFinalFailures; private TestIndex( AnalysisWindow window, @@ -52,7 +51,6 @@ private TestIndex( Dictionary sessionsRunIn, Dictionary references, Dictionary sessionPositions, - HashSet sessionsWithFinalFailures, IReadOnlyList fingerprints) { Window = window; @@ -61,7 +59,6 @@ private TestIndex( _sessionsRunIn = sessionsRunIn; _references = references; _sessionPositions = sessionPositions; - _sessionsWithFinalFailures = sessionsWithFinalFailures; Fingerprints = fingerprints; } @@ -201,30 +198,61 @@ public double RunFrequencyOf(string fingerprint) } /// - /// Gets the fraction of a test's failures that landed in a session that ended up failing. + /// Gets the fraction of the sessions a test failed in that it ended red. /// /// The test to measure. /// A value in [0,1]; zero when the test never failed. /// + /// /// Separates a test that fails and blocks the build from one whose failures are always masked by /// a retry. Both are worth fixing; only the first is stopping anyone today. + /// + /// + /// Both counts are sessions, for the reason gives: a retried run + /// records an execution per attempt, so counting attempts put every masked failure in the + /// denominator and none in the numerator, and a test that failed four times in one green session + /// and once in a red one scored 0.20 where the occasions say 0.50. The bias ran hardest against + /// the tests the retry findings report, and it made this term disagree about its unit with the + /// one immediately above it in the same scorer. + /// + /// + /// Blocking means this test ended the session red, read off its deciding attempt via + /// . Asking instead whether the session ended red — as this did — credited a + /// test whose every failure was masked with blocking the build whenever some other test failed + /// finally in the same session, which is the opposite of the separation above. + /// /// public double BlockingRateOf(string fingerprint) { - int failures = 0; - int blocking = 0; + int failedSessions = 0; + int lastFailedSession = -1; + // Executions arrive grouped by session, so a session is new to the tally only when the last + // failure counted came from a different one. The same walk Build takes, and for the same + // reason: a retried run must not count as several occasions. foreach (ExecutionRef reference in ExecutionsOf(fingerprint)) { - if (!reference.Failed) + if (!reference.Failed || reference.SessionIndex == lastFailedSession) continue; - failures++; - if (_sessionsWithFinalFailures.Contains(reference.Session.SessionId)) + failedSessions++; + lastFailedSession = reference.SessionIndex; + } + + if (failedSessions == 0) + return 0; + + int blocking = 0; + + foreach (ExecutionRef run in RunsOf(fingerprint)) + { + if (run.Failed) blocking++; } - return failures == 0 ? 0 : (double)blocking / failures; + // A run that failed is a session the test failed in, so the numerator cannot outrun the + // denominator and the ratio needs no clamping. + return (double)blocking / failedSessions; } /// @@ -313,7 +341,6 @@ public static TestIndex Build(AnalysisWindow window) var sessionsRunIn = new Dictionary(StringComparer.Ordinal); var references = new Dictionary(StringComparer.Ordinal); var sessionPositions = new Dictionary(); - var sessionsWithFinalFailures = new HashSet(); for (int position = 0; position < window.Sessions.Count; position++) { @@ -365,9 +392,6 @@ public static TestIndex Build(AnalysisWindow window) if (!references.ContainsKey(fingerprint)) references[fingerprint] = ToReference(execution); } - - if (SessionOutcomes.HasFinalFailure(session)) - sessionsWithFinalFailures.Add(session.SessionId); } var fingerprints = byFingerprint.Keys.OrderBy(f => f, StringComparer.Ordinal).ToList(); @@ -379,7 +403,6 @@ public static TestIndex Build(AnalysisWindow window) sessionsRunIn, references, sessionPositions, - sessionsWithFinalFailures, fingerprints); } diff --git a/tests/Xping.Cli.Tests/Report/TestIndexTests.cs b/tests/Xping.Cli.Tests/Report/TestIndexTests.cs index b2a37fe..d9abb24 100644 --- a/tests/Xping.Cli.Tests/Report/TestIndexTests.cs +++ b/tests/Xping.Cli.Tests/Report/TestIndexTests.cs @@ -175,7 +175,7 @@ public void ARunAgreesWithSessionOutcomesWhenAttemptsArriveOutOfOrder() TestIndex.Build(TestSessionFactory.Window(session)).RunsOf(SubjectFingerprint)); Assert.True(run.Failed); - Assert.True(SessionOutcomes.HasFinalFailure(session)); + Assert.Equal((1, 1), SessionOutcomes.Tally(session)); } [Fact] @@ -196,6 +196,110 @@ public void RunsAreOrderedNewestSessionFirstLikeExecutions() Assert.Equal(Enumerable.Range(0, 8), runs.Select(r => r.SessionIndex)); } + // --------------------------------------------------------------------------------------- + // Blocking rate + // --------------------------------------------------------------------------------------- + + /// + /// Builds one session in which took attempts + /// and ended the way says. + /// + /// The session's position; 0 is the oldest. + /// How many attempts the subject took. + /// The outcome of the deciding attempt; every earlier one failed. + /// A second test that ends the session red on its only attempt. + private static TestSession Attempted( + int ordinal, + int attempts, + TestOutcome ends, + bool alsoFailing = false) + { + var executions = new List(); + + for (int attempt = 1; attempt <= attempts; attempt++) + { + bool deciding = attempt == attempts; + + executions.Add(TestSessionFactory.Execution( + Subject, + outcome: deciding ? ends : TestOutcome.Failed, + attempt: attempt, + passedOnRetry: deciding && ends == TestOutcome.Passed && attempts > 1, + maxRetries: attempts - 1, + errorMessage: deciding && ends == TestOutcome.Passed ? null : "boom")); + } + + executions.Add(alsoFailing + ? TestSessionFactory.Execution("Neighbour", TestOutcome.Failed, errorMessage: "boom") + : TestSessionFactory.Execution("Neighbour")); + + return TestSessionFactory.Session(ordinal, executions); + } + + [Fact] + public void BlockingRateCountsSessionsRatherThanAttempts() + { + // The bug this pins: four failed attempts in one session that ended green and one failure in + // a session that ended red is five failed executions of which one blocked — 0.20 — where the + // occasions say one of two, 0.50. Counting attempts made a test look less blocking the more + // it retried, which is the opposite of the truth and worst for the tests the retry findings + // already report. + TestIndex index = TestIndex.Build(TestSessionFactory.Window( + Attempted(0, attempts: 5, ends: TestOutcome.Passed), + Attempted(1, attempts: 1, ends: TestOutcome.Failed))); + + Assert.Equal(0.50, index.BlockingRateOf(SubjectFingerprint), 3); + } + + [Fact] + public void AMaskedFailureIsNotBlockingEvenWhenAnotherTestFailedTheSession() + { + // Blocking has to mean this test ended the session red. Asking whether the session ended red + // credited a test whose every failure was masked with blocking the build whenever a + // neighbour failed finally in the same session. + TestIndex index = TestIndex.Build(TestSessionFactory.Window( + Attempted(0, attempts: 3, ends: TestOutcome.Passed, alsoFailing: true), + Attempted(1, attempts: 2, ends: TestOutcome.Passed, alsoFailing: true))); + + Assert.Equal(0, index.BlockingRateOf(SubjectFingerprint)); + } + + [Fact] + public void ATestThatEndsEverySessionRedBlocksEveryTimeItFails() + { + TestIndex index = TestIndex.Build(TestSessionFactory.Window( + Attempted(0, attempts: 1, ends: TestOutcome.Failed), + Attempted(1, attempts: 3, ends: TestOutcome.Failed))); + + Assert.Equal(1.0, index.BlockingRateOf(SubjectFingerprint)); + } + + [Fact] + public void ATimeoutCountsAsAFailureAndAsABlock() + { + // Failure is TestOutcome.Failed or TestOutcome.Timeout, and both counts read it the same way. + TestIndex index = TestIndex.Build(TestSessionFactory.Window( + Attempted(0, attempts: 1, ends: TestOutcome.Timeout))); + + Assert.Equal(1.0, index.BlockingRateOf(SubjectFingerprint)); + } + + [Fact] + public void BlockingRateIsZeroForATestThatNeverFailed() + { + TestIndex index = TestIndex.Build(Window(total: 6, presentIn: 6, attempts: 1)); + + Assert.Equal(0, index.BlockingRateOf(SubjectFingerprint)); + } + + [Fact] + public void BlockingRateIsZeroForAFingerprintTheWindowNeverSaw() + { + TestIndex index = TestIndex.Build(Window(total: 6, presentIn: 6, attempts: 2)); + + Assert.Equal(0, index.BlockingRateOf("fp-NeverRan")); + } + // --------------------------------------------------------------------------------------- // Recency // ---------------------------------------------------------------------------------------