Skip to content

Commit e907acd

Browse files
authored
Improve missing-attribute suggestions: Jaro-Winkler similarity, gated containment (#147)
* Improve missing-attribute suggestions: Jaro-Winkler similarity, gated containment * Tighten suggestion-algorithm comments * Move Jaro-Winkler similarity helpers to Util
1 parent 3464917 commit e907acd

6 files changed

Lines changed: 209 additions & 18 deletions

File tree

src/runtime/MethodBinder.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,14 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a
10521052
}
10531053
value.Append(' ').Append(overloads);
10541054
}
1055+
1056+
// After the overloads block: consumers that extract the hint from
1057+
// its marker onwards must keep this line too.
1058+
var mismatch = DiagnoseClosestOverloadMismatch(candidates, args, kw);
1059+
if (mismatch.Length > 0)
1060+
{
1061+
value.Append('\n').Append(mismatch);
1062+
}
10551063
}
10561064
catch
10571065
{
@@ -1061,14 +1069,6 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a
10611069
Exceptions.Clear();
10621070
}
10631071

1064-
// After the overloads block: consumers that extract the hint from
1065-
// its marker onwards must keep this line too.
1066-
var mismatch = DiagnoseClosestOverloadMismatch(candidates, args, kw);
1067-
if (mismatch.Length > 0)
1068-
{
1069-
value.Append('\n').Append(mismatch);
1070-
}
1071-
10721072
Exceptions.RaiseTypeError(value.ToString());
10731073
}
10741074

src/runtime/Types/ClassBase.cs

Lines changed: 37 additions & 10 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,31 @@ 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+
// Jaro-Winkler (prefix-favoring) keeps suffix-extended targets that an edit-distance
842+
// cutoff rejects (InteractiveBrokers -> INTERACTIVE_BROKERS_BROKERAGE); gated
843+
// containment covers fragment lookups outside its match window ('cash' -> 'set_cash').
840844
private static string ComputeSimilarMemberNames(Type type, string name)
841845
{
842846
const int MaxSuggestions = 5;
843-
var threshold = Math.Max(2, name.Length / 3);
847+
// In evaluation over real member sets, intended targets scored >= 0.90 and noise <= 0.85.
848+
const double SimilarityThreshold = 0.87;
844849

845-
var scored = new List<(string Name, int Distance, SuggestionKind Kind)>();
850+
var scored = new List<(string Name, double Score, SuggestionKind Kind)>();
846851
foreach (var candidate in GetCandidateMemberNames(type))
847852
{
848-
var distance = Util.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)
853+
var score = Util.JaroWinklerSimilarity(name, candidate.Key);
854+
if (score < SimilarityThreshold)
853855
{
854-
scored.Add((candidate.Key, distance, candidate.Value));
856+
// Coverage scoring ranks containment matches below any similarity match.
857+
score = IsMeaningfulContainment(name, candidate.Key)
858+
? (double)Math.Min(name.Length, candidate.Key.Length) / Math.Max(name.Length, candidate.Key.Length)
859+
: 0;
860+
}
861+
862+
if (score > 0)
863+
{
864+
scored.Add((candidate.Key, score, candidate.Value));
855865
}
856866
}
857867

@@ -861,7 +871,7 @@ private static string ComputeSimilarMemberNames(Type type, string name)
861871
}
862872

863873
var ordered = scored
864-
.OrderBy(t => t.Distance)
874+
.OrderByDescending(t => t.Score)
865875
.ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
866876
.ToList();
867877

@@ -895,5 +905,22 @@ private static (string Name, SuggestionKind Kind) ToSnakeCaseMemberName(MemberIn
895905
};
896906
}
897907

908+
// Without the length gates every 1-2 letter member is a substring of any long
909+
// missed name and floods the suggestion list.
910+
private static bool IsMeaningfulContainment(string name, string candidate)
911+
{
912+
const int MinFragmentLength = 3;
913+
914+
if (name.Length >= MinFragmentLength
915+
&& candidate.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0)
916+
{
917+
return true;
918+
}
919+
920+
return candidate.Length >= MinFragmentLength
921+
&& 2 * candidate.Length >= name.Length
922+
&& name.IndexOf(candidate, StringComparison.OrdinalIgnoreCase) >= 0;
923+
}
924+
898925
}
899926
}

src/runtime/Util/Util.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,5 +363,91 @@ internal static int LevenshteinDistance(string a, string b)
363363
}
364364
return prev[m];
365365
}
366+
367+
/// <summary>
368+
/// Case-insensitive Jaro-Winkler similarity in [0, 1] (Jaro boosted by shared prefix).
369+
/// </summary>
370+
internal static double JaroWinklerSimilarity(string a, string b)
371+
{
372+
const double PrefixScale = 0.1;
373+
const int MaxPrefixLength = 4;
374+
375+
a = a.ToLowerInvariant();
376+
b = b.ToLowerInvariant();
377+
378+
var jaro = JaroSimilarity(a, b);
379+
380+
var prefix = 0;
381+
var maxPrefix = Math.Min(MaxPrefixLength, Math.Min(a.Length, b.Length));
382+
while (prefix < maxPrefix && a[prefix] == b[prefix])
383+
{
384+
prefix++;
385+
}
386+
387+
return jaro + prefix * PrefixScale * (1 - jaro);
388+
}
389+
390+
private static double JaroSimilarity(string a, string b)
391+
{
392+
if (a == b)
393+
{
394+
return 1;
395+
}
396+
397+
var n = a.Length;
398+
var m = b.Length;
399+
if (n == 0 || m == 0)
400+
{
401+
return 0;
402+
}
403+
404+
var window = Math.Max(0, Math.Max(n, m) / 2 - 1);
405+
var aMatched = new bool[n];
406+
var bMatched = new bool[m];
407+
408+
var matches = 0;
409+
for (var i = 0; i < n; i++)
410+
{
411+
var lo = Math.Max(0, i - window);
412+
var hi = Math.Min(m, i + window + 1);
413+
for (var j = lo; j < hi; j++)
414+
{
415+
if (!bMatched[j] && a[i] == b[j])
416+
{
417+
aMatched[i] = bMatched[j] = true;
418+
matches++;
419+
break;
420+
}
421+
}
422+
}
423+
424+
if (matches == 0)
425+
{
426+
return 0;
427+
}
428+
429+
var transpositions = 0;
430+
var k = 0;
431+
for (var i = 0; i < n; i++)
432+
{
433+
if (!aMatched[i])
434+
{
435+
continue;
436+
}
437+
while (!bMatched[k])
438+
{
439+
k++;
440+
}
441+
if (a[i] != b[k])
442+
{
443+
transpositions++;
444+
}
445+
k++;
446+
}
447+
transpositions /= 2;
448+
449+
return ((double)matches / n + (double)matches / m
450+
+ (double)(matches - transpositions) / matches) / 3;
451+
}
366452
}
367453
}

src/testing/classtest.cs

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

8888
public static int CalculationResult { get; set; }
89+
90+
// Short members, all substrings of a longer miss like 'set_account_type', which
91+
// must not suggest them.
92+
public static int T()
93+
{
94+
return 0;
95+
}
96+
97+
public static int CC()
98+
{
99+
return 0;
100+
}
101+
102+
public static void SetAccountCurrency(string currency)
103+
{
104+
}
105+
}
106+
107+
/// <summary>
108+
/// Supports suggestion tests for enum members that extend the guessed name with a suffix.
109+
/// </summary>
110+
public enum SuggestionEnum
111+
{
112+
InteractiveBrokersBrokerage,
113+
InteractiveBrokersFix,
114+
Binance,
89115
}
90116
}

tests/test_class.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,40 @@ 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+
Every short member is a substring of a long miss, so hints used to read
224+
"Did you mean: 'cc', 'co', 'a', 'c', 't'?" while the intended member was absent.
225+
"""
226+
from Python.Test import SuggestionTest
227+
228+
with pytest.raises(AttributeError) as exc_info:
229+
_ = SuggestionTest.set_account_type
230+
231+
message = str(exc_info.value)
232+
assert "Did you mean" in message
233+
suggested = _suggestions(message)
234+
assert "set_account_currency" in suggested
235+
assert all(len(s) > 2 for s in suggested)
236+
237+
238+
def test_missing_attribute_fragment_suggests_containing_member():
239+
"""Typing a meaningful fragment of a member name still suggests that member."""
240+
from Python.Test import SuggestionTest
241+
242+
with pytest.raises(AttributeError) as exc_info:
243+
_ = SuggestionTest.currency
244+
245+
assert "set_account_currency" in _suggestions(str(exc_info.value))
246+
247+
214248
def test_missing_static_member_no_similar():
215249
"""A static member with no similar name keeps the standard message (no hint)."""
216250
from System import Math

tests/test_enum.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ 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+
from both the PascalCase and the UPPER_SNAKE spelling of the guess."""
74+
import re
75+
from Python.Test import SuggestionEnum
76+
77+
for miss in ("InteractiveBrokers", "INTERACTIVE_BROKERS"):
78+
with pytest.raises(AttributeError) as exc_info:
79+
getattr(SuggestionEnum, miss)
80+
81+
message = str(exc_info.value)
82+
assert "Did you mean" in message
83+
suggested = re.findall(r"'([^']+)'", message.split("Did you mean")[1])
84+
assert "INTERACTIVE_BROKERS_BROKERAGE" in suggested
85+
assert "INTERACTIVE_BROKERS_FIX" in suggested
86+
assert "BINANCE" not in suggested
87+
88+
7189
def test_byte_enum():
7290
"""Test byte enum."""
7391
assert Test.ByteEnum.Zero == Test.ByteEnum(0)

0 commit comments

Comments
 (0)