| """Module contains the score calculation algorithems.""" |
| from functools import partial |
| from typing import Dict, List, Union, cast |
|
|
| from pfzy.types import SCORE_INDICES |
|
|
| SCORE_MIN = float("-inf") |
| SCORE_MAX = float("inf") |
| SCORE_GAP_LEADING = -0.005 |
| SCORE_GAP_TRAILING = -0.005 |
| SCORE_GAP_INNER = -0.01 |
| SCORE_MATCH_CONSECUTIVE = 1.0 |
|
|
|
|
| def _char_range_with( |
| char_start: str, char_stop: str, value, hash_table: Dict[str, Union[int, float]] |
| ) -> Dict[str, Union[int, float]]: |
| """Generate index mapping for `bonus` calculation. |
| |
| Args: |
| char_start: Starting char of the range. |
| char_stop: Ending char of the range. |
| value: Value to give to the range of char. |
| hash_table: Base dictionary to add the mapping. |
| |
| Returns: |
| A dictionary containing the given range with provided index. |
| |
| Examples: |
| >>> _char_range_with("a", "d", 1, {}) |
| {'a': 1, 'b': 1, 'c': 1, 'd': 1} |
| """ |
| hash_table = hash_table.copy() |
| hash_table.update( |
| (chr(uni_char), value) |
| for uni_char in range(ord(char_start), ord(char_stop) + 1) |
| ) |
| return hash_table |
|
|
|
|
| lower_with = partial(_char_range_with, "a", "z") |
| upper_with = partial(_char_range_with, "A", "Z") |
| digit_with = partial(_char_range_with, "0", "9") |
|
|
| SCORE_MATCH_SLASH = 0.9 |
| SCORE_MATCH_WORD = 0.8 |
| SCORE_MATCH_CAPITAL = 0.7 |
| SCORE_MATCH_DOT = 0.6 |
| BONUS_MAP = { |
| "/": SCORE_MATCH_SLASH, |
| "-": SCORE_MATCH_WORD, |
| "_": SCORE_MATCH_WORD, |
| " ": SCORE_MATCH_WORD, |
| ".": SCORE_MATCH_DOT, |
| } |
| BONUS_STATES = [{}, BONUS_MAP, lower_with(SCORE_MATCH_CAPITAL, BONUS_MAP)] |
| BONUS_INDEX = cast(Dict[str, int], digit_with(1, lower_with(1, upper_with(2, {})))) |
|
|
|
|
| def _bonus(haystack: str) -> List[float]: |
| """Calculate bonus score for the given haystack. |
| |
| The bonus are applied to each char based on the previous char. |
| |
| When previous char is within the `BONUS_MAP` then additional bonus |
| are applied to the current char due to it might be the start of a new |
| word. |
| |
| When encountered a mix case character, if the current char is capitalised then |
| if the previous char is normal case or within `BONUS_MAP`, additional bounus are applied. |
| |
| Args: |
| haystack: String to calculate bonus. |
| |
| Returns: |
| A list of float matching the length of the given haystack |
| with each index representing the bonus score to apply. |
| |
| Examples: |
| >>> _bonus("asdf") |
| [0.9, 0, 0, 0] |
| >>> _bonus("asdf asdf") |
| [0.9, 0, 0, 0, 0, 0.8, 0, 0, 0] |
| >>> _bonus("asdf aSdf") |
| [0.9, 0, 0, 0, 0, 0.8, 0.7, 0, 0] |
| >>> _bonus("asdf/aSdf") |
| [0.9, 0, 0, 0, 0, 0.9, 0.7, 0, 0] |
| """ |
| prev_char = "/" |
| bonus = [] |
| for char in haystack: |
| bonus.append(BONUS_STATES[BONUS_INDEX.get(char, 0)].get(prev_char, 0)) |
| prev_char = char |
| return bonus |
|
|
|
|
| def _score(needle: str, haystack: str) -> SCORE_INDICES: |
| """Use fzy logic to calculate score for `needle` within the given `haystack`. |
| |
| 2 2D array to track the score. |
| 1. The running score (`running_score`) which represents the best score for the current position. |
| 2. The result score (`result_score`) which tracks to overall best score that could be for the current positon. |
| |
| With every consequtive match, additional bonuse score are given and for every non matching char, a negative |
| gap score is applied. |
| |
| After the score is calculated, the final matching score will be stored at the last position of the `result_score`. |
| |
| Backtrack the result by comparing the 2 2D array to find the corresponding indices. |
| |
| Args: |
| needle: Substring to find in haystack. |
| haystack: String to be searched and scored. |
| |
| Returns: |
| A tuple of matching score with a list of matching indices. |
| """ |
| needle_len, haystack_len = len(needle), len(haystack) |
| bonus_score = _bonus(haystack) |
|
|
| |
| if needle.islower(): |
| haystack = haystack.lower() |
|
|
| |
| if needle_len == 0 or needle_len == haystack_len: |
| return SCORE_MAX, list(range(needle_len)) |
|
|
| |
| running_score: List[List[float]] = [ |
| [0 for _ in range(haystack_len)] for _ in range(needle_len) |
| ] |
|
|
| |
| result_score: List[List[float]] = [ |
| [0 for _ in range(haystack_len)] for _ in range(needle_len) |
| ] |
|
|
| for i in range(needle_len): |
| prev_score = SCORE_MIN |
|
|
| |
| |
| gap_score = SCORE_GAP_TRAILING if i == needle_len - 1 else SCORE_GAP_INNER |
|
|
| for j in range(haystack_len): |
| if needle[i] == haystack[j]: |
| score = SCORE_MIN |
| if i == 0: |
| score = j * SCORE_GAP_LEADING + bonus_score[j] |
| elif j != 0: |
| score = max( |
| result_score[i - 1][j - 1] + bonus_score[j], |
| |
| running_score[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE, |
| ) |
| running_score[i][j] = score |
| result_score[i][j] = prev_score = max(score, prev_score + gap_score) |
| else: |
| running_score[i][j] = SCORE_MIN |
| |
| result_score[i][j] = prev_score = prev_score + gap_score |
|
|
| |
| |
| i, j = needle_len - 1, haystack_len - 1 |
| |
| match_required = False |
| indices = [0 for _ in range(needle_len)] |
|
|
| while i >= 0: |
| while j >= 0: |
| |
| |
| |
| |
| |
| if ( |
| match_required or running_score[i][j] == result_score[i][j] |
| ) and running_score[i][j] != SCORE_MIN: |
| match_required = ( |
| i > 0 |
| and j > 0 |
| and result_score[i][j] |
| == running_score[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE |
| ) |
| indices[i] = j |
| j -= 1 |
| break |
| else: |
| j -= 1 |
| i -= 1 |
|
|
| return result_score[needle_len - 1][haystack_len - 1], indices |
|
|
|
|
| def _subsequence(needle: str, haystack: str) -> bool: |
| """Check if needle is subsequence of haystack. |
| |
| Args: |
| needle: Substring to find in haystack. |
| haystack: String to be searched and scored. |
| |
| Returns: |
| Boolean indicating if `needle` is subsequence of `haystack`. |
| |
| Examples: |
| >>> _subsequence("as", "bbwi") |
| False |
| >>> _subsequence("as", "bbaiws") |
| True |
| >>> _subsequence("sa", "bbaiws") |
| False |
| """ |
| needle, haystack = needle.lower(), haystack.lower() |
| if not needle: |
| return True |
| offset = 0 |
| for char in needle: |
| offset = haystack.find(char, offset) + 1 |
| if offset <= 0: |
| return False |
| return True |
|
|
|
|
| def fzy_scorer(needle: str, haystack: str) -> SCORE_INDICES: |
| """Use fzy matching algorithem to match needle against haystack. |
| |
| Note: |
| The `fzf` unordered search is not supported for performance concern. |
| When the provided `needle` is not a subsequence of `haystack` at all, |
| then `(-inf, None)` is returned. |
| |
| See Also: |
| https://github.com/jhawthorn/fzy/blob/master/src/match.c |
| |
| Args: |
| needle: Substring to find in haystack. |
| haystack: String to be searched and scored against. |
| |
| Returns: |
| A tuple of matching score with a list of matching indices. |
| |
| Examples: |
| >>> fzy_scorer("ab", "acb") |
| (0.89, [0, 2]) |
| >>> fzy_scorer("ab", "acbabc") |
| (0.98, [3, 4]) |
| >>> fzy_scorer("ab", "wc") |
| (-inf, None) |
| """ |
| if _subsequence(needle, haystack): |
| return _score(needle, haystack) |
| else: |
| return SCORE_MIN, None |
|
|
|
|
| def substr_scorer(needle: str, haystack: str) -> SCORE_INDICES: |
| """Match needle against haystack using :meth:`str.find`. |
| |
| Note: |
| Scores may be negative but the higher the score, the higher |
| the match rank. `-inf` score means no match found. |
| |
| See Also: |
| https://github.com/aslpavel/sweep.py/blob/3f4a179b708059c12b9e5d76d1eb3c70bf2caadc/sweep.py#L837 |
| |
| Args: |
| needle: Substring to find in haystack. |
| haystack: String to be searched and scored against. |
| |
| Returns: |
| A tuple of matching score with a list of matching indices. |
| |
| Example: |
| >>> substr_scorer("ab", "awsab") |
| (-1.3, [3, 4]) |
| >>> substr_scorer("ab", "abc") |
| (0.5, [0, 1]) |
| >>> substr_scorer("ab", "iop") |
| (-inf, None) |
| >>> substr_scorer("ab", "asdafswabc") |
| (-1.6388888888888888, [7, 8]) |
| >>> substr_scorer(" ", "asdf") |
| (0, []) |
| """ |
| indices = [] |
| offset = 0 |
| needle, haystack = needle.lower(), haystack.lower() |
|
|
| for needle in needle.split(" "): |
| if not needle: |
| continue |
| offset = haystack.find(needle, offset) |
| if offset < 0: |
| return SCORE_MIN, None |
| needle_len = len(needle) |
| indices.extend(range(offset, offset + needle_len)) |
| offset += needle_len |
|
|
| if not indices: |
| return 0, indices |
|
|
| return ( |
| -(indices[-1] + 1 - indices[0]) + 2 / (indices[0] + 1) + 1 / (indices[-1] + 1), |
| indices, |
| ) |
|
|