@@ -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}
0 commit comments