From 97fd567883f191d3635b6450f643cefbadd94f0d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:44:26 -0400 Subject: [PATCH] Make the node row label agree with its percentage (#611) The label printed both row counts N0 but took the percentage from the unrounded values, so a Key Lookup that ran 117 times for 1 row read "1 of 1 (89%)". PlanRowAccuracy, ported from PerformanceMonitor's PlanRowAccuracy (#4684), adds the fewest decimals (up to 4, fixed-point) at which the printed numbers give the printed percentage, and never prints a non-zero value as 0. That label now reads "1 of 1.128 (89%)". The App and Web node labels and the HTML export all use it, so all three print the same numbers, and PerformanceMonitor prints the same string for the same plan. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019tS6P95Dtzs4aeMpb1xqzX --- .../Controls/PlanViewerControl.Rendering.cs | 8 +- src/PlanViewer.Core/Output/HtmlExporter.cs | 8 +- .../Services/PlanRowAccuracy.cs | 110 +++++++ src/PlanViewer.Web/Pages/Index.razor | 6 +- src/PlanViewer.Web/PlanViewer.Web.csproj | 1 + .../PlanRowAccuracyTests.cs | 270 ++++++++++++++++++ 6 files changed, 392 insertions(+), 11 deletions(-) create mode 100644 src/PlanViewer.Core/Services/PlanRowAccuracy.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanRowAccuracyTests.cs diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs index 4f7cb4b..2c52595 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs @@ -300,15 +300,13 @@ private Border CreateNodeVisual(PlanNode node, double divergenceLimit, int total // to compare fairly against the summed ActualRows. Everywhere else — including a // parallel zone, where ActualExecutions just counts threads — the estimate stays // per-execution. RowEstimateHelper is the one place that decides which applies. - var expectedRows = RowEstimateHelper.GetExpectedRows(node); + // #611: PlanRowAccuracy adds decimals where N0 would print numbers that contradict + // the percentage ("1 of 1 (89%)"). var accuracyRatio = RowEstimateHelper.GetRowAccuracyRatio(node); IBrush rowBrush = (accuracyRatio < 1.0 / divergenceLimit || accuracyRatio > divergenceLimit) ? OrangeRedBrush : fgBrush; - var accuracy = expectedRows > 0 - ? $" ({accuracyRatio * 100:F0}%)" - : ""; stack.Children.Add(new TextBlock { - Text = $"{node.ActualRows:N0} of {expectedRows:N0}{accuracy}", + Text = PlanRowAccuracy.FormatActualOfExpected(node.ActualRows, RowEstimateHelper.GetExpectedRows(node)), FontSize = 10, Foreground = rowBrush, TextAlignment = TextAlignment.Center, diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 4aad7cc..0bc20df 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Text; using System.Web; +using PlanViewer.Core.Services; namespace PlanViewer.Core.Output; @@ -573,10 +574,11 @@ private static void WriteOperatorLine(StringBuilder sb, OperatorResult node) if (node.ActualRows.HasValue) { // #594: the same execution-aware estimate the plan viewer's node label shows. + // #611: and the same printed numbers, so they agree with the percentage. var est = node.ExpectedRows ?? node.EstimatedRows; - var ratio = est > 0 ? (double)node.ActualRows.Value / est : 0; - var accuracy = est > 0 ? $" ({ratio * 100:F0}%)" : ""; - sb.Append($" {node.ActualRows.Value:N0} of {est:N0} rows{accuracy}"); + var (actualText, expectedText, percent) = PlanRowAccuracy.PrintActualOfExpected(node.ActualRows.Value, est); + var accuracy = percent != null ? $" ({percent}%)" : ""; + sb.Append($" {actualText} of {expectedText} rows{accuracy}"); } else { diff --git a/src/PlanViewer.Core/Services/PlanRowAccuracy.cs b/src/PlanViewer.Core/Services/PlanRowAccuracy.cs new file mode 100644 index 0000000..1e3ca73 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanRowAccuracy.cs @@ -0,0 +1,110 @@ +using System; +using System.Globalization; + +namespace PlanViewer.Core.Services; + +/// +/// The plan viewer's node row line: the rows an operator actually returned against the rows it was +/// expected to, printed as "{actual} of {expected} ({percent}%)". The expected figure is +/// , a total like ActualRows (#594). Shared by +/// the App and Web node labels and the HTML export (#611). PerformanceMonitor's plan viewer ports +/// the same rule (its PlanRowAccuracy, #4684), so both apps print the same string for the +/// same plan. +/// +public static class PlanRowAccuracy +{ + /// The most decimals adds to make a label agree with its percentage. + private const int MaxAgreementDecimals = 4; + + /// + /// The node row line: "{actual} of {expected} ({percent}%)", or just "{actual} of {expected}" + /// when is not above zero. See for the rule. + /// A Key Lookup that ran 117 times for 1 row (estimate 0.00964372 each) reads 1 of 1.128 (89%), not the + /// 1 of 1 (89%) that printing both counts N0 gave. + /// + public static string FormatActualOfExpected(double actualRows, double expectedRows, IFormatProvider? provider = null) + { + var (actual, expected, percent) = PrintActualOfExpected(actualRows, expectedRows, provider); + return percent == null + ? string.Concat(actual, " of ", expected) + : string.Concat(actual, " of ", expected, " (", percent, "%)"); + } + + /// + /// The two row counts and the whole percentage that joins, for a surface + /// that words the line its own way (the HTML export adds "rows"). The rule, word for word: + /// Print N0. If the whole percent computed from the printed numbers differs from the printed percentage, add the + /// fewest decimals (fixed-point, never scientific, capped at 4) at which they agree. A non-zero value never prints as + /// zero. If it would, print it fixed-point to its first significant digit. + /// + /// The percentage is actualRows / expectedRows * 100 to a whole number, from the unrounded values. It is + /// null when is not above zero (there is nothing to divide by). + /// The agreement search runs from 0 to 4 decimals. At each count a whole number prints N0 and any other + /// number prints N plus that count. The text is parsed back (group separator allowed, so "2,983" reads as + /// 2983) and the search stops at the first count where the whole percent of the printed numbers equals the printed + /// percentage. A printed divisor of zero never agrees. At 4 it stops either way, so a value like 0.00012345 can + /// print a line that still contradicts its percentage: the cap wins. + /// A non-zero value whose text would read as zero is reprinted with as many decimals as it takes to show its + /// first significant digit (0.000005 prints "0.000005"); the cap does not apply to that. + /// Only N formats print the row counts, so no magnitude prints an exponent. Culture follows + /// , the current culture by default. + /// + /// + public static (string Actual, string Expected, string? Percent) PrintActualOfExpected( + double actualRows, double expectedRows, IFormatProvider? provider = null) + { + provider ??= CultureInfo.CurrentCulture; + + var percent = expectedRows > 0 ? WholePercent(actualRows, expectedRows, provider) : null; + + var actualText = ""; + var expectedText = ""; + for (var decimals = 0; decimals <= MaxAgreementDecimals; decimals++) + { + actualText = PrintRows(actualRows, decimals, provider); + expectedText = PrintRows(expectedRows, decimals, provider); + + if (percent == null || PrintedNumbersGive(percent, actualText, expectedText, provider)) + break; + } + + return (actualText, expectedText, percent); + } + + private static string WholePercent(double actualRows, double expectedRows, IFormatProvider provider) + => (actualRows / expectedRows * 100).ToString("F0", provider); + + /// + /// One row count as text: a whole number is always N0, any other number N{decimals}. A non-zero + /// value that would print as zero is reprinted to its first significant digit instead. + /// + private static string PrintRows(double value, int decimals, IFormatProvider provider) + { + var text = value.ToString(Math.Floor(value) == value ? "N0" : NFormat(decimals), provider); + + if (value != 0 && double.IsFinite(value) + && double.TryParse(text, NumberStyles.Number, provider, out var printed) && printed == 0) + { + var firstSignificantDecimals = -(int)Math.Floor(Math.Log10(Math.Abs(value))); + text = value.ToString(NFormat(firstSignificantDecimals), provider); + } + + return text; + } + + /// True when the whole percent of the two printed numbers is the printed percentage. + private static bool PrintedNumbersGive(string percent, string actualText, string expectedText, IFormatProvider provider) + { + if (!double.TryParse(actualText, NumberStyles.Number, provider, out var printedActual) + || !double.TryParse(expectedText, NumberStyles.Number, provider, out var printedExpected)) + { + return false; + } + + // A printed divisor of zero gives NaN or Infinity, which never equals a percentage. + var printedPercent = printedActual / printedExpected * 100; + return double.IsFinite(printedPercent) && printedPercent.ToString("F0", provider) == percent; + } + + private static string NFormat(int decimals) => string.Create(CultureInfo.InvariantCulture, $"N{decimals}"); +} diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 7637a05..db6eafb 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -538,13 +538,13 @@ else // #594: EstimateRows is per execution — RowEstimateHelper scales it by // ActualExecutions only on the inner side of a Nested Loops join, where that count is // real rather than a parallel zone's thread count. - var expectedRows = RowEstimateHelper.GetExpectedRows(node); + // #611: PlanRowAccuracy adds decimals where N0 would print numbers that contradict + // the percentage ("1 of 1 (89%)"). var ratio = RowEstimateHelper.GetRowAccuracyRatio(node); var rowClass = (ratio < 0.1 || ratio > 10.0) ? " rows-skewed" : ""; - var accuracy = expectedRows > 0 ? $" ({ratio * 100:F0}%)" : ""; builder.OpenElement(28, "div"); builder.AddAttribute(29, "class", $"node-rows{rowClass}"); - builder.AddContent(30, $"{node.ActualRows:N0} of {expectedRows:N0}{accuracy}"); + builder.AddContent(30, PlanRowAccuracy.FormatActualOfExpected(node.ActualRows, RowEstimateHelper.GetExpectedRows(node))); builder.CloseElement(); } else diff --git a/src/PlanViewer.Web/PlanViewer.Web.csproj b/src/PlanViewer.Web/PlanViewer.Web.csproj index 75d9bda..85c32b7 100644 --- a/src/PlanViewer.Web/PlanViewer.Web.csproj +++ b/src/PlanViewer.Web/PlanViewer.Web.csproj @@ -32,6 +32,7 @@ + diff --git a/tests/PlanViewer.Core.Tests/PlanRowAccuracyTests.cs b/tests/PlanViewer.Core.Tests/PlanRowAccuracyTests.cs new file mode 100644 index 0000000..904df52 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanRowAccuracyTests.cs @@ -0,0 +1,270 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Avalonia.Controls; +using Avalonia.LogicalTree; +using PlanViewer.App.Controls; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// #611: the node row label printed both counts N0 but took its percentage from the unrounded +/// values, so a Key Lookup that ran 117 times for 1 row (estimate 0.00964372 each, 1.128 expected +/// in total) read "1 of 1 (89%)". PlanRowAccuracy adds the fewest decimals at which the printed +/// numbers give the printed percentage. The rule and these cases are PerformanceMonitor's (#4684), +/// so both apps print the same string for the same plan. +/// +public class PlanRowAccuracyTests +{ + private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture; + + // ---- the rule ---------------------------------------------------------------------------- + + /// Whole numbers print N0 and a missing or zero expectation drops the percentage, exactly as the label always did. + [Theory] + [InlineData(1.0, 1.0, "1 of 1 (100%)")] + [InlineData(5.0, 4.0, "5 of 4 (125%)")] + [InlineData(1234.5, 1000.0, "1,234 of 1,000 (123%)")] // N0 rounds an exact .5 to even + [InlineData(105.5128, 103.694, "106 of 104 (102%)")] + [InlineData(1234567.0, 2000000.0, "1,234,567 of 2,000,000 (62%)")] + [InlineData(1.0, 0.0, "1 of 0")] + [InlineData(0.0, 0.0, "0 of 0")] + [InlineData(12.0, 0.0, "12 of 0")] + [InlineData(0.0, 250.0, "0 of 250 (0%)")] + [InlineData(1.0, 1000000.0, "1 of 1,000,000 (0%)")] // the percentage is whole; only the row counts are kept off zero + public void WholeNumbers_PrintN0(double actual, double expected, string label) + { + Assert.Equal(label, PlanRowAccuracy.FormatActualOfExpected(actual, expected, Invariant)); + } + + /// The fewest decimals at which the printed numbers give the printed percentage. A whole number keeps its N0 + /// text, and a fixed-point count means a trailing zero can appear ("0.60", "0.30"). + [Theory] + [InlineData(1.0, 1.12831524, "1 of 1.128 (89%)")] + [InlineData(0.0, 0.5, "0 of 0.5 (0%)")] + [InlineData(0.5, 5.0, "0.5 of 5 (10%)")] + [InlineData(3.0, 0.4, "3 of 0.4 (750%)")] + [InlineData(0.1, 0.3, "0.1 of 0.3 (33%)")] + [InlineData(2.5, 2.0, "2.5 of 2 (125%)")] + [InlineData(100000.0, 12.5, "100,000 of 12.5 (800000%)")] + [InlineData(0.6, 0.75, "0.60 of 0.75 (80%)")] + [InlineData(0.25, 0.3, "0.25 of 0.30 (83%)")] + [InlineData(0.99, 1.01, "0.99 of 1.01 (98%)")] + [InlineData(0.6, 0.55555, "0.600 of 0.556 (108%)")] + [InlineData(0.0104, 0.0096, "0.0104 of 0.0096 (108%)")] + [InlineData(25.0, 16.4, "25 of 16.4 (152%)")] + [InlineData(1.0, 0.4, "1 of 0.4 (250%)")] + public void Decimals_AreAddedOnlyUntilTheNumbersAgreeWithThePercentage(double actual, double expected, string label) + { + Assert.Equal(label, PlanRowAccuracy.FormatActualOfExpected(actual, expected, Invariant)); + } + + /// Four decimals is the ceiling for making the numbers agree, so a label can still contradict its + /// percentage there. It still never prints a non-zero value as 0: 0.00001234 needs five decimals to show a digit, and + /// the cap does not apply to that. + [Theory] + [InlineData(1.0, 0.00012345, "1 of 0.0001 (810045%)")] + [InlineData(0.000123, 0.000456, "0.0001 of 0.0005 (27%)")] + [InlineData(1.0, 0.00001234, "1 of 0.00001 (8103728%)")] + [InlineData(0.0000004, 1.0, "0.0000004 of 1 (0%)")] + [InlineData(0.00001, 0.0, "0.00001 of 0")] + [InlineData(0.0, 0.000005, "0 of 0.000005 (0%)")] + public void Cap_StopsAtFourDecimals_ButANonZeroValueNeverPrintsAsZero(double actual, double expected, string label) + { + Assert.Equal(label, PlanRowAccuracy.FormatActualOfExpected(actual, expected, Invariant)); + } + + /// No magnitude prints an exponent. + [Theory] + [InlineData(1e15, 3e15, "1,000,000,000,000,000 of 3,000,000,000,000,000 (33%)")] + [InlineData(1e21, 4e21, "1,000,000,000,000,000,000,000 of 4,000,000,000,000,000,000,000 (25%)")] + public void LargeNumbers_PrintEveryDigit(double actual, double expected, string label) + { + Assert.Equal(label, PlanRowAccuracy.FormatActualOfExpected(actual, expected, Invariant)); + } + + [Theory] + [InlineData(1e300, 3e300)] + [InlineData(1.0, double.MaxValue)] + [InlineData(5e-324, 1e-320)] + [InlineData(1e-300, 1e300)] + public void ExtremeMagnitudes_NeverPrintAnExponent(double actual, double expected) + { + var label = PlanRowAccuracy.FormatActualOfExpected(actual, expected, Invariant); + Assert.DoesNotContain("E", label, StringComparison.OrdinalIgnoreCase); + Assert.Matches(new Regex(@"^[\d,.]+ of [\d,.]+ \(\d+%\)$", RegexOptions.CultureInvariant), label); + } + + /// The HTML export words the line its own way, so it takes the pieces: the same two numbers, and no + /// percentage when there is nothing to divide by. + [Fact] + public void PrintActualOfExpected_ReturnsThePiecesTheLabelJoins() + { + Assert.Equal(("1", "1.128", "89"), PlanRowAccuracy.PrintActualOfExpected(1.0, 1.12831524, Invariant)); + Assert.Equal(("1", "0", (string?)null), PlanRowAccuracy.PrintActualOfExpected(1.0, 0.0, Invariant)); + } + + // ---- culture ----------------------------------------------------------------------------- + + /// The numbers, the decimal mark and the group mark follow the caller's culture, and the agreement check + /// parses the group mark back ("2.983" is 2983 in de-DE). + [Theory] + [InlineData(1.0, 1.12831524, "1 of 1,128 (89%)")] + [InlineData(609.0, 2983.02, "609 of 2.983 (20%)")] + [InlineData(0.6, 0.75, "0,60 of 0,75 (80%)")] + public void Culture_FollowsTheCallers(double actual, double expected, string label) + { + Assert.Equal(label, PlanRowAccuracy.FormatActualOfExpected(actual, expected, new CultureInfo("de-DE"))); + } + + [Fact] + public void Culture_DefaultsToTheCurrentCulture() + { + var saved = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.Equal("1 of 1,128 (89%)", PlanRowAccuracy.FormatActualOfExpected(1.0, 1.12831524)); + } + finally + { + CultureInfo.CurrentCulture = saved; + } + } + + // ---- a sweep ----------------------------------------------------------------------------- + + /// 2,000 pairs from 1e-6 up to 1e6, whole and fractional, a twentieth with no actual rows. Each label must + /// (1) agree with its own percentage or have stopped at four decimals, (2) carry no exponent, and (3) never print a + /// non-zero value as zero. + [Fact] + public void Sweep_EveryLabelAgreesOrHitTheCap_HasNoExponent_AndNeverPrintsANonZeroValueAsZero() + { + var random = new Random(4684); + var shape = new Regex(@"^(?\S+) of (?\S+) \((?

\d+)%\)$", RegexOptions.CultureInvariant); + int pairs = 0, withDecimals = 0, atTheCap = 0, pastTheCap = 0; + + for (var i = 0; i < 2000; i++) + { + var expected = Math.Pow(10, random.NextDouble() * 12 - 6); + if (i % 3 == 0) + expected = Math.Max(Math.Round(expected), 1); + var actual = expected * Math.Pow(10, random.NextDouble() * 2 - 1); + if (i % 5 == 0) + actual = Math.Round(actual); + if (i % 20 == 0) + actual = 0; + + var label = PlanRowAccuracy.FormatActualOfExpected(actual, expected, Invariant); + var context = $"actual {actual:R}, expected {expected:R} -> \"{label}\""; + var match = shape.Match(label); + Assert.True(match.Success, $"unexpected label shape: {context}"); + pairs++; + + // (2) no exponent + Assert.False(label.Contains('E') || label.Contains('e'), $"exponent in {context}"); + + var actualText = match.Groups["a"].Value; + var expectedText = match.Groups["e"].Value; + var printedActual = double.Parse(actualText, NumberStyles.Number, Invariant); + var printedExpected = double.Parse(expectedText, NumberStyles.Number, Invariant); + + // (3) a non-zero value never prints as zero + Assert.True(actual == 0 || printedActual != 0, $"actual printed as zero: {context}"); + Assert.True(printedExpected != 0, $"expected printed as zero: {context}"); + + // (1) the printed numbers give the printed percentage, unless the search ran out at four decimals + var decimals = Math.Max(DecimalPlaces(actualText), DecimalPlaces(expectedText)); + var agrees = (printedActual / printedExpected * 100).ToString("F0", Invariant) == match.Groups["p"].Value; + Assert.True(agrees || decimals >= 4, $"the label contradicts its percentage before the cap: {context}"); + + if (decimals > 0) + withDecimals++; + if (decimals >= 4) + atTheCap++; + if (decimals >= 5) + pastTheCap++; + } + + Assert.Equal(2000, pairs); + // The sweep has to reach the interesting paths, or the three properties above prove little. + Assert.True(withDecimals > 800, $"only {withDecimals} labels needed decimals"); + Assert.True(atTheCap > 200, $"only {atTheCap} labels reached the cap"); + Assert.True(pastTheCap > 100, $"only {pastTheCap} labels needed the first-significant-digit rule"); + } + + private static int DecimalPlaces(string number) + { + var point = number.IndexOf('.'); + return point < 0 ? 0 : number.Length - point - 1; + } + + // ---- the surfaces ------------------------------------------------------------------------ + + ///

The issue's own example: key_lookup_plan's Key Lookup (NodeId 4, on the inner side of the Nested Loops) + /// ran 117 times for 1 row. The App's node label reads "1 of 1.128 (89%)", not "1 of 1 (89%)". + [Fact] + public void KeyLookupPlan_AppNodeLabel_AgreesWithItsPercentage() + { + HeadlessUi.Run(() => + { + var path = Path.Combine("Plans", "key_lookup_plan.sqlplan"); + Assert.True(File.Exists(path), $"Test plan not found: {path}"); + var xml = File.ReadAllText(path).Replace("encoding=\"utf-16\"", "encoding=\"utf-8\""); + + var viewer = new PlanViewerControl(); + Assert.True(viewer.LoadPlan(xml, "key_lookup_plan.sqlplan"), $"Plan failed to load: {viewer.LastLoadError}"); + var window = new Window { Content = viewer, Width = 1600, Height = 1000 }; + window.Show(); + window.UpdateLayout(); + + var texts = viewer.GetLogicalDescendants().OfType().Select(t => t.Text).ToList(); + Assert.Contains("1 of 1.128 (89%)", texts); + Assert.DoesNotContain("1 of 1 (89%)", texts); + }); + } + + /// The HTML export prints the same numbers, in its own "X of Y rows (P%)" wording. + [Fact] + public void KeyLookupPlan_HtmlExport_AgreesWithItsPercentage() + { + var plan = PlanTestHelper.LoadAndAnalyze("key_lookup_plan.sqlplan"); + foreach (var batch in plan.Batches) + foreach (var stmt in batch.Statements) + PlanLayoutEngine.Layout(stmt); + + var result = ResultMapper.Map(plan, "key_lookup_plan.sqlplan"); + var html = HtmlExporter.Export(result, TextFormatter.Format(result)); + + Assert.Contains("1 of 1.128 rows (89%)", html); + Assert.DoesNotContain("1 of 1 rows (89%)", html); + } + + /// The Web viewer is not referenced by this project, so this pins its node label to the shared formatter + /// instead of rendering it. + [Fact] + public void WebNodeLabel_UsesTheSharedFormatter() + { + var razor = File.ReadAllText(Path.Combine(SolutionRoot(), "src", "PlanViewer.Web", "Pages", "Index.razor")); + + Assert.Contains("PlanRowAccuracy.FormatActualOfExpected(node.ActualRows, RowEstimateHelper.GetExpectedRows(node))", razor); + Assert.DoesNotContain("{expectedRows:N0}", razor); + } + + private static string SolutionRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "PlanViewer.sln"))) + return current.FullName; + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the solution root."); + } +}