@@ -659,9 +659,14 @@ class PackageNameIndex {
659659 /// Maps the collapsed name to all the original names (e.g. `asyncmap` => [`async_map`, `as_y_n_cmaP`] ).
660660 late final Map <String , List <String >> _collapsedNameResolvesToMap;
661661
662- /// Inverted trigram index: maps each trigram to the package indexes (ascending),
663- /// whose collapsed name contains that trigram.
664- late final Map <String , List <int >> _trigramPostings;
662+ /// Inverted n-gram index: maps each n-gram (1-3 characters) to the token postings
663+ /// of the packages whose collapsed name contains that n-gram.
664+ ///
665+ /// The weight byte is 99 or 100, depending whether the n-gram also occurs
666+ /// in the original - e.g. underscored - name, as opposed to appearing only
667+ /// after underscores are collapsed.
668+ /// The scoring can use it without re-checking substrings.
669+ late final Map <String , TokenPostings > _ngramPostings;
665670
666671 late final _counterPool = IndexedCounterPool (_packageNames.length);
667672
@@ -672,19 +677,52 @@ class PackageNameIndex {
672677 return _PkgNameData (lowercased, collapsed);
673678 }).toList ();
674679 _collapsedNameResolvesToMap = {};
675- _trigramPostings = {};
680+ final builders = < String , PostingsBuilder > {};
676681 for (var i = 0 ; i < _data.length; i++ ) {
682+ final collapsed = _data[i].collapsed;
677683 _collapsedNameResolvesToMap
678- .putIfAbsent (_data[i]. collapsed, () => [])
684+ .putIfAbsent (collapsed, () => [])
679685 .add (_packageNames[i]);
680- // Register only a single trigram -> document index posting.
681- // TODO(https://github.com/dart-lang/pub-dev/issues/9462): consider posting weights
682- for (final trigram in trigrams (_data[i].collapsed).toSet ()) {
683- _trigramPostings.putIfAbsent (trigram, () => < int > []).add (i);
686+ final lowercased = _data[i].lowercased;
687+ // Register one posting per distinct 1-, 2- and 3-gram of the collapsed name.
688+ for (final ngram in _ngrams (collapsed)) {
689+ // The weight byte indicates whether the n-gram also occurs in the
690+ // original (e.g. underscored) name. Postings are added in ascending
691+ // document index order, as required by the delta encoding.
692+ final weight = lowercased.contains (ngram)
693+ ? _originalNameWeight
694+ : _collapsedNameWeight;
695+ builders.putIfAbsent (ngram, PostingsBuilder .new ).add (i, weight);
696+ }
697+ }
698+ _ngramPostings = builders.map ((k, b) => MapEntry (k, b.build ()));
699+ }
700+
701+ Iterable <String > _ngrams (String collapsed) {
702+ final result = < String > {};
703+ final length = collapsed.length;
704+ for (var i = 0 ; i < length; i++ ) {
705+ for (var j = 1 ; j <= 3 && i + j <= length; j++ ) {
706+ result.add (collapsed.substring (i, i + j));
684707 }
685708 }
709+ return result;
686710 }
687711
712+ /// Score assigned when the query n-gram(s) occur in the original (underscored)
713+ /// package name.
714+ static const _originalNameScore = 1.0 ;
715+
716+ /// Score assigned when the query n-gram(s) occur only in the collapsed name,
717+ /// i.e. the match spans a removed underscore.
718+ static const _collapsedNameScore = _collapsedNameWeight / _originalNameWeight;
719+
720+ /// Posting weight bytes: the match score scaled by 100. Storing them as small
721+ /// integers lets the trigram counter sum the weights and recognize a perfect
722+ /// all-original match as `parts.length * _originalNameWeight` .
723+ static const _originalNameWeight = 100 ;
724+ static const _collapsedNameWeight = 99 ;
725+
688726 String _removeUnderscores (String text) => text.replaceAll ('_' , '' );
689727
690728 /// Search [text] and return the matching packages with scores.
@@ -723,57 +761,50 @@ class PackageNameIndex {
723761 final lowercasedWord = word.toLowerCase ();
724762 final collapsedWord = _removeUnderscores (lowercasedWord);
725763
726- bool tryScoreWithSubstringMatch (int index) {
727- final entry = _data[index];
728- if (entry.collapsed.length >= collapsedWord.length &&
729- entry.collapsed.contains (collapsedWord)) {
730- // also check for non-collapsed match
731- if (entry.lowercased.length >= lowercasedWord.length &&
732- entry.lowercased.contains (lowercasedWord)) {
733- score.setValue (index, 1.0 );
734- } else {
735- score.setValue (index, 0.99 );
736- }
737- return true ;
738- }
739- return false ;
740- }
741-
742- // Note: short strings (less than a trigram) are not indexed separately,
743- // we need to do full substring check, evaluating every package.
744- // TODO(https://github.com/dart-lang/pub-dev/issues/9462): consider updating the index + the evaluation algorithm
745- if (collapsedWord.length < 3 ) {
746- for (var i = 0 ; i < _data.length; i++ ) {
747- if (filterOnNonZeros? .isNotPositive (i) ?? false ) continue ;
748- tryScoreWithSubstringMatch (i);
749- }
764+ // Short queries (1-3 chars) are themselves a single indexed n-gram: look the
765+ // whole query up in the postings and score each candidate directly from its
766+ // stored original/collapsed weight, without any substring re-check.
767+ if (collapsedWord.length <= 3 ) {
768+ final postings = _ngramPostings[collapsedWord];
769+ if (postings == null ) return ;
770+ postings.forEach ((i, weight) {
771+ if (filterOnNonZeros? .isNotPositive (i) ?? false ) return ;
772+ score.setValue (
773+ i,
774+ weight == _originalNameWeight
775+ ? _originalNameScore
776+ : _collapsedNameScore,
777+ );
778+ });
750779 return ;
751780 }
752781
753782 _counterPool.withPoolItemSync (
754783 fn: (counts) {
755- // count the trigrams for each package
784+ // Sum the posting weights of the matching trigrams for each package.
756785 final parts = trigrams (collapsedWord);
757786 for (final part in parts) {
758- final postings = _trigramPostings [part];
787+ final postings = _ngramPostings [part];
759788 if (postings == null ) continue ;
760- for (final i in postings) {
761- counts.increment (i);
762- }
789+ postings.forEach ((i, weight) => counts.add (i, weight));
763790 }
764791
765- final acceptThreshold = parts.length ~ / 2 ;
792+ final n = parts.length;
793+ final fullMatchSum = n * _originalNameWeight;
794+ final fullMatchCollapsedSum = n * _collapsedNameWeight;
795+ final minSum = fullMatchCollapsedSum ~ / 2 ;
796+
766797 for (var i = 0 ; i < _packageNames.length; i++ ) {
767- final matched = counts.getValue (i);
768- if (matched == 0 ) continue ;
798+ final sum = counts.getValue (i);
799+ // Partial matches must still cover at least half of the trigrams.
800+ if (sum < minSum) continue ;
769801 if (filterOnNonZeros? .isNotPositive (i) ?? false ) continue ;
770- if (tryScoreWithSubstringMatch (i)) continue ;
771- if (matched >= acceptThreshold) {
772- // making sure that match score is minimum 0.5
773- final v = matched / parts.length;
774- if (v >= 0.5 ) {
775- score.setValue (i, v);
776- }
802+
803+ if (sum >= fullMatchSum) {
804+ score.setValue (i, _originalNameScore);
805+ } else {
806+ // Partial match: fractional relevance score in [0.5, 1.0).
807+ score.setValue (i, sum / fullMatchSum);
777808 }
778809 }
779810 },
0 commit comments