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