From 8ceaf73645313c7ab9dacd1473a802338b4b0fc8 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 11:57:29 -0400 Subject: [PATCH 1/3] Improve missing-attribute suggestions: Jaro-Winkler similarity, gated containment --- src/runtime/Types/ClassBase.cs | 144 +++++++++++++++++++++++++++------ src/testing/classtest.cs | 28 +++++++ tests/test_class.py | 37 +++++++++ tests/test_enum.py | 23 ++++++ 4 files changed, 209 insertions(+), 23 deletions(-) diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index 1342b6a3f..4b2bccfa8 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -49,7 +49,7 @@ private enum SuggestionKind // getattr(self, "_optional", None) on a .NET-derived object, or a mistyped enum value). // Memoize the fully-built " Did you mean: ...?" hint (empty when there is nothing to // suggest) per (type, missing-name) so repeats are a dictionary lookup instead of an - // O(members) reflection + Levenshtein scan on every miss. + // O(members) reflection + similarity scan on every miss. private static readonly ConcurrentDictionary<(Type Type, string Name), string> _suggestionCache = new(); internal ClassBase(Type tp) @@ -837,21 +837,36 @@ private static Dictionary GetCandidateMemberNames(Type t // Builds the " Did you mean: 'x', 'y'?" hint for a missing attribute, or an empty // string when no member is similar enough to suggest. The result is cached in // _suggestionCache, so this runs at most once per (type, missing-name). + // + // Similarity is Jaro-Winkler rather than a Levenshtein threshold: the prefix-favoring + // measure keeps suffix-extended real targets that an edit-distance cutoff rejects + // (BrokerageName.InteractiveBrokers -> INTERACTIVE_BROKERS_BROKERAGE is 11 edits away + // but 0.92 similar), while naturally rejecting the short-name noise edit distance + // admits ('cash' is within 2 edits of 'ASI'). Substring containment is kept as a + // fallback signal for fragment lookups Jaro-Winkler cannot see (its match window + // rules out 'cash' vs 'set_cash'), but only for fragments long enough to be + // meaningful, so 1-2 letter members no longer qualify for every long missed name. private static string ComputeSimilarMemberNames(Type type, string name) { const int MaxSuggestions = 5; - var threshold = Math.Max(2, name.Length / 3); + const double SimilarityThreshold = 0.87; - var scored = new List<(string Name, int Distance, SuggestionKind Kind)>(); + var scored = new List<(string Name, double Score, SuggestionKind Kind)>(); foreach (var candidate in GetCandidateMemberNames(type)) { - var distance = LevenshteinDistance(name, candidate.Key); - var related = distance <= threshold - || candidate.Key.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0 - || name.IndexOf(candidate.Key, StringComparison.OrdinalIgnoreCase) >= 0; - if (related) + var score = JaroWinklerSimilarity(name, candidate.Key); + if (score < SimilarityThreshold) { - scored.Add((candidate.Key, distance, candidate.Value)); + // Containment matches score by how much of the longer name the fragment + // covers, so they always rank below any Jaro-Winkler match. + score = IsMeaningfulContainment(name, candidate.Key) + ? (double)Math.Min(name.Length, candidate.Key.Length) / Math.Max(name.Length, candidate.Key.Length) + : 0; + } + + if (score > 0) + { + scored.Add((candidate.Key, score, candidate.Value)); } } @@ -861,7 +876,7 @@ private static string ComputeSimilarMemberNames(Type type, string name) } var ordered = scored - .OrderBy(t => t.Distance) + .OrderByDescending(t => t.Score) .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -895,30 +910,113 @@ private static (string Name, SuggestionKind Kind) ToSnakeCaseMemberName(MemberIn }; } - private static int LevenshteinDistance(string a, string b) + // A containment signal is only trustworthy when the contained fragment carries real + // information: at least 3 characters, and a candidate contained in the missed name + // must additionally cover at least half of it. Without the length gates every 1-2 + // letter member (single-letter methods, greek-letter properties) is a substring of + // any long missed name and floods the suggestion list. + private static bool IsMeaningfulContainment(string name, string candidate) { + const int MinFragmentLength = 3; + + if (name.Length >= MinFragmentLength + && candidate.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + return candidate.Length >= MinFragmentLength + && 2 * candidate.Length >= name.Length + && name.IndexOf(candidate, StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// Case-insensitive Jaro-Winkler similarity in [0, 1]: the Jaro similarity (matching + /// characters within a sliding window, penalizing transpositions) boosted by up to + /// 0.1 per shared prefix character (capped at 4), so names that agree on their + /// leading characters rank higher than names with the same edit distance elsewhere. + /// + private static double JaroWinklerSimilarity(string a, string b) + { + const double PrefixScale = 0.1; + const int MaxPrefixLength = 4; + a = a.ToLowerInvariant(); b = b.ToLowerInvariant(); + + var jaro = JaroSimilarity(a, b); + + var prefix = 0; + var maxPrefix = Math.Min(MaxPrefixLength, Math.Min(a.Length, b.Length)); + while (prefix < maxPrefix && a[prefix] == b[prefix]) + { + prefix++; + } + + return jaro + prefix * PrefixScale * (1 - jaro); + } + + private static double JaroSimilarity(string a, string b) + { + if (a == b) + { + return 1; + } + var n = a.Length; var m = b.Length; - if (n == 0) return m; - if (m == 0) return n; + if (n == 0 || m == 0) + { + return 0; + } + + var window = Math.Max(0, Math.Max(n, m) / 2 - 1); + var aMatched = new bool[n]; + var bMatched = new bool[m]; + + var matches = 0; + for (var i = 0; i < n; i++) + { + var lo = Math.Max(0, i - window); + var hi = Math.Min(m, i + window + 1); + for (var j = lo; j < hi; j++) + { + if (!bMatched[j] && a[i] == b[j]) + { + aMatched[i] = bMatched[j] = true; + matches++; + break; + } + } + } - var prev = new int[m + 1]; - var curr = new int[m + 1]; - for (var j = 0; j <= m; j++) prev[j] = j; + if (matches == 0) + { + return 0; + } - for (var i = 1; i <= n; i++) + var transpositions = 0; + var k = 0; + for (var i = 0; i < n; i++) { - curr[0] = i; - for (var j = 1; j <= m; j++) + if (!aMatched[i]) { - var cost = a[i - 1] == b[j - 1] ? 0 : 1; - curr[j] = Math.Min(Math.Min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost); + continue; } - (prev, curr) = (curr, prev); + while (!bMatched[k]) + { + k++; + } + if (a[i] != b[k]) + { + transpositions++; + } + k++; } - return prev[m]; + transpositions /= 2; + + return ((double)matches / n + (double)matches / m + + (double)(matches - transpositions) / matches) / 3; } } } diff --git a/src/testing/classtest.cs b/src/testing/classtest.cs index 0c726e866..1313ca868 100644 --- a/src/testing/classtest.cs +++ b/src/testing/classtest.cs @@ -86,5 +86,33 @@ public static int[] CalculationResults() } public static int CalculationResult { get; set; } + + // Short members: every one of these is a substring of a longer missed name like + // 'set_account_type', so suggestion tests can assert they are not offered as + // suggestions for it while a similarly-named longer member is. + public static int T() + { + return 0; + } + + public static int CC() + { + return 0; + } + + public static void SetAccountCurrency(string currency) + { + } + } + + /// + /// Supports missing-attribute suggestion tests for enum values whose real name extends + /// the guessed name with an extra suffix (a common miss on enum-like constant sets). + /// + public enum SuggestionEnum + { + InteractiveBrokersBrokerage, + InteractiveBrokersFix, + Binance, } } diff --git a/tests/test_class.py b/tests/test_class.py index df374af92..01282a47b 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -211,6 +211,43 @@ def test_missing_property_suggests_data_only(): assert "'calculation_results'" not in hint +def _suggestions(message): + """Extract the quoted member names from a "Did you mean" hint.""" + import re + return re.findall(r"'([^']+)'", message.split("Did you mean")[1]) + + +def test_missing_attribute_does_not_suggest_short_members(): + """A long missed name must not collect 1-2 letter members via substring containment. + + Types like QCAlgorithm expose many 1-2 letter members (indicator shortcuts, greek + letters); every one of them is a substring of a long missed name, so they used to + flood the hint (e.g. 'set_account_type' -> "Did you mean: 'cc', 'co', 'a', 'c', + 't'?") while the member the user most likely meant was not within the edit-distance + threshold and did not appear at all. + """ + from Python.Test import SuggestionTest + + with pytest.raises(AttributeError) as exc_info: + _ = SuggestionTest.set_account_type + + message = str(exc_info.value) + assert "Did you mean" in message + suggested = _suggestions(message) + assert "set_account_currency" in suggested + assert all(len(s) > 2 for s in suggested) + + +def test_missing_attribute_fragment_suggests_containing_member(): + """Typing a meaningful fragment of a member name still suggests that member.""" + from Python.Test import SuggestionTest + + with pytest.raises(AttributeError) as exc_info: + _ = SuggestionTest.currency + + assert "set_account_currency" in _suggestions(str(exc_info.value)) + + def test_missing_static_member_no_similar(): """A static member with no similar name keeps the standard message (no hint).""" from System import Math diff --git a/tests/test_enum.py b/tests/test_enum.py index 4c15a431e..cfdf764cd 100644 --- a/tests/test_enum.py +++ b/tests/test_enum.py @@ -68,6 +68,29 @@ def test_missing_enum_member_hasattr_still_false(): assert not hasattr(DayOfWeek, "Sundey") +def test_missing_enum_member_suffix_extended_name_suggested(): + """A guessed name that the real member extends with a suffix must be suggested. + + Enum-like constant sets often have members that extend the natural guess (e.g. + BrokerageName.INTERACTIVE_BROKERS_BROKERAGE for a guessed INTERACTIVE_BROKERS); + such members are many edits away, so a pure edit-distance threshold missed them. + Both the PascalCase and the UPPER_SNAKE guess must surface every extension. + """ + import re + from Python.Test import SuggestionEnum + + for miss in ("InteractiveBrokers", "INTERACTIVE_BROKERS"): + with pytest.raises(AttributeError) as exc_info: + getattr(SuggestionEnum, miss) + + message = str(exc_info.value) + assert "Did you mean" in message + suggested = re.findall(r"'([^']+)'", message.split("Did you mean")[1]) + assert "INTERACTIVE_BROKERS_BROKERAGE" in suggested + assert "INTERACTIVE_BROKERS_FIX" in suggested + assert "BINANCE" not in suggested + + def test_byte_enum(): """Test byte enum.""" assert Test.ByteEnum.Zero == Test.ByteEnum(0) From fcf51f7a23cd5c779261159622f5d2fd0df2dd52 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 13:07:43 -0400 Subject: [PATCH 2/3] Tighten suggestion-algorithm comments --- src/runtime/Types/ClassBase.cs | 27 ++++++++------------------- src/testing/classtest.cs | 8 +++----- tests/test_class.py | 7 ++----- tests/test_enum.py | 9 ++------- 4 files changed, 15 insertions(+), 36 deletions(-) diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index 4b2bccfa8..0c32a309d 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -838,17 +838,13 @@ private static Dictionary GetCandidateMemberNames(Type t // string when no member is similar enough to suggest. The result is cached in // _suggestionCache, so this runs at most once per (type, missing-name). // - // Similarity is Jaro-Winkler rather than a Levenshtein threshold: the prefix-favoring - // measure keeps suffix-extended real targets that an edit-distance cutoff rejects - // (BrokerageName.InteractiveBrokers -> INTERACTIVE_BROKERS_BROKERAGE is 11 edits away - // but 0.92 similar), while naturally rejecting the short-name noise edit distance - // admits ('cash' is within 2 edits of 'ASI'). Substring containment is kept as a - // fallback signal for fragment lookups Jaro-Winkler cannot see (its match window - // rules out 'cash' vs 'set_cash'), but only for fragments long enough to be - // meaningful, so 1-2 letter members no longer qualify for every long missed name. + // Jaro-Winkler (prefix-favoring) keeps suffix-extended targets that an edit-distance + // cutoff rejects (InteractiveBrokers -> INTERACTIVE_BROKERS_BROKERAGE); gated + // containment covers fragment lookups outside its match window ('cash' -> 'set_cash'). private static string ComputeSimilarMemberNames(Type type, string name) { const int MaxSuggestions = 5; + // In evaluation over real member sets, intended targets scored >= 0.90 and noise <= 0.85. const double SimilarityThreshold = 0.87; var scored = new List<(string Name, double Score, SuggestionKind Kind)>(); @@ -857,8 +853,7 @@ private static string ComputeSimilarMemberNames(Type type, string name) var score = JaroWinklerSimilarity(name, candidate.Key); if (score < SimilarityThreshold) { - // Containment matches score by how much of the longer name the fragment - // covers, so they always rank below any Jaro-Winkler match. + // Coverage scoring ranks containment matches below any similarity match. score = IsMeaningfulContainment(name, candidate.Key) ? (double)Math.Min(name.Length, candidate.Key.Length) / Math.Max(name.Length, candidate.Key.Length) : 0; @@ -910,11 +905,8 @@ private static (string Name, SuggestionKind Kind) ToSnakeCaseMemberName(MemberIn }; } - // A containment signal is only trustworthy when the contained fragment carries real - // information: at least 3 characters, and a candidate contained in the missed name - // must additionally cover at least half of it. Without the length gates every 1-2 - // letter member (single-letter methods, greek-letter properties) is a substring of - // any long missed name and floods the suggestion list. + // Without the length gates every 1-2 letter member is a substring of any long + // missed name and floods the suggestion list. private static bool IsMeaningfulContainment(string name, string candidate) { const int MinFragmentLength = 3; @@ -931,10 +923,7 @@ private static bool IsMeaningfulContainment(string name, string candidate) } /// - /// Case-insensitive Jaro-Winkler similarity in [0, 1]: the Jaro similarity (matching - /// characters within a sliding window, penalizing transpositions) boosted by up to - /// 0.1 per shared prefix character (capped at 4), so names that agree on their - /// leading characters rank higher than names with the same edit distance elsewhere. + /// Case-insensitive Jaro-Winkler similarity in [0, 1] (Jaro boosted by shared prefix). /// private static double JaroWinklerSimilarity(string a, string b) { diff --git a/src/testing/classtest.cs b/src/testing/classtest.cs index 1313ca868..e7ae6a996 100644 --- a/src/testing/classtest.cs +++ b/src/testing/classtest.cs @@ -87,9 +87,8 @@ public static int[] CalculationResults() public static int CalculationResult { get; set; } - // Short members: every one of these is a substring of a longer missed name like - // 'set_account_type', so suggestion tests can assert they are not offered as - // suggestions for it while a similarly-named longer member is. + // Short members, all substrings of a longer miss like 'set_account_type', which + // must not suggest them. public static int T() { return 0; @@ -106,8 +105,7 @@ public static void SetAccountCurrency(string currency) } /// - /// Supports missing-attribute suggestion tests for enum values whose real name extends - /// the guessed name with an extra suffix (a common miss on enum-like constant sets). + /// Supports suggestion tests for enum members that extend the guessed name with a suffix. /// public enum SuggestionEnum { diff --git a/tests/test_class.py b/tests/test_class.py index 01282a47b..c5d34ca51 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -220,11 +220,8 @@ def _suggestions(message): def test_missing_attribute_does_not_suggest_short_members(): """A long missed name must not collect 1-2 letter members via substring containment. - Types like QCAlgorithm expose many 1-2 letter members (indicator shortcuts, greek - letters); every one of them is a substring of a long missed name, so they used to - flood the hint (e.g. 'set_account_type' -> "Did you mean: 'cc', 'co', 'a', 'c', - 't'?") while the member the user most likely meant was not within the edit-distance - threshold and did not appear at all. + Every short member is a substring of a long miss, so hints used to read + "Did you mean: 'cc', 'co', 'a', 'c', 't'?" while the intended member was absent. """ from Python.Test import SuggestionTest diff --git a/tests/test_enum.py b/tests/test_enum.py index cfdf764cd..96a8dfeb2 100644 --- a/tests/test_enum.py +++ b/tests/test_enum.py @@ -69,13 +69,8 @@ def test_missing_enum_member_hasattr_still_false(): def test_missing_enum_member_suffix_extended_name_suggested(): - """A guessed name that the real member extends with a suffix must be suggested. - - Enum-like constant sets often have members that extend the natural guess (e.g. - BrokerageName.INTERACTIVE_BROKERS_BROKERAGE for a guessed INTERACTIVE_BROKERS); - such members are many edits away, so a pure edit-distance threshold missed them. - Both the PascalCase and the UPPER_SNAKE guess must surface every extension. - """ + """A guessed name that the real member extends with a suffix must be suggested, + from both the PascalCase and the UPPER_SNAKE spelling of the guess.""" import re from Python.Test import SuggestionEnum From 62b3f6a2168d08dcb171e6753a83fb3170f6c9eb Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 13:28:48 -0400 Subject: [PATCH 3/3] Move Jaro-Winkler similarity helpers to Util --- src/runtime/Types/ClassBase.cs | 87 +--------------------------------- src/runtime/Util/Util.cs | 86 +++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 86 deletions(-) diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index 0c32a309d..ed1659789 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -850,7 +850,7 @@ private static string ComputeSimilarMemberNames(Type type, string name) var scored = new List<(string Name, double Score, SuggestionKind Kind)>(); foreach (var candidate in GetCandidateMemberNames(type)) { - var score = JaroWinklerSimilarity(name, candidate.Key); + var score = Util.JaroWinklerSimilarity(name, candidate.Key); if (score < SimilarityThreshold) { // Coverage scoring ranks containment matches below any similarity match. @@ -922,90 +922,5 @@ private static bool IsMeaningfulContainment(string name, string candidate) && name.IndexOf(candidate, StringComparison.OrdinalIgnoreCase) >= 0; } - /// - /// Case-insensitive Jaro-Winkler similarity in [0, 1] (Jaro boosted by shared prefix). - /// - private static double JaroWinklerSimilarity(string a, string b) - { - const double PrefixScale = 0.1; - const int MaxPrefixLength = 4; - - a = a.ToLowerInvariant(); - b = b.ToLowerInvariant(); - - var jaro = JaroSimilarity(a, b); - - var prefix = 0; - var maxPrefix = Math.Min(MaxPrefixLength, Math.Min(a.Length, b.Length)); - while (prefix < maxPrefix && a[prefix] == b[prefix]) - { - prefix++; - } - - return jaro + prefix * PrefixScale * (1 - jaro); - } - - private static double JaroSimilarity(string a, string b) - { - if (a == b) - { - return 1; - } - - var n = a.Length; - var m = b.Length; - if (n == 0 || m == 0) - { - return 0; - } - - var window = Math.Max(0, Math.Max(n, m) / 2 - 1); - var aMatched = new bool[n]; - var bMatched = new bool[m]; - - var matches = 0; - for (var i = 0; i < n; i++) - { - var lo = Math.Max(0, i - window); - var hi = Math.Min(m, i + window + 1); - for (var j = lo; j < hi; j++) - { - if (!bMatched[j] && a[i] == b[j]) - { - aMatched[i] = bMatched[j] = true; - matches++; - break; - } - } - } - - if (matches == 0) - { - return 0; - } - - var transpositions = 0; - var k = 0; - for (var i = 0; i < n; i++) - { - if (!aMatched[i]) - { - continue; - } - while (!bMatched[k]) - { - k++; - } - if (a[i] != b[k]) - { - transpositions++; - } - k++; - } - transpositions /= 2; - - return ((double)matches / n + (double)matches / m - + (double)(matches - transpositions) / matches) / 3; - } } } diff --git a/src/runtime/Util/Util.cs b/src/runtime/Util/Util.cs index 45ee649a9..3075aa7f5 100644 --- a/src/runtime/Util/Util.cs +++ b/src/runtime/Util/Util.cs @@ -336,5 +336,91 @@ public static bool IsInteger(this TypeCode typeCode) return false; } } + + /// + /// Case-insensitive Jaro-Winkler similarity in [0, 1] (Jaro boosted by shared prefix). + /// + internal static double JaroWinklerSimilarity(string a, string b) + { + const double PrefixScale = 0.1; + const int MaxPrefixLength = 4; + + a = a.ToLowerInvariant(); + b = b.ToLowerInvariant(); + + var jaro = JaroSimilarity(a, b); + + var prefix = 0; + var maxPrefix = Math.Min(MaxPrefixLength, Math.Min(a.Length, b.Length)); + while (prefix < maxPrefix && a[prefix] == b[prefix]) + { + prefix++; + } + + return jaro + prefix * PrefixScale * (1 - jaro); + } + + private static double JaroSimilarity(string a, string b) + { + if (a == b) + { + return 1; + } + + var n = a.Length; + var m = b.Length; + if (n == 0 || m == 0) + { + return 0; + } + + var window = Math.Max(0, Math.Max(n, m) / 2 - 1); + var aMatched = new bool[n]; + var bMatched = new bool[m]; + + var matches = 0; + for (var i = 0; i < n; i++) + { + var lo = Math.Max(0, i - window); + var hi = Math.Min(m, i + window + 1); + for (var j = lo; j < hi; j++) + { + if (!bMatched[j] && a[i] == b[j]) + { + aMatched[i] = bMatched[j] = true; + matches++; + break; + } + } + } + + if (matches == 0) + { + return 0; + } + + var transpositions = 0; + var k = 0; + for (var i = 0; i < n; i++) + { + if (!aMatched[i]) + { + continue; + } + while (!bMatched[k]) + { + k++; + } + if (a[i] != b[k]) + { + transpositions++; + } + k++; + } + transpositions /= 2; + + return ((double)matches / n + (double)matches / m + + (double)(matches - transpositions) / matches) / 3; + } } }