kotoshu 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9985d7382788ffadb4cb749614c8358dcb26bf25ba4ad6408843773a301a0ca6
4
- data.tar.gz: 15daa8e23f8cf5a4f4d23ce8a13cb5ed71c9b2973c61f87e3b68ddd76b96b8fd
3
+ metadata.gz: 503b4db65b4971cdc8854123771d9c1855af0de6f408f625d63219d5470c848c
4
+ data.tar.gz: caa57938fc7a76f3c0f7d026346db8e7f247db34b1a9b51d79ab2e9962668f57
5
5
  SHA512:
6
- metadata.gz: 9aa241732513b7cf36b3bcea16a59180b920e0f6582f750e68d865311c1d74b05d6531e769f2bfb8e8c54700a8dad4b67c8858bce7bfa9a6c009c09b41d573fa
7
- data.tar.gz: e48730e02792163a07bec84e450abfabe0e401ce45eda2decdd9ea6b2456dba11dbbece9934c256edad505a1eeb35030d5efa11043b42313479a1b243cb07251
6
+ metadata.gz: 9bb38cd338777586b8bf3c4700d36c5bd083b81f002fc9df0ee3ab667bde36a2a31b0dd8ebd9096543b57de8e151c18b9265df900306c7c71ae06cdc8e33825f
7
+ data.tar.gz: 6a11f2cbc34632d9533af0665308943063f06d952816e277fd1e995adfbbe84a56915693a6b9a9a53a4c64589ff8d09399c66ace9c7f7c657f865e4b0ef7adc4
@@ -466,3 +466,277 @@ rules:
466
466
  suggestion: "used to"
467
467
  message: '"use to" should be "used to"'
468
468
  suggestion: 'used to'
469
+
470
+ - id: EN_ITS_IT_S
471
+ name: "its/it's"
472
+ category: confusion
473
+ severity: warning
474
+ description: |
475
+ "its" (no apostrophe) is the possessive pronoun — like "his"
476
+ or "her". "it's" (with apostrophe) is a contraction of "it is"
477
+ or "it has". When "its" is followed by an adjective, adverb,
478
+ or past participle, the writer usually meant the contraction.
479
+ message: "Did you mean \"it's\" (contraction of \"it is\")?"
480
+ suggestion: "it's"
481
+ patterns:
482
+ - context:
483
+ target_token: "its"
484
+ trigger_when_followed_by:
485
+ tags: [ADJ, ADV]
486
+ words:
487
+ - been
488
+ - going
489
+ - getting
490
+ - becoming
491
+ - appearing
492
+ - seeming
493
+ - looking
494
+ - feeling
495
+ - sounding
496
+ - tasting
497
+ - smelling
498
+ conditions:
499
+ - type: possessive_contraction_check
500
+
501
+ - id: EN_YOUR_YOURE
502
+ name: "your/you're"
503
+ category: confusion
504
+ severity: warning
505
+ description: |
506
+ "your" is the possessive pronoun. "you're" is a contraction of
507
+ "you are". When "your" is followed by an adjective or past
508
+ participle, the writer usually meant the contraction.
509
+ message: "Did you mean \"you're\" (contraction of \"you are\")?"
510
+ suggestion: "you're"
511
+ patterns:
512
+ - context:
513
+ target_token: "your"
514
+ trigger_when_followed_by:
515
+ tags: [ADJ, ADV]
516
+ words:
517
+ - going
518
+ - getting
519
+ - becoming
520
+ - coming
521
+ - leaving
522
+ - arriving
523
+ - doing
524
+ - making
525
+ - saying
526
+ - thinking
527
+ - feeling
528
+ - looking
529
+ - sounding
530
+ conditions:
531
+ - type: possessive_contraction_check
532
+
533
+ - id: EN_WHOSE_WHOS
534
+ name: "whose/who's"
535
+ category: confusion
536
+ severity: warning
537
+ description: |
538
+ "whose" is the possessive form of "who". "who's" is a
539
+ contraction of "who is" or "who has". When "whose" is followed
540
+ by a verb form, the writer usually meant the contraction.
541
+ message: "Did you mean \"who's\" (contraction of \"who is\")?"
542
+ suggestion: "who's"
543
+ patterns:
544
+ - context:
545
+ target_token: "whose"
546
+ trigger_when_followed_by:
547
+ tags: [VERB]
548
+ words:
549
+ - coming
550
+ - going
551
+ - here
552
+ - there
553
+ - talking
554
+ - speaking
555
+ - calling
556
+ - looking
557
+ - watching
558
+ - working
559
+ - doing
560
+ - making
561
+ - been
562
+ - got
563
+ conditions:
564
+ - type: possessive_contraction_check
565
+
566
+ - id: EN_SENTENCE_START_CAP
567
+ name: "Sentence start capitalization"
568
+ category: capitalization
569
+ severity: error
570
+ description: |
571
+ The first word of a sentence should be capitalized. Sentence
572
+ boundaries are: the start of the stream, or any token whose
573
+ final character is ".", "!", or "?".
574
+ message: "Sentences should start with a capital letter"
575
+ suggestion: "Capitalize the first letter"
576
+ patterns:
577
+ - conditions:
578
+ - type: sentence_start_check
579
+
580
+ - id: EN_PROPER_NOUN_CAP
581
+ name: "Proper noun capitalization"
582
+ category: capitalization
583
+ severity: error
584
+ description: |
585
+ Days of the week, months, languages, and some other common
586
+ proper nouns must be capitalized in English.
587
+ message: "Proper nouns should be capitalized"
588
+ suggestion: "Capitalize the proper noun"
589
+ patterns:
590
+ - conditions:
591
+ - type: word_list_check
592
+ corrections:
593
+ # Days of the week
594
+ monday: Monday
595
+ tuesday: Tuesday
596
+ wednesday: Wednesday
597
+ thursday: Thursday
598
+ friday: Friday
599
+ saturday: Saturday
600
+ sunday: Sunday
601
+ # Months
602
+ january: January
603
+ february: February
604
+ march: March
605
+ april: April
606
+ june: June
607
+ july: July
608
+ august: August
609
+ september: September
610
+ october: October
611
+ november: November
612
+ december: December
613
+ # Languages and nationalities
614
+ english: English
615
+ spanish: Spanish
616
+ french: French
617
+ german: German
618
+ italian: Italian
619
+ russian: Russian
620
+ chinese: Chinese
621
+ japanese: Japanese
622
+ korean: Korean
623
+ arabic: Arabic
624
+ hebrew: Hebrew
625
+ hindi: Hindi
626
+ portuguese: Portuguese
627
+ dutch: Dutch
628
+ swedish: Swedish
629
+ polish: Polish
630
+ turkish: Turkish
631
+ greek: Greek
632
+ latin: Latin
633
+
634
+ - id: EN_MORE_THEN
635
+ name: "more then → more than"
636
+ category: grammar
637
+ severity: error
638
+ description: |
639
+ "more then" is a common confusion. The comparative "more"
640
+ takes "than" (the comparison conjunction), not "then"
641
+ (the time adverb).
642
+ patterns:
643
+ - conditions:
644
+ - type: phrase_check
645
+ wrong_phrase: "more then"
646
+ suggestion: "more than"
647
+ message: '"more then" should be "more than"'
648
+ suggestion: 'more than'
649
+
650
+ - id: EN_LESS_THEN
651
+ name: "less then → less than"
652
+ category: grammar
653
+ severity: error
654
+ description: |
655
+ "less then" is a common confusion. "less" takes "than" for
656
+ comparison.
657
+ patterns:
658
+ - conditions:
659
+ - type: phrase_check
660
+ wrong_phrase: "less then"
661
+ suggestion: "less than"
662
+ message: '"less then" should be "less than"'
663
+ suggestion: 'less than'
664
+
665
+ - id: EN_RATHER_THEN
666
+ name: "rather then → rather than"
667
+ category: grammar
668
+ severity: error
669
+ description: |
670
+ "rather then" is a common confusion. "rather" takes "than"
671
+ in preference constructions ("I would rather X than Y").
672
+ patterns:
673
+ - conditions:
674
+ - type: phrase_check
675
+ wrong_phrase: "rather then"
676
+ suggestion: "rather than"
677
+ message: '"rather then" should be "rather than"'
678
+ suggestion: 'rather than'
679
+
680
+ - id: EN_OTHER_THEN
681
+ name: "other then → other than"
682
+ category: grammar
683
+ severity: error
684
+ description: |
685
+ "other then" is a common confusion. "other than" is the
686
+ correct idiom for "except for" or "apart from".
687
+ patterns:
688
+ - conditions:
689
+ - type: phrase_check
690
+ wrong_phrase: "other then"
691
+ suggestion: "other than"
692
+ message: '"other then" should be "other than"'
693
+ suggestion: 'other than'
694
+
695
+ - id: EN_COULD_CARE_LESS
696
+ name: "could care less → couldn't care less"
697
+ category: grammar
698
+ severity: warning
699
+ description: |
700
+ "I could care less" literally means you DO care (because
701
+ it's possible to care less). The intended idiom is
702
+ "I couldn't care less" (it's impossible to care less
703
+ because you already care zero).
704
+ patterns:
705
+ - conditions:
706
+ - type: phrase_check
707
+ wrong_phrase: "could care less"
708
+ suggestion: "couldn't care less"
709
+ message: "\"could care less\" should be \"couldn't care less\""
710
+ suggestion: "couldn't care less"
711
+
712
+ - id: EN_FOR_FREE
713
+ name: "for free → free"
714
+ category: grammar
715
+ severity: suggestion
716
+ description: |
717
+ "for free" is redundant — "free" already means "at no cost".
718
+ Prefer just "free" (e.g., "I got it free", not "I got it
719
+ for free").
720
+ patterns:
721
+ - conditions:
722
+ - type: phrase_check
723
+ wrong_phrase: "for free"
724
+ suggestion: "free"
725
+ message: '"for free" is redundant — consider just "free"'
726
+ suggestion: 'free'
727
+
728
+ - id: EN_ALOT
729
+ name: "alot → a lot"
730
+ category: grammar
731
+ severity: error
732
+ description: |
733
+ "alot" is not a word. The correct spelling is "a lot"
734
+ (two words). "allot" (verb, to distribute) is a different
735
+ word.
736
+ patterns:
737
+ - conditions:
738
+ - type: phrase_check
739
+ wrong_phrase: "alot"
740
+ suggestion: "a lot"
741
+ message: '"alot" is not a word — use "a lot"'
742
+ suggestion: 'a lot'
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kotoshu
4
+ module Algorithms
5
+ # Damerau-Levenshtein edit distance.
6
+ #
7
+ # Counts the minimum number of operations (insertion, deletion,
8
+ # substitution, or transposition of adjacent characters) needed
9
+ # to transform one string into another. The transposition
10
+ # extension distinguishes this from plain Levenshtein — a
11
+ # transposition (e.g. "teh" → "the") costs 1 instead of 2.
12
+ #
13
+ # Extracted from EditDistanceStrategy so that the algorithm is
14
+ # reusable independent of the strategy pipeline and testable
15
+ # without send-to-private.
16
+ module EditDistance
17
+ module_function
18
+
19
+ # Compute the Damerau-Levenshtein distance between two strings.
20
+ #
21
+ # @param str1 [String] First string
22
+ # @param str2 [String] Second string
23
+ # @return [Integer] Edit distance (0 when str1 == str2)
24
+ def distance(str1, str2)
25
+ return str2.length if str1.empty?
26
+ return str1.length if str2.empty?
27
+
28
+ len1 = str1.length
29
+ len2 = str2.length
30
+
31
+ d = Array.new(len1 + 1) { Array.new(len2 + 1, 0) }
32
+
33
+ (0..len1).each { |i| d[i][0] = i }
34
+ (0..len2).each { |j| d[0][j] = j }
35
+
36
+ (1..len1).each do |i|
37
+ (1..len2).each do |j|
38
+ cost = str1[i - 1] == str2[j - 1] ? 0 : 1
39
+
40
+ d[i][j] = [
41
+ d[i - 1][j] + 1, # deletion
42
+ d[i][j - 1] + 1, # insertion
43
+ d[i - 1][j - 1] + cost # substitution
44
+ ].min
45
+
46
+ next unless i > 1 && j > 1 &&
47
+ str1[i - 1] == str2[j - 2] &&
48
+ str1[i - 2] == str2[j - 1]
49
+
50
+ d[i][j] = [d[i][j], d[i - 2][j - 2] + 1].min
51
+ end
52
+ end
53
+
54
+ d[len1][len2]
55
+ end
56
+
57
+ # Compute edit distance with early-exit threshold.
58
+ #
59
+ # Returns nil when the true distance exceeds +threshold+,
60
+ # letting callers prune candidate pairs without paying for
61
+ # the full computation. The current implementation computes
62
+ # the full distance and then thresholds — kept as a seam so
63
+ # a future banded-DP optimization can drop in here.
64
+ #
65
+ # @param str1 [String] First string
66
+ # @param str2 [String] Second string
67
+ # @param threshold [Integer] Maximum distance to report
68
+ # @return [Integer, nil] Distance if ≤ threshold, else nil
69
+ def distance_with_threshold(str1, str2, threshold)
70
+ dist = distance(str1, str2)
71
+ dist <= threshold ? dist : nil
72
+ end
73
+ end
74
+ end
75
+ end
@@ -17,5 +17,6 @@ module Kotoshu
17
17
  autoload :NgramSuggest, "kotoshu/algorithms/ngram_suggest"
18
18
  autoload :PhonetSuggest, "kotoshu/algorithms/phonet_suggest"
19
19
  autoload :Capitalization, "kotoshu/algorithms/capitalization"
20
+ autoload :EditDistance, "kotoshu/algorithms/edit_distance"
20
21
  end
21
22
  end
@@ -164,17 +164,23 @@ module Kotoshu
164
164
 
165
165
  # Calculate hash index for item with seed.
166
166
  #
167
- # Uses double hashing for multiple hash functions:
168
- # hash_i(item) = (hash1(item) + i * hash2(item)) % m
167
+ # Uses double hashing (Kirsch-Mitzenmacher):
168
+ # h_i(x) = (h1(x) + i * h2(x)) mod m
169
+ #
170
+ # The scheme requires h1 and h2 to be *independent* hash
171
+ # functions. Using `item.hash` for both (as an earlier version
172
+ # did) makes them linearly dependent and inflates the false-
173
+ # positive rate well above the configured target — observed
174
+ # ~6–16% versus the theoretical 1% on a filter sized for
175
+ # 1% FPR. Deriving h2 from a salted copy of the item restores
176
+ # independence and brings the observed rate back into line.
169
177
  #
170
178
  # @param item [String] The item to hash
171
179
  # @param seed [Integer] Hash function index
172
180
  # @return [Integer] Bit array index
173
181
  def hash_index(item, seed)
174
- # Use Ruby's built-in hash with different seeds
175
182
  hash1 = item.hash
176
- hash2 = (item.hash * 31) + seed
177
-
183
+ hash2 = "#{item}#bloom#salt".hash
178
184
  (hash1 + (seed * hash2.abs)) % @size
179
185
  end
180
186
  end
@@ -232,6 +232,14 @@ module Kotoshu
232
232
  @registry ||= {}
233
233
  end
234
234
 
235
+ # Reset the type registry. Used by specs that register throw-away
236
+ # dictionary classes; not part of the runtime contract.
237
+ #
238
+ # @return [void]
239
+ def self.reset_registry!
240
+ @registry = {}
241
+ end
242
+
235
243
  # Register a dictionary type.
236
244
  #
237
245
  # @param type [Symbol] The type key
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kotoshu
4
+ module Grammar
5
+ module PatternMatchers
6
+ # Matcher for possessive-vs-contraction confusion rules.
7
+ #
8
+ # Detects when a possessive form ("its", "your", "whose") is
9
+ # used where the corresponding contraction ("it's", "you're",
10
+ # "who's") is intended — or vice versa. The trigger is the
11
+ # following token's POS tag or word form.
12
+ #
13
+ # Rule shape:
14
+ #
15
+ # patterns:
16
+ # - context:
17
+ # target_token: "its"
18
+ # trigger_when_followed_by:
19
+ # tags: [ADJ, ADV]
20
+ # words: [been, going, getting]
21
+ # conditions:
22
+ # - type: possessive_contraction_check
23
+ #
24
+ # When the target token is followed by a token whose POS tag is
25
+ # in +tags+ OR whose lowercased form is in +words+, emit an
26
+ # error with +rule.suggestion+ as the replacement.
27
+ #
28
+ # Examples:
29
+ # "its cold" → suggest "it's" (cold is ADJ)
30
+ # "your ready" → suggest "you're"
31
+ # "whose coming" → suggest "who's"
32
+ class PossessiveContractionMatcher < BaseMatcher
33
+ def match(tokens, rule)
34
+ errors = []
35
+ target = pattern_target_token
36
+ triggers = pattern_triggers
37
+
38
+ tokens.each_with_index do |token, idx|
39
+ next unless matches_target?(token, target)
40
+
41
+ next_token = tokens[idx + 1]
42
+ next unless next_token
43
+ next unless triggers_match?(next_token, triggers)
44
+
45
+ errors << build_error(token, next_token, rule)
46
+ end
47
+ errors
48
+ end
49
+
50
+ protected
51
+
52
+ def pattern_target_token
53
+ @pattern.dig('context', 'target_token')
54
+ end
55
+
56
+ def pattern_triggers
57
+ @pattern.dig('context', 'trigger_when_followed_by') || {}
58
+ end
59
+
60
+ def matches_target?(token, target)
61
+ target && token[:token]&.downcase == target.downcase
62
+ end
63
+
64
+ def triggers_match?(next_token, triggers)
65
+ trigger_tags = triggers['tags'] || []
66
+ trigger_words = (triggers['words'] || []).map(&:downcase)
67
+
68
+ tag = next_token[:pos_tag]
69
+ word = next_token[:token]&.downcase
70
+
71
+ trigger_tags.include?(tag) || trigger_words.include?(word)
72
+ end
73
+
74
+ def build_error(token, next_token, rule)
75
+ {
76
+ rule_id: rule.id,
77
+ position: token[:position],
78
+ message: rule.message,
79
+ suggestion: rule.suggestion,
80
+ context: "\"#{token[:token]} #{next_token[:token]}\"",
81
+ suggestions: [rule.suggestion]
82
+ }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kotoshu
4
+ module Grammar
5
+ module PatternMatchers
6
+ # Matcher for sentence-start capitalization rules.
7
+ #
8
+ # English (and most Latin-script languages) capitalizes the
9
+ # first word of every sentence. This matcher walks the token
10
+ # stream tracking sentence boundaries and flags lowercase
11
+ # sentence starts.
12
+ #
13
+ # A sentence starts:
14
+ # - at the beginning of the stream, OR
15
+ # - immediately after a token whose final character is a
16
+ # sentence-ending punctuation mark (. ! ?).
17
+ #
18
+ # The matcher only considers alphabetic tokens. Numbers,
19
+ # symbols, and whitespace don't reset the sentence-start flag.
20
+ #
21
+ # Rule shape:
22
+ #
23
+ # patterns:
24
+ # - conditions:
25
+ # - type: sentence_start_check
26
+ #
27
+ class SentenceStartMatcher < BaseMatcher
28
+ SENTENCE_ENDERS = %w[. ! ?].freeze
29
+
30
+ def match(tokens, rule)
31
+ errors = []
32
+ expecting_capital = true
33
+
34
+ tokens.each do |token|
35
+ word = token[:token]
36
+ next if word.nil? || word.empty?
37
+
38
+ if sentence_start_word?(word, expecting_capital)
39
+ errors << build_error(token, rule)
40
+ end
41
+
42
+ # Any non-empty token resets the sentence-start expectation.
43
+ # Only sentence-ending punctuation re-arms it (below).
44
+ expecting_capital = false
45
+ expecting_capital = true if ends_sentence?(word)
46
+ end
47
+
48
+ errors
49
+ end
50
+
51
+ protected
52
+
53
+ def sentence_start_word?(word, expecting_capital)
54
+ expecting_capital && alphabetic_word?(word) && first_letter_lowercase?(word)
55
+ end
56
+
57
+ def alphabetic_word?(word)
58
+ !word.nil? && !word.empty? && word[0].match?(/[[:alpha:]]/)
59
+ end
60
+
61
+ def first_letter_lowercase?(word)
62
+ first = word[0]
63
+ first == first.downcase && first != first.upcase
64
+ end
65
+
66
+ def ends_sentence?(word)
67
+ SENTENCE_ENDERS.include?(word[-1])
68
+ end
69
+
70
+ def build_error(token, rule)
71
+ capitalized = token[:token].sub(/\A(\w)/) { $1.upcase }
72
+ {
73
+ rule_id: rule.id,
74
+ position: token[:position],
75
+ message: rule.message,
76
+ suggestion: capitalized,
77
+ context: "\"#{token[:token]}\"",
78
+ suggestions: [capitalized]
79
+ }
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kotoshu
4
+ module Grammar
5
+ module PatternMatchers
6
+ # Matcher for word-list corrections.
7
+ #
8
+ # Flags any token whose lowercase form is a key in the rule's
9
+ # +corrections+ map. Useful for proper-noun capitalization
10
+ # (monday → Monday, english → English) where a single rule
11
+ # covers dozens of word pairs.
12
+ #
13
+ # Rule shape:
14
+ #
15
+ # patterns:
16
+ # - conditions:
17
+ # - type: word_list_check
18
+ # corrections:
19
+ # monday: Monday
20
+ # tuesday: Tuesday
21
+ # english: English
22
+ #
23
+ # The matcher only fires when the actual token text differs from
24
+ # the mapped correction (so "Monday" doesn't get flagged).
25
+ #
26
+ class WordListMatcher < BaseMatcher
27
+ def match(tokens, rule)
28
+ corrections = word_list_condition&.dig('corrections') || {}
29
+ return [] if corrections.empty?
30
+
31
+ errors = []
32
+ tokens.each do |token|
33
+ word = token[:token]
34
+ next unless word
35
+
36
+ correction = corrections[word.downcase]
37
+ next unless correction
38
+ next if word == correction # already correct
39
+
40
+ errors << build_error(token, correction, rule)
41
+ end
42
+ errors
43
+ end
44
+
45
+ private
46
+
47
+ def word_list_condition
48
+ @word_list_condition ||= @pattern['conditions']&.find do |c|
49
+ c['type'] == 'word_list_check'
50
+ end
51
+ end
52
+
53
+ def build_error(token, correction, rule)
54
+ {
55
+ rule_id: rule.id,
56
+ position: token[:position],
57
+ message: rule.message,
58
+ suggestion: correction,
59
+ context: "\"#{token[:token]}\"",
60
+ suggestions: [correction]
61
+ }
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -80,10 +80,16 @@ module Kotoshu
80
80
  PatternMatchers::VowelSoundMatcher.new(pattern, exceptions)
81
81
  elsif condition_types.include?('context_check')
82
82
  PatternMatchers::PossessiveContextMatcher.new(pattern, exceptions)
83
+ elsif condition_types.include?('possessive_contraction_check')
84
+ PatternMatchers::PossessiveContractionMatcher.new(pattern, exceptions)
83
85
  elsif condition_types.include?('distance_check')
84
86
  PatternMatchers::DoubleNegativeMatcher.new(pattern, exceptions)
85
87
  elsif condition_types.include?('phrase_check')
86
88
  PatternMatchers::PhraseMatcher.new(pattern, exceptions)
89
+ elsif condition_types.include?('sentence_start_check')
90
+ PatternMatchers::SentenceStartMatcher.new(pattern, exceptions)
91
+ elsif condition_types.include?('word_list_check')
92
+ PatternMatchers::WordListMatcher.new(pattern, exceptions)
87
93
  else
88
94
  PatternMatchers::BaseMatcher.new(pattern)
89
95
  end
@@ -14,8 +14,11 @@ module Kotoshu
14
14
  autoload :BaseMatcher, "kotoshu/grammar/pattern_matchers/base_matcher"
15
15
  autoload :VowelSoundMatcher, "kotoshu/grammar/pattern_matchers/vowel_sound_matcher"
16
16
  autoload :PossessiveContextMatcher, "kotoshu/grammar/pattern_matchers/possessive_context_matcher"
17
+ autoload :PossessiveContractionMatcher, "kotoshu/grammar/pattern_matchers/possessive_contraction_matcher"
17
18
  autoload :DoubleNegativeMatcher, "kotoshu/grammar/pattern_matchers/double_negative_matcher"
18
19
  autoload :PhraseMatcher, "kotoshu/grammar/pattern_matchers/phrase_matcher"
20
+ autoload :SentenceStartMatcher, "kotoshu/grammar/pattern_matchers/sentence_start_matcher"
21
+ autoload :WordListMatcher, "kotoshu/grammar/pattern_matchers/word_list_matcher"
19
22
  end
20
23
  end
21
24
  end
@@ -78,8 +78,6 @@ module Kotoshu
78
78
  normalize(str1) == normalize(str2)
79
79
  end
80
80
 
81
- protected
82
-
83
81
  # Strip punctuation from text.
84
82
  #
85
83
  # @param text [String] Text to strip
@@ -274,6 +274,10 @@ module Kotoshu
274
274
  @lookup_cache.clear
275
275
  end
276
276
 
277
+ def cache_size
278
+ @lookup_cache.size
279
+ end
280
+
277
281
  private
278
282
 
279
283
  def lookup_with_pos(word)
@@ -204,6 +204,10 @@ module Kotoshu
204
204
  @lookup_cache.clear
205
205
  end
206
206
 
207
+ def cache_size
208
+ @lookup_cache.size
209
+ end
210
+
207
211
  private
208
212
 
209
213
  def lookup_with_pos(word)
@@ -206,6 +206,10 @@ module Kotoshu
206
206
  @lookup_cache.clear
207
207
  end
208
208
 
209
+ def cache_size
210
+ @lookup_cache.size
211
+ end
212
+
209
213
  private
210
214
 
211
215
  def lookup_with_pos(word)
@@ -211,6 +211,10 @@ module Kotoshu
211
211
  @lookup_cache.clear
212
212
  end
213
213
 
214
+ def cache_size
215
+ @lookup_cache.size
216
+ end
217
+
214
218
  private
215
219
 
216
220
  def lookup_with_pos(word)
@@ -61,21 +61,19 @@ module Kotoshu
61
61
  @strategies.select { |s| s.handles?(context) }
62
62
  end
63
63
 
64
- # Generate suggestions by delegating to all child strategies.
64
+ # Generate suggestions by delegating to all applicable strategies.
65
+ #
66
+ # Candidates are collected from every strategy first, then handed
67
+ # to a single SuggestionSet so dedup and ranking run once over the
68
+ # full pool (not as a side effect of each merge). TODO 56 T5.1.
65
69
  #
66
70
  # @param context [Context] The suggestion context
67
71
  # @return [SuggestionSet] Combined suggestions from all strategies
68
72
  def generate(context)
69
- # Create result set
70
- result = SuggestionSet.empty(max_size: context.max_results)
71
-
72
- # Process each applicable strategy
73
- applicable_strategies(context).each do |strategy|
74
- strategy_result = strategy.generate(context)
75
- result.merge!(strategy_result)
73
+ candidates = applicable_strategies(context).flat_map do |strategy|
74
+ strategy.generate(context).suggestions
76
75
  end
77
-
78
- result
76
+ SuggestionSet.new(candidates, max_size: context.max_results)
79
77
  end
80
78
 
81
79
  # Check if any strategy can handle the context.
@@ -267,52 +267,13 @@ module Kotoshu
267
267
  end
268
268
 
269
269
  # Calculate Damerau-Levenshtein edit distance between two strings.
270
- # This extends Levenshtein by treating transposition of adjacent characters as 1 operation.
271
- #
272
- # Examples:
273
- # "wrold" → "world" = 1 (transposition of 'r' and 'o')
274
- # "hello" → "hell" = 1 (deletion)
275
- # "cat" → "cut" = 1 (substitution)
270
+ # Delegates to Algorithms::EditDistance.
276
271
  #
277
272
  # @param str1 [String] First string
278
273
  # @param str2 [String] Second string
279
274
  # @return [Integer] Edit distance
280
275
  def edit_distance(str1, str2)
281
- # Handle empty strings
282
- return str2.length if str1.empty?
283
- return str1.length if str2.empty?
284
-
285
- len1 = str1.length
286
- len2 = str2.length
287
-
288
- # Create a 2D array for dynamic programming
289
- d = Array.new(len1 + 1) { Array.new(len2 + 1, 0) }
290
-
291
- # Initialize the first row and column
292
- (0..len1).each { |i| d[i][0] = i }
293
- (0..len2).each { |j| d[0][j] = j }
294
-
295
- # Fill the matrix
296
- (1..len1).each do |i|
297
- (1..len2).each do |j|
298
- cost = str1[i - 1] == str2[j - 1] ? 0 : 1
299
-
300
- d[i][j] = [
301
- d[i - 1][j] + 1, # deletion
302
- d[i][j - 1] + 1, # insertion
303
- d[i - 1][j - 1] + cost # substitution
304
- ].min
305
-
306
- # Check for transposition (Damerau extension)
307
- if i > 1 && j > 1 &&
308
- str1[i - 1] == str2[j - 2] &&
309
- str1[i - 2] == str2[j - 1]
310
- d[i][j] = [d[i][j], d[i - 2][j - 2] + 1].min
311
- end
312
- end
313
- end
314
-
315
- d[len1][len2]
276
+ Algorithms::EditDistance.distance(str1, str2)
316
277
  end
317
278
 
318
279
  # Optimized edit distance with early termination.
@@ -323,12 +284,11 @@ module Kotoshu
323
284
  # @param threshold [Integer] Maximum distance to calculate
324
285
  # @return [Integer, nil] Distance or nil if exceeds threshold
325
286
  def edit_distance_with_threshold(str1, str2, threshold)
326
- # For now, use the regular implementation
327
- # This can be optimized later with early termination
328
- dist = edit_distance(str1, str2)
329
- dist <= threshold ? dist : nil
287
+ Algorithms::EditDistance.distance_with_threshold(str1, str2, threshold)
330
288
  end
331
289
 
290
+ public
291
+
332
292
  # Calculate enhanced score combining multiple factors.
333
293
  #
334
294
  # Lower score = better suggestion
@@ -479,6 +439,8 @@ module Kotoshu
479
439
  bonus
480
440
  end
481
441
 
442
+ private
443
+
482
444
  # Resolve keyboard layout using OOP registry pattern
483
445
  #
484
446
  # @param keyboard_layout [Keyboard::Layout, String, nil] Layout override
@@ -7,13 +7,16 @@ module Kotoshu
7
7
  class SuggestionSet
8
8
  include Enumerable
9
9
 
10
- attr_reader :suggestions, :max_size
10
+ # @return [Integer] Number of duplicate suggestions removed by the
11
+ # last call to {#deduplicate!}. Zero until deduplicate! runs.
12
+ attr_reader :suggestions, :max_size, :duplicates_removed
11
13
 
12
14
  # @param suggestions [Array<Suggestion>] Initial suggestions
13
15
  # @param max_size [Integer] Maximum number of suggestions to keep
14
16
  def initialize(suggestions = [], max_size: 10)
15
17
  @suggestions = suggestions
16
18
  @max_size = max_size
19
+ @duplicates_removed = 0
17
20
  sort_and_limit!
18
21
  end
19
22
 
@@ -227,13 +230,34 @@ module Kotoshu
227
230
 
228
231
  private
229
232
 
230
- # Sort suggestions by combined score and limit to max_size.
231
- #
232
- def sort_and_limit!
233
+ # Sort suggestions by combined score.
234
+ def sort!
233
235
  @suggestions.sort!
236
+ end
237
+
238
+ # Remove duplicate suggestions (same word, case-insensitive).
239
+ # Called explicitly so the dedup is visible as a named step,
240
+ # not buried inside sort_and_limit as a side effect (TODO 56 T5.1).
241
+ # Tracks the count of removed duplicates for diagnostics.
242
+ def deduplicate!
243
+ before = @suggestions.length
234
244
  @suggestions.uniq! { |s| s.word.downcase }
245
+ @duplicates_removed = before - @suggestions.length
246
+ end
247
+
248
+ # Truncate to max_size.
249
+ def limit!
235
250
  @suggestions = @suggestions.first(@max_size)
236
251
  end
252
+
253
+ # Sort, deduplicate, then limit. The canonical pipeline called
254
+ # after every mutation (add, concat, merge!). Each step is a
255
+ # named method so callers can reason about the order.
256
+ def sort_and_limit!
257
+ sort!
258
+ deduplicate!
259
+ limit!
260
+ end
237
261
  end
238
262
  end
239
263
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kotoshu
4
- VERSION = '0.5.1'
4
+ VERSION = '0.6.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kotoshu
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-03 00:00:00.000000000 Z
11
+ date: 2026-07-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -87,6 +87,7 @@ files:
87
87
  - lib/kotoshu.rb
88
88
  - lib/kotoshu/algorithms.rb
89
89
  - lib/kotoshu/algorithms/capitalization.rb
90
+ - lib/kotoshu/algorithms/edit_distance.rb
90
91
  - lib/kotoshu/algorithms/lookup.rb
91
92
  - lib/kotoshu/algorithms/ngram_suggest.rb
92
93
  - lib/kotoshu/algorithms/permutations.rb
@@ -187,7 +188,10 @@ files:
187
188
  - lib/kotoshu/grammar/pattern_matchers/double_negative_matcher.rb
188
189
  - lib/kotoshu/grammar/pattern_matchers/phrase_matcher.rb
189
190
  - lib/kotoshu/grammar/pattern_matchers/possessive_context_matcher.rb
191
+ - lib/kotoshu/grammar/pattern_matchers/possessive_contraction_matcher.rb
192
+ - lib/kotoshu/grammar/pattern_matchers/sentence_start_matcher.rb
190
193
  - lib/kotoshu/grammar/pattern_matchers/vowel_sound_matcher.rb
194
+ - lib/kotoshu/grammar/pattern_matchers/word_list_matcher.rb
191
195
  - lib/kotoshu/grammar/rule.rb
192
196
  - lib/kotoshu/grammar/rule_engine.rb
193
197
  - lib/kotoshu/grammar/rule_loader.rb