-
Notifications
You must be signed in to change notification settings - Fork 7.6k
Fix for #2068: better QuickOpen heuristics #2462
Conversation
this is just a checkpoint. my current plan is to remove that function because it slows things down too much
Created a new QuickOpen matching algorithm that searches for matches among "special characters" (path markers, camelCase changes) and does so left-to-right rather than right-to-left as in the old algorithm. The new algorithm does still give an added bonus to matches that occur within the filename, which is checked first. Also, there is now a collection of tests to try out the QuickOpen logic and ensure that it is working sanely. The scores look pretty sane now. In this commit, I added some code to help debug scores and was able to get the scores to be pretty sane and the results look quite nice. I also removed the dead code. fix a display bug when there is no query added comments for new QuickOpen algorithm. Note that in the process I spotted a bug and added a failing test, which I have not had a chance to fix yet. partial fix for a bug in the new quickopen logic fixed the bug with strings that are longer than the final segment fix an off-by-one problem that left an initial character match out of the last segment
Adding a link for convenience: Fix for #2068 |
@@ -22,7 +22,8 @@ | |||
*/ | |||
|
|||
/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ | |||
/*global define, $, window, setTimeout */ | |||
/*global define, $, window, setTimeout, ArrayBuffer, Int8Array */ | |||
/*unittests: QuickOpen */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Out of curiosity -- is "unittests" a machine-read annotation of some sort, or just a shorthand you're using?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is actually machine read... http://www.blueskyonmars.com/2013/01/02/test-driving-brackets/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, nice! So when's the follow-up pull request to add these notations to all our other JS files? :-)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
heh. We just need to separate out the slow running tests from the fast running ones, and then it's a piece of cake.
I'll note that I just fired up Brackets with QuickOpen.js open, hit command-P and it didn't actually run the tests... so it looks like my extension is not working as it should. It does rerun the last run tests, which is handy but not the same as actually reading which tests the file says should be run.
Almost done reviewing -- will wrap up later tonight. |
|
||
// a bonus is given for characters that match at the beginning | ||
// of the filename | ||
if (c === 0 && (strCounter > lastSegmentStart)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since c
is always strCounter - 1 given the current pair of calls, could this be simplified to just if (c === lastSegmentStart)
? I'm a little thrown by the === 0
though -- is the intent for this bonus to apply only during the lastSegmentSearch() phase, and not in other cases where a char matches the filename start?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should just be c === lastSegmentStart. Not quite sure how I ended up with something so convoluted.
} | ||
|
||
// if we've finished the query, or we haven't finished the query but we have no | ||
// more backtracking we can do, then we're all done searching. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I found this comment a little confusing since it's in the opposite order as the two clauses in the if
. Worth rewording (or reordering the code, conversely)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good point. I flipped the code.
Still trying to wrap my head around deadBranches and backtrackTo a little better. Will be back online later tonight to continue reviewing... |
I wonder if I can come up with some ASCII art to make them clearer. The key is that the normal pattern prefers the special characters, but in so doing it skips over possible matches. So, I used backtrackTo to keep taking us back one special character at a time (while pulling off every other character that may have matched in between), and then we take it forward consecutively from there. We didn't get a match in the specials, but maybe there's one lurking between those last two specials we checked. But, a problem remains: what happens if we match a consecutive character, then switch back to hitting specials and then hit the end of the string again without a complete match? That's where deadBranches comes in. At the time we set backtrackTo, we also have positively identified that the part of the query after queryCounter does not appear after that special character. If we find ourselves heading down that path again, we need to stop looking for specials because we're not going to find what we're looking for. Without deadBranches, it would keep trying to match up the specials. I did try a simpler approach where it just keeps track of the highest special it should scan to, but it turned out that that approach would actually not do specials scanning when it should. This is akin to dynamic programming, if not exactly that. stringMatch is not exactly a generalized routine... we have some idea how it's used, and I tried to match the algorithm to that. |
There were also a couple of minor code changes (variable renames and such) but no algorithmic changes.
@peterflynn and I worked through an example on IRC of how the backtracking works and I added that example in the doc for _generateMatchList (in commit 112b206) in hopes that how the matching works will be a bit clearer. |
Ok, I think it's all starting to make sense to me :-) Here are a few things I think we might want to capture in the docs in some way:
Hopefully that is all correct! If not lmk... |
Also needs a (hopefully trivial) merge with master before this is mergeable |
Yes, that is how it all works. It's not very important, but there is one minor adjustment. You say:
could be, because those special characters would be traversed, but it does not happen. It doesn't happen because the specials are traversed first and only after that fails does it resort to matching the normal characters. So, as it progresses forward character-by-character it will compare a special if it hits one, but it won't be a match because it had already compared it. Otherwise, everything you said is spot on. Maybe I'll just add your points in directly, because I think that someone else's interpretation of that code (plus the comments that I already have there) can only help someone new who's approaching it for the first time. And, yes, the merge is trivial. I did that merge when I created the branch for the performance improvement yesterday. |
Conflicts: src/search/QuickOpen.js
Hmm, ok I just ran across another case where it seems to fail to find a match: open StringMatch.js and search for "_computerangesa" (or any longer substring of _computeRangesAndScore) -- it won't show any results. If I take out the "s" ("_computeRangeAndScore") then it matches. |
Also one case of funny scoring: if I search for "jsutil," it ranks JSLintUtils.js above JSUtils.js. It seems like both the longer contiguous match and the preference for shorter strings should work in JSUtils' favor, so I'm not sure what's happening... I tried turning on DEBUG_SCORES, but I'm only seeing the total number in the Quick Open results list -- not the breakdown of individual scoring attributes. Is there more to it than just changing the initializer from false to true? |
I'll look into the "_computerangesa" case and the scoring. Scoring is easy to tweak and will never be 100% perfect all the time. The current algorithm counts uppercase letters after lowercase ones as special (the camelCase pattern), so JSLintUtils.js has an extra special than JSUtils.js. I'll see if there's something that makes sense to tweak. When DEBUG_SCORES is on, hover over the score to see the individual parts (it's a tooltip). |
The problem was that backtrackTo was causing backtracking to go too far back for the "s", because it had already backtracked to the "r" previously (when it hit the "g" in the query). backtrackTo was the original mechanism I used in backtracking before adding deadBranches. It turns out that backtracking really needs to go back before deadBranches[queryCounter], because where we need to backtrack to depends on where we are in the query.
@peterflynn noted that "jsutil" matched "JSLintUtils.js" over "JSUtils.js". This change gives a significant boost to consecutive matches that started on a special character. I also boosted specials a little more to balance out specials vs. consecutive matches.
I haven't been following the algorithm in detail, but I wonder if we should treat all uppercase letters as "special", not just ones after lowercase letters, precisely because of cases like "JSUtils", where conceptually the "U" really is the start of a "word", and arguably the "S" is too (because it's part of an acronymish abbreviation standing in for a word). I think most contiguous strings of uppercase letters in identifier names tend to be abbreviations like this. |
@njx that's certainly an option (which I considered just now, but found another way to accomplish the same thing). I'm guessing that we'll turn the knobs a bit on the scoring parameters over time to improve how the matches feel (because it's pretty subjective). I found a different tweak this morning that I think will work out nicely (see commit f325030 if interested). @peterflynn good catch on the _computerangesa search. You had asked at one point if there was a need for backtrackTo to be able to move forward. I couldn't think of a case, but this problem was actually an instance of that very problem. As I said in the commit message, backtrackTo was my starting point for the backtracking, but it turned out that it wasn't necessary and was even harmful. So, the fix here actually made things a bit simpler. When we determine that past point X in the string, the last Y parts of the query can't find a match, we just keep track of that. We won't go hunting beyond that point for a match for that part of the query, and if we need to backtrack we'll make sure that we rewind to the previous special before point X. deadBranches is all of the bookkeeping we need. |
closeRangeGap(str.length); | ||
|
||
// shorter strings that match are often better than longer ones | ||
var lengthPenalty = -1 * Math.round(str.length * DEDUCTION_FOR_LENGTH); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should bother fixing this right now, but I just noticed that rounding combined with DEDUCTION_FOR_LENGTH < 1 means we don't discriminate between strings that are only 1-2 chars different in length. I wonder if it'd be safe to just not bother rounding?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that's true, but I don't think 1-2 characters is a big deal (except maybe as a simple tiebreaker). I believe I added the rounding to make the scores more pleasant to look at when DEBUG_SCORES is on (not a valid reason, admittedly).
I actually just confirmed that I can eliminate the DEDUCTION_FOR_LENGTH entirely right now and the tests still pass. I think this parameter has become less important after the other scoring tweaks.
The code changes all look good. I'm just going to run on this branch for a while longer to make sure I don't hit any other cases that seem off -- and if not, I think we're good to merge! |
Awesome. Thanks again for digging into this one and sticking with it. When this merges, I think I'll redo my pull request for the performance improvement. That code is really quite straightforward and I think we should get it in soon, even if not sprint 19. |
Alright, played with it a bunch more all all still seems well -- time to merge! |
Fix for #2068: better QuickOpen heuristics
This change broke a few extensions. Looking at the fix, it seems that all QuickOpen plugins will likely need these functions, so we may as well re-export them as we have been doing rather than requiring the extensions to all add another import. If we do decide to deprecate these later, we should do so with deprecation warnings (something we weren't doing when these were moved to the StringMatch module). Revert "Marked as deprecated by #2462 in Sprint 19" This reverts commit 49e0827.
The new heuristics don't relate so much to longest substring as they do to trying to find contiguous matches around characters in "special" positions in the string.