Skip to content

Commit f9cb27c

Browse files
authored
feat: add thread safety to Bayes and LSI classifiers (#86)
* feat: add thread safety to Bayes and LSI classifiers Both classifiers now use Mutex_m for thread-safe operations. Concurrent train() and classify() calls no longer corrupt internal state. Key changes: - Include Mutex_m in both Bayes and LSI classes - Wrap all public methods accessing shared state in synchronize blocks - Add unlocked private variants for internal calls to avoid deadlock - Implement marshal_dump/marshal_load to handle serialization (Mutex cannot be marshaled) The pattern used avoids deadlock by having public methods delegate to unlocked private methods when the lock is already held. Closes #66 * fix: add Mutex_m RBS type stubs for validation The rbs-inline tool generates 'include Mutex_m' in the type signatures for Bayes and LSI classes, but RBS validation fails because it cannot find the Mutex_m type definition. Add vendor type stubs with the core mutex methods and their standard aliases (synchronize, lock, unlock, etc.) to satisfy both RBS validation and Steep type checking. * fix: resolve RuboCop and Steep type check issues Extract GSL and native matrix vector assignment into helper methods to reduce build_index method length below the 25-line threshold. Use anonymous block forwarding (&) per Ruby 3.1+ style preferences for the unlocked proxy methods.
1 parent acbc053 commit f9cb27c

3 files changed

Lines changed: 269 additions & 153 deletions

File tree

lib/classifier/bayes.rb

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
# Copyright:: Copyright (c) 2005 Lucas Carlson
55
# License:: LGPL
66

7+
require 'mutex_m'
8+
79
module Classifier
810
class Bayes
11+
include Mutex_m
12+
913
# @rbs @categories: Hash[Symbol, Hash[Symbol, Integer]]
1014
# @rbs @total_words: Integer
1115
# @rbs @category_counts: Hash[Symbol, Integer]
@@ -16,6 +20,7 @@ class Bayes
1620
# b = Classifier::Bayes.new 'Interesting', 'Uninteresting', 'Spam'
1721
# @rbs (*String | Symbol) -> void
1822
def initialize(*categories)
23+
super()
1924
@categories = {}
2025
categories.each { |category| @categories[category.prepare_category_name] = {} }
2126
@total_words = 0
@@ -33,12 +38,15 @@ def initialize(*categories)
3338
# @rbs (String | Symbol, String) -> void
3439
def train(category, text)
3540
category = category.prepare_category_name
36-
@category_counts[category] += 1
37-
text.word_hash.each do |word, count|
38-
@categories[category][word] ||= 0
39-
@categories[category][word] += count
40-
@total_words += count
41-
@category_word_count[category] += count
41+
word_hash = text.word_hash
42+
synchronize do
43+
@category_counts[category] += 1
44+
word_hash.each do |word, count|
45+
@categories[category][word] ||= 0
46+
@categories[category][word] += count
47+
@total_words += count
48+
@category_word_count[category] += count
49+
end
4250
end
4351
end
4452

@@ -53,19 +61,22 @@ def train(category, text)
5361
# @rbs (String | Symbol, String) -> void
5462
def untrain(category, text)
5563
category = category.prepare_category_name
56-
@category_counts[category] -= 1
57-
text.word_hash.each do |word, count|
58-
next unless @total_words >= 0
59-
60-
orig = @categories[category][word] || 0
61-
@categories[category][word] ||= 0
62-
@categories[category][word] -= count
63-
if @categories[category][word] <= 0
64-
@categories[category].delete(word)
65-
count = orig
64+
word_hash = text.word_hash
65+
synchronize do
66+
@category_counts[category] -= 1
67+
word_hash.each do |word, count|
68+
next unless @total_words >= 0
69+
70+
orig = @categories[category][word] || 0
71+
@categories[category][word] ||= 0
72+
@categories[category][word] -= count
73+
if @categories[category][word] <= 0
74+
@categories[category].delete(word)
75+
count = orig
76+
end
77+
@category_word_count[category] -= count if @category_word_count[category] >= count
78+
@total_words -= count
6679
end
67-
@category_word_count[category] -= count if @category_word_count[category] >= count
68-
@total_words -= count
6980
end
7081
end
7182

@@ -77,17 +88,19 @@ def untrain(category, text)
7788
# @rbs (String) -> Hash[String, Float]
7889
def classifications(text)
7990
words = text.word_hash.keys
80-
training_count = @category_counts.values.sum.to_f
81-
vocab_size = [@categories.values.flat_map(&:keys).uniq.size, 1].max
91+
synchronize do
92+
training_count = @category_counts.values.sum.to_f
93+
vocab_size = [@categories.values.flat_map(&:keys).uniq.size, 1].max
8294

83-
@categories.to_h do |category, category_words|
84-
smoothed_total = ((@category_word_count[category] || 0) + vocab_size).to_f
95+
@categories.to_h do |category, category_words|
96+
smoothed_total = ((@category_word_count[category] || 0) + vocab_size).to_f
8597

86-
# Laplace smoothing: P(word|category) = (count + α) / (total + α * V)
87-
word_score = words.sum { |w| Math.log(((category_words[w] || 0) + 1) / smoothed_total) }
88-
prior_score = Math.log((@category_counts[category] || 0.1) / training_count)
98+
# Laplace smoothing: P(word|category) = (count + α) / (total + α * V)
99+
word_score = words.sum { |w| Math.log(((category_words[w] || 0) + 1) / smoothed_total) }
100+
prior_score = Math.log((@category_counts[category] || 0.1) / training_count)
89101

90-
[category.to_s, word_score + prior_score]
102+
[category.to_s, word_score + prior_score]
103+
end
91104
end
92105
end
93106

@@ -134,7 +147,7 @@ def respond_to_missing?(name, include_private = false)
134147
#
135148
# @rbs () -> Array[String]
136149
def categories
137-
@categories.keys.collect(&:to_s)
150+
synchronize { @categories.keys.collect(&:to_s) }
138151
end
139152

140153
# Allows you to add categories to the classifier.
@@ -148,11 +161,24 @@ def categories
148161
#
149162
# @rbs (String | Symbol) -> Hash[Symbol, Integer]
150163
def add_category(category)
151-
@categories[category.prepare_category_name] = {}
164+
synchronize { @categories[category.prepare_category_name] = {} }
152165
end
153166

154167
alias append_category add_category
155168

169+
# Custom marshal serialization to exclude mutex state
170+
# @rbs () -> Array[untyped]
171+
def marshal_dump
172+
[@categories, @total_words, @category_counts, @category_word_count]
173+
end
174+
175+
# Custom marshal deserialization to recreate mutex
176+
# @rbs (Array[untyped]) -> void
177+
def marshal_load(data)
178+
mu_initialize
179+
@categories, @total_words, @category_counts, @category_word_count = data
180+
end
181+
156182
# Allows you to remove categories from the classifier.
157183
# For example:
158184
# b.remove_category "Spam"
@@ -164,13 +190,15 @@ def add_category(category)
164190
# @rbs (String | Symbol) -> void
165191
def remove_category(category)
166192
category = category.prepare_category_name
167-
raise StandardError, "No such category: #{category}" unless @categories.key?(category)
193+
synchronize do
194+
raise StandardError, "No such category: #{category}" unless @categories.key?(category)
168195

169-
@total_words -= @category_word_count[category].to_i
196+
@total_words -= @category_word_count[category].to_i
170197

171-
@categories.delete(category)
172-
@category_counts.delete(category)
173-
@category_word_count.delete(category)
198+
@categories.delete(category)
199+
@category_counts.delete(category)
200+
@category_word_count.delete(category)
201+
end
174202
end
175203
end
176204
end

0 commit comments

Comments
 (0)