Skip to content

Commit 8ceaf73

Browse files
committed
Improve missing-attribute suggestions: Jaro-Winkler similarity, gated containment
1 parent c080337 commit 8ceaf73

4 files changed

Lines changed: 209 additions & 23 deletions

File tree

src/runtime/Types/ClassBase.cs

Lines changed: 121 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ private enum SuggestionKind
4949
// getattr(self, "_optional", None) on a .NET-derived object, or a mistyped enum value).
5050
// Memoize the fully-built " Did you mean: ...?" hint (empty when there is nothing to
5151
// suggest) per (type, missing-name) so repeats are a dictionary lookup instead of an
52-
// O(members) reflection + Levenshtein scan on every miss.
52+
// O(members) reflection + similarity scan on every miss.
5353
private static readonly ConcurrentDictionary<(Type Type, string Name), string> _suggestionCache = new();
5454

5555
internal ClassBase(Type tp)
@@ -837,21 +837,36 @@ private static Dictionary<string, SuggestionKind> GetCandidateMemberNames(Type t
837837
// Builds the " Did you mean: 'x', 'y'?" hint for a missing attribute, or an empty
838838
// string when no member is similar enough to suggest. The result is cached in
839839
// _suggestionCache, so this runs at most once per (type, missing-name).
840+
//
841+
// Similarity is Jaro-Winkler rather than a Levenshtein threshold: the prefix-favoring
842+
// measure keeps suffix-extended real targets that an edit-distance cutoff rejects
843+
// (BrokerageName.InteractiveBrokers -> INTERACTIVE_BROKERS_BROKERAGE is 11 edits away
844+
// but 0.92 similar), while naturally rejecting the short-name noise edit distance
845+
// admits ('cash' is within 2 edits of 'ASI'). Substring containment is kept as a
846+
// fallback signal for fragment lookups Jaro-Winkler cannot see (its match window
847+
// rules out 'cash' vs 'set_cash'), but only for fragments long enough to be
848+
// meaningful, so 1-2 letter members no longer qualify for every long missed name.
840849
private static string ComputeSimilarMemberNames(Type type, string name)
841850
{
842851
const int MaxSuggestions = 5;
843-
var threshold = Math.Max(2, name.Length / 3);
852+
const double SimilarityThreshold = 0.87;
844853

845-
var scored = new List<(string Name, int Distance, SuggestionKind Kind)>();
854+
var scored = new List<(string Name, double Score, SuggestionKind Kind)>();
846855
foreach (var candidate in GetCandidateMemberNames(type))
847856
{
848-
var distance = LevenshteinDistance(name, candidate.Key);
849-
var related = distance <= threshold
850-
|| candidate.Key.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0
851-
|| name.IndexOf(candidate.Key, StringComparison.OrdinalIgnoreCase) >= 0;
852-
if (related)
857+
var score = JaroWinklerSimilarity(name, candidate.Key);
858+
if (score < SimilarityThreshold)
853859
{
854-
scored.Add((candidate.Key, distance, candidate.Value));
860+
// Containment matches score by how much of the longer name the fragment
861+
// covers, so they always rank below any Jaro-Winkler match.
862+
score = IsMeaningfulContainment(name, candidate.Key)
863+
? (double)Math.Min(name.Length, candidate.Key.Length) / Math.Max(name.Length, candidate.Key.Length)
864+
: 0;
865+
}
866+
867+
if (score > 0)
868+
{
869+
scored.Add((candidate.Key, score, candidate.Value));
855870
}
856871
}
857872

@@ -861,7 +876,7 @@ private static string ComputeSimilarMemberNames(Type type, string name)
861876
}
862877

863878
var ordered = scored
864-
.OrderBy(t => t.Distance)
879+
.OrderByDescending(t => t.Score)
865880
.ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
866881
.ToList();
867882

@@ -895,30 +910,113 @@ private static (string Name, SuggestionKind Kind) ToSnakeCaseMemberName(MemberIn
895910
};
896911
}
897912

898-
private static int LevenshteinDistance(string a, string b)
913+
// A containment signal is only trustworthy when the contained fragment carries real
914+
// information: at least 3 characters, and a candidate contained in the missed name
915+
// must additionally cover at least half of it. Without the length gates every 1-2
916+
// letter member (single-letter methods, greek-letter properties) is a substring of
917+
// any long missed name and floods the suggestion list.
918+
private static bool IsMeaningfulContainment(string name, string candidate)
899919
{
920+
const int MinFragmentLength = 3;
921+
922+
if (name.Length >= MinFragmentLength
923+
&& candidate.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0)
924+
{
925+
return true;
926+
}
927+
928+
return candidate.Length >= MinFragmentLength
929+
&& 2 * candidate.Length >= name.Length
930+
&& name.IndexOf(candidate, StringComparison.OrdinalIgnoreCase) >= 0;
931+
}
932+
933+
/// <summary>
934+
/// Case-insensitive Jaro-Winkler similarity in [0, 1]: the Jaro similarity (matching
935+
/// characters within a sliding window, penalizing transpositions) boosted by up to
936+
/// 0.1 per shared prefix character (capped at 4), so names that agree on their
937+
/// leading characters rank higher than names with the same edit distance elsewhere.
938+
/// </summary>
939+
private static double JaroWinklerSimilarity(string a, string b)
940+
{
941+
const double PrefixScale = 0.1;
942+
const int MaxPrefixLength = 4;
943+
900944
a = a.ToLowerInvariant();
901945
b = b.ToLowerInvariant();
946+
947+
var jaro = JaroSimilarity(a, b);
948+
949+
var prefix = 0;
950+
var maxPrefix = Math.Min(MaxPrefixLength, Math.Min(a.Length, b.Length));
951+
while (prefix < maxPrefix && a[prefix] == b[prefix])
952+
{
953+
prefix++;
954+
}
955+
956+
return jaro + prefix * PrefixScale * (1 - jaro);
957+
}
958+
959+
private static double JaroSimilarity(string a, string b)
960+
{
961+
if (a == b)
962+
{
963+
return 1;
964+
}
965+
902966
var n = a.Length;
903967
var m = b.Length;
904-
if (n == 0) return m;
905-
if (m == 0) return n;
968+
if (n == 0 || m == 0)
969+
{
970+
return 0;
971+
}
972+
973+
var window = Math.Max(0, Math.Max(n, m) / 2 - 1);
974+
var aMatched = new bool[n];
975+
var bMatched = new bool[m];
976+
977+
var matches = 0;
978+
for (var i = 0; i < n; i++)
979+
{
980+
var lo = Math.Max(0, i - window);
981+
var hi = Math.Min(m, i + window + 1);
982+
for (var j = lo; j < hi; j++)
983+
{
984+
if (!bMatched[j] && a[i] == b[j])
985+
{
986+
aMatched[i] = bMatched[j] = true;
987+
matches++;
988+
break;
989+
}
990+
}
991+
}
906992

907-
var prev = new int[m + 1];
908-
var curr = new int[m + 1];
909-
for (var j = 0; j <= m; j++) prev[j] = j;
993+
if (matches == 0)
994+
{
995+
return 0;
996+
}
910997

911-
for (var i = 1; i <= n; i++)
998+
var transpositions = 0;
999+
var k = 0;
1000+
for (var i = 0; i < n; i++)
9121001
{
913-
curr[0] = i;
914-
for (var j = 1; j <= m; j++)
1002+
if (!aMatched[i])
9151003
{
916-
var cost = a[i - 1] == b[j - 1] ? 0 : 1;
917-
curr[j] = Math.Min(Math.Min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
1004+
continue;
9181005
}
919-
(prev, curr) = (curr, prev);
1006+
while (!bMatched[k])
1007+
{
1008+
k++;
1009+
}
1010+
if (a[i] != b[k])
1011+
{
1012+
transpositions++;
1013+
}
1014+
k++;
9201015
}
921-
return prev[m];
1016+
transpositions /= 2;
1017+
1018+
return ((double)matches / n + (double)matches / m
1019+
+ (double)(matches - transpositions) / matches) / 3;
9221020
}
9231021
}
9241022
}

src/testing/classtest.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,33 @@ public static int[] CalculationResults()
8686
}
8787

8888
public static int CalculationResult { get; set; }
89+
90+
// Short members: every one of these is a substring of a longer missed name like
91+
// 'set_account_type', so suggestion tests can assert they are not offered as
92+
// suggestions for it while a similarly-named longer member is.
93+
public static int T()
94+
{
95+
return 0;
96+
}
97+
98+
public static int CC()
99+
{
100+
return 0;
101+
}
102+
103+
public static void SetAccountCurrency(string currency)
104+
{
105+
}
106+
}
107+
108+
/// <summary>
109+
/// Supports missing-attribute suggestion tests for enum values whose real name extends
110+
/// the guessed name with an extra suffix (a common miss on enum-like constant sets).
111+
/// </summary>
112+
public enum SuggestionEnum
113+
{
114+
InteractiveBrokersBrokerage,
115+
InteractiveBrokersFix,
116+
Binance,
89117
}
90118
}

tests/test_class.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,43 @@ def test_missing_property_suggests_data_only():
211211
assert "'calculation_results'" not in hint
212212

213213

214+
def _suggestions(message):
215+
"""Extract the quoted member names from a "Did you mean" hint."""
216+
import re
217+
return re.findall(r"'([^']+)'", message.split("Did you mean")[1])
218+
219+
220+
def test_missing_attribute_does_not_suggest_short_members():
221+
"""A long missed name must not collect 1-2 letter members via substring containment.
222+
223+
Types like QCAlgorithm expose many 1-2 letter members (indicator shortcuts, greek
224+
letters); every one of them is a substring of a long missed name, so they used to
225+
flood the hint (e.g. 'set_account_type' -> "Did you mean: 'cc', 'co', 'a', 'c',
226+
't'?") while the member the user most likely meant was not within the edit-distance
227+
threshold and did not appear at all.
228+
"""
229+
from Python.Test import SuggestionTest
230+
231+
with pytest.raises(AttributeError) as exc_info:
232+
_ = SuggestionTest.set_account_type
233+
234+
message = str(exc_info.value)
235+
assert "Did you mean" in message
236+
suggested = _suggestions(message)
237+
assert "set_account_currency" in suggested
238+
assert all(len(s) > 2 for s in suggested)
239+
240+
241+
def test_missing_attribute_fragment_suggests_containing_member():
242+
"""Typing a meaningful fragment of a member name still suggests that member."""
243+
from Python.Test import SuggestionTest
244+
245+
with pytest.raises(AttributeError) as exc_info:
246+
_ = SuggestionTest.currency
247+
248+
assert "set_account_currency" in _suggestions(str(exc_info.value))
249+
250+
214251
def test_missing_static_member_no_similar():
215252
"""A static member with no similar name keeps the standard message (no hint)."""
216253
from System import Math

tests/test_enum.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,29 @@ def test_missing_enum_member_hasattr_still_false():
6868
assert not hasattr(DayOfWeek, "Sundey")
6969

7070

71+
def test_missing_enum_member_suffix_extended_name_suggested():
72+
"""A guessed name that the real member extends with a suffix must be suggested.
73+
74+
Enum-like constant sets often have members that extend the natural guess (e.g.
75+
BrokerageName.INTERACTIVE_BROKERS_BROKERAGE for a guessed INTERACTIVE_BROKERS);
76+
such members are many edits away, so a pure edit-distance threshold missed them.
77+
Both the PascalCase and the UPPER_SNAKE guess must surface every extension.
78+
"""
79+
import re
80+
from Python.Test import SuggestionEnum
81+
82+
for miss in ("InteractiveBrokers", "INTERACTIVE_BROKERS"):
83+
with pytest.raises(AttributeError) as exc_info:
84+
getattr(SuggestionEnum, miss)
85+
86+
message = str(exc_info.value)
87+
assert "Did you mean" in message
88+
suggested = re.findall(r"'([^']+)'", message.split("Did you mean")[1])
89+
assert "INTERACTIVE_BROKERS_BROKERAGE" in suggested
90+
assert "INTERACTIVE_BROKERS_FIX" in suggested
91+
assert "BINANCE" not in suggested
92+
93+
7194
def test_byte_enum():
7295
"""Test byte enum."""
7396
assert Test.ByteEnum.Zero == Test.ByteEnum(0)

0 commit comments

Comments
 (0)