summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimon Quigley <tsimonq2@debian.org>2026-06-26 15:36:16 -0500
committergit-ubuntu importer <ubuntu-devel-discuss@lists.ubuntu.com>2026-06-27 04:38:44 +0000
commit464cd1abd409468939e65d756cd66fb229db0597 (patch)
tree4a04e699f4ecd427d25742d1efc65429bf2a724c
parentee607acdb2855c00c0904b2805221eabcf063f3b (diff)
Imported using git-ubuntu import.
Notes
Notes: * Team upload. [ Debian Janitor ] * Set upstream metadata fields: Bug-Submit. * Update standards version to 4.4.1, no changes needed. * Update watch file format version to 4. * Bump debhelper from old 12 to 13. * Update standards version to 4.5.1, no changes needed. * Update standards version to 4.6.1, no changes needed. [ Cédric Boutillier ] * Update team name * Add .gitattributes to keep unwanted files out of the source package [ Lucas Nussbaum ] * debian/gbp.conf: Add for DEP-14 * debian/gbp.conf: remove trailing empty lines * debian/.gitattributes: remove * debian/salsa-ci.yml: use team-specific include [ Simon Quigley ] * Upgrade the watch file to version 5. * New upstream release. * Refresh the upstream metadata. * Refresh the rules file. * Drop {XS,XB}-Ruby-Versions from control. * Update Standards-Version to 4.7.4. * Bump debhelper-compat to 14, dropping ${misc:Depends}, ${shlibs:Depends}, and ${ruby:Depends} from runtime dependencies. * Mark the package as Architecture: any. * Add Forwarded: not-needed to the patch.
-rw-r--r--.claude-plugin/plugin.json12
-rw-r--r--.github/dependabot.yml6
-rw-r--r--.github/workflows/release.yml41
-rw-r--r--.github/workflows/ruby.yml63
-rw-r--r--.gitignore30
-rw-r--r--.rubocop.yml120
-rw-r--r--CLAUDE.md77
-rw-r--r--Gemfile22
-rw-r--r--Gemfile.lock181
-rw-r--r--LICENSE4
-rw-r--r--README.markdown97
-rw-r--r--README.md202
-rw-r--r--Rakefile180
-rw-r--r--Steepfile27
-rwxr-xr-xbenchmark/lsi_benchmark.rb295
-rw-r--r--checksums.yaml.gzbin268 -> 0 bytes
-rw-r--r--classifier.gemspec36
-rw-r--r--cloving.json27
-rw-r--r--commands/classify.md32
-rw-r--r--commands/models.md24
-rw-r--r--commands/train.md33
-rw-r--r--debian/changelog36
-rw-r--r--debian/control16
-rw-r--r--debian/copyright5
-rw-r--r--debian/gbp.conf4
-rw-r--r--debian/patches/0001-Remove-RubyGems-Depends.patch17
-rw-r--r--debian/ruby-classifier.docs2
-rwxr-xr-xdebian/rules4
-rw-r--r--debian/salsa-ci.yml3
-rw-r--r--debian/upstream/metadata2
-rw-r--r--debian/watch6
-rwxr-xr-xexe/classifier15
-rw-r--r--ext/classifier/classifier_ext.c26
-rw-r--r--ext/classifier/extconf.rb15
-rw-r--r--ext/classifier/incremental_svd.c393
-rw-r--r--ext/classifier/linalg.h72
-rw-r--r--ext/classifier/matrix.c387
-rw-r--r--ext/classifier/svd.c208
-rw-r--r--ext/classifier/vector.c319
-rw-r--r--install.rb50
-rw-r--r--lib/classifier.rb11
-rw-r--r--lib/classifier/bayes.rb657
-rw-r--r--lib/classifier/cli.rb958
-rw-r--r--lib/classifier/config.rb31
-rw-r--r--lib/classifier/errors.rb19
-rw-r--r--lib/classifier/extensions/string.rb4
-rw-r--r--lib/classifier/extensions/vector.rb174
-rw-r--r--lib/classifier/extensions/vector_serialize.rb20
-rw-r--r--lib/classifier/extensions/word_hash.rb241
-rw-r--r--lib/classifier/knn.rb352
-rw-r--r--lib/classifier/logistic_regression.rb614
-rw-r--r--lib/classifier/lsi.rb1130
-rw-r--r--lib/classifier/lsi/content_node.rb102
-rw-r--r--lib/classifier/lsi/incremental_svd.rb166
-rw-r--r--lib/classifier/lsi/summary.rb72
-rw-r--r--lib/classifier/lsi/word_list.rb26
-rw-r--r--lib/classifier/storage.rb9
-rw-r--r--lib/classifier/storage/base.rb50
-rw-r--r--lib/classifier/storage/file.rb51
-rw-r--r--lib/classifier/storage/memory.rb49
-rw-r--r--lib/classifier/streaming.rb122
-rw-r--r--lib/classifier/streaming/line_reader.rb99
-rw-r--r--lib/classifier/streaming/progress.rb96
-rw-r--r--lib/classifier/tfidf.rb416
-rw-r--r--lib/classifier/version.rb3
-rw-r--r--metadata.yml79
-rw-r--r--sig/classifier.rbs3
-rw-r--r--sig/vendor/fast_stemmer.rbs9
-rw-r--r--sig/vendor/gsl.rbs27
-rw-r--r--sig/vendor/json.rbs5
-rw-r--r--sig/vendor/matrix.rbs37
-rw-r--r--sig/vendor/mutex_m.rbs16
-rw-r--r--sig/vendor/optparse.rbs19
-rw-r--r--sig/vendor/streaming.rbs14
-rw-r--r--skills/classifier/SKILL.md67
-rw-r--r--test/bayes/bayesian_test.rb670
-rw-r--r--test/bayes/streaming_test.rb354
-rw-r--r--test/cli/cli_test.rb279
-rw-r--r--test/cli/lr_commands_test.rb142
-rw-r--r--test/cli/lsi_commands_test.rb131
-rw-r--r--test/cli/registry_commands_test.rb457
-rw-r--r--test/config/config_test.rb22
-rw-r--r--test/extensions/word_hash_test.rb196
-rw-r--r--test/knn/knn_test.rb584
-rw-r--r--test/knn/streaming_test.rb37
-rw-r--r--test/linalg/native_ext_test.rb196
-rw-r--r--test/logistic_regression/logistic_regression_test.rb597
-rw-r--r--test/logistic_regression/streaming_test.rb39
-rw-r--r--test/lsi/incremental_svd_test.rb304
-rw-r--r--test/lsi/lsi_test.rb920
-rw-r--r--test/lsi/streaming_test.rb400
-rw-r--r--test/lsi/summary_test.rb113
-rw-r--r--test/property/property_test.rb325
-rw-r--r--test/storage/storage_test.rb993
-rw-r--r--test/streaming/streaming_test.rb374
-rw-r--r--test/test_helper.rb19
-rw-r--r--test/tfidf/tfidf_test.rb449
97 files changed, 15368 insertions, 1070 deletions
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
new file mode 100644
index 0000000..9dc49d3
--- /dev/null
+++ b/.claude-plugin/plugin.json
@@ -0,0 +1,12 @@
+{
+ "name": "classifier",
+ "description": "Text classification CLI using Bayesian, LSI, KNN, Logistic Regression, and TF-IDF algorithms",
+ "version": "1.0.0",
+ "author": {
+ "name": "Lucas Carlson",
+ "url": "https://github.com/cardmagic"
+ },
+ "repository": "https://github.com/cardmagic/classifier",
+ "license": "LGPL-2.1",
+ "keywords": ["machine-learning", "text-classification", "nlp", "bayesian", "lsi", "cli"]
+}
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..76853fd
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+ - package-ecosystem: "bundler"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..aa70931
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,41 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ ci:
+ uses: ./.github/workflows/ruby.yml
+
+ release:
+ needs: ci
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ id-token: write
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '4.0'
+ bundler-cache: true
+
+ - name: Build gem
+ run: gem build classifier.gemspec
+
+ - name: Configure RubyGems credentials
+ uses: rubygems/configure-rubygems-credentials@main
+
+ - name: Push to RubyGems
+ run: gem push classifier-*.gem
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: classifier-*.gem
+ generate_release_notes: true
diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml
new file mode 100644
index 0000000..be87b98
--- /dev/null
+++ b/.github/workflows/ruby.yml
@@ -0,0 +1,63 @@
+# This workflow uses actions that are not certified by GitHub.
+# They are provided by a third-party and are governed by
+# separate terms of service, privacy policy, and support
+# documentation.
+# This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake
+# For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby
+
+name: Ruby
+
+on:
+ push:
+ branches: [ "master" ]
+ pull_request:
+ branches: [ "master" ]
+ workflow_call:
+
+permissions:
+ contents: read
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.4'
+ bundler-cache: true
+ - name: Run RuboCop
+ run: bundle exec rubocop
+
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ ruby-version: ['3.3', '3.4', '4.0']
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby-version }}
+ bundler-cache: true # runs 'bundle install' and caches installed gems automatically
+ - name: Run tests
+ run: bundle exec rake test
+
+ typecheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.4'
+ bundler-cache: true
+ - name: Generate RBS from inline annotations
+ run: bundle exec rbs-inline --output sig lib/
+ - name: Validate RBS types
+ run: bundle exec rbs -I sig validate
+ - name: Type check with Steep
+ run: bundle exec steep check
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea84b02
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,30 @@
+# Coverage reports
+coverage/
+
+# Gem build artifacts
+*.gem
+pkg/
+
+# Native extension build artifacts
+*.bundle
+*.so
+*.o
+*.gcda
+*.gcno
+tmp/
+lib/classifier/classifier_ext.*
+Makefile
+mkmf.log
+
+# IDE/editor files
+.idea/
+*.swp
+*.swo
+*~
+
+# OS files
+.DS_Store
+
+# Claude Code local settings
+.claude/
+sig/generated/
diff --git a/.rubocop.yml b/.rubocop.yml
new file mode 100644
index 0000000..987882b
--- /dev/null
+++ b/.rubocop.yml
@@ -0,0 +1,120 @@
+plugins:
+ - rubocop-minitest
+
+# Allow rbs-inline #: comments without space after #
+Layout/LeadingCommentSpace:
+ AllowRBSInlineAnnotation: true
+
+# @rbs type annotations can be long
+Layout/LineLength:
+ Max: 140
+
+AllCops:
+ TargetRubyVersion: 3.1
+ NewCops: enable
+ Exclude:
+ - 'vendor/**/*'
+ - 'coverage/**/*'
+ - '*.gemspec'
+ - 'bin/**/*'
+ - 'install.rb'
+ - 'benchmark/**/*'
+
+# Existing code lacks documentation, can add incrementally
+Style/Documentation:
+ Enabled: false
+
+# Test files often have long blocks
+Metrics/BlockLength:
+ Exclude:
+ - 'test/**/*'
+ - 'lib/classifier/extensions/vector.rb'
+ - 'lib/classifier/cli.rb'
+
+# Allow longer methods in complex algorithms (SVD, etc.) and CLI
+Metrics/MethodLength:
+ Max: 25
+ Exclude:
+ - 'test/**/*'
+ - 'lib/classifier/extensions/vector.rb'
+ - 'lib/classifier/lsi/content_node.rb'
+ - 'lib/classifier/cli.rb'
+
+# Allow higher complexity for mathematical algorithms and CLI
+Metrics/AbcSize:
+ Max: 30
+ Exclude:
+ - 'test/**/*'
+ - 'lib/classifier/extensions/vector.rb'
+ - 'lib/classifier/lsi.rb'
+ - 'lib/classifier/lsi/content_node.rb'
+ - 'lib/classifier/cli.rb'
+
+Metrics/CyclomaticComplexity:
+ Max: 10
+ Exclude:
+ - 'lib/classifier/extensions/vector.rb'
+ - 'lib/classifier/lsi/content_node.rb'
+ - 'lib/classifier/cli.rb'
+
+Metrics/PerceivedComplexity:
+ Max: 10
+ Exclude:
+ - 'lib/classifier/extensions/vector.rb'
+ - 'lib/classifier/lsi/content_node.rb'
+ - 'lib/classifier/cli.rb'
+
+# Class length limits - algorithms, tests and CLI can be longer
+Metrics/ClassLength:
+ Max: 250
+ Exclude:
+ - 'test/**/*'
+ - 'lib/classifier/lsi.rb'
+ - 'lib/classifier/cli.rb'
+
+# SV_decomp is a standard algorithm name
+Naming/MethodName:
+ Exclude:
+ - 'lib/classifier/extensions/vector.rb'
+
+# Short parameter names are acceptable for serialization
+Naming/MethodParameterName:
+ Exclude:
+ - 'lib/classifier/extensions/vector_serialize.rb'
+
+# Marshal.load is intentional for deserialization
+Security/MarshalLoad:
+ Exclude:
+ - 'lib/classifier/extensions/vector_serialize.rb'
+ - 'test/**/*'
+
+# CORPUS_SKIP_WORDS is a public constant used externally
+Lint/UselessConstantScoping:
+ Enabled: false
+
+# Frozen string literal is optional for this gem
+Style/FrozenStringLiteralComment:
+ Enabled: false
+
+# Allow compact class/module child definitions
+Style/ClassAndModuleChildren:
+ Enabled: false
+
+# Allow both styles of string literals
+Style/StringLiterals:
+ EnforcedStyle: single_quotes
+
+Style/StringLiteralsInInterpolation:
+ EnforcedStyle: single_quotes
+
+# Minitest assertions
+Minitest/MultipleAssertions:
+ Max: 10
+
+# Allow multiple classes/modules in extension files that patch stdlib
+# and in test files that group related test classes
+Style/OneClassPerFile:
+ Exclude:
+ - 'lib/classifier/extensions/vector.rb'
+ - 'lib/classifier/lsi.rb'
+ - 'test/**/*'
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..efec299
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,77 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Ruby gem providing text classification via two algorithms:
+- **Bayes** (`Classifier::Bayes`) - Naive Bayesian classification
+- **LSI** (`Classifier::LSI`) - Latent Semantic Indexing for semantic classification, clustering, and search
+
+## Common Commands
+
+```bash
+# Compile native C extension
+bundle exec rake compile
+
+# Run all tests (compiles first)
+bundle exec rake test
+
+# Run a single test file
+ruby -Ilib test/bayes/bayesian_test.rb
+ruby -Ilib test/lsi/lsi_test.rb
+
+# Run tests with pure Ruby (no native extension)
+NATIVE_VECTOR=true bundle exec rake test
+
+# Run benchmarks
+bundle exec rake benchmark
+bundle exec rake benchmark:compare
+
+# Interactive console
+bundle exec rake console
+
+# Generate documentation
+bundle exec rake doc
+```
+
+## Architecture
+
+### Core Components
+
+**Bayesian Classifier** (`lib/classifier/bayes.rb`)
+- Train with `train(category, text)` or dynamic methods like `train_spam(text)`
+- Classify with `classify(text)` returning the best category
+- Uses log probabilities for numerical stability
+
+**LSI Classifier** (`lib/classifier/lsi.rb`)
+- Uses Singular Value Decomposition (SVD) for semantic analysis
+- Native C extension for 5-50x faster matrix operations; falls back to pure Ruby
+- Key operations: `add_item`, `classify`, `find_related`, `search`
+- `auto_rebuild` option controls automatic index rebuilding after changes
+
+**String Extensions** (`lib/classifier/extensions/word_hash.rb`)
+- `word_hash` / `clean_word_hash` - tokenize text to stemmed word frequencies
+- `CORPUS_SKIP_WORDS` - stopwords filtered during tokenization
+- Uses `fast-stemmer` gem for Porter stemming
+
+**Vector Extensions** (`lib/classifier/extensions/vector.rb`)
+- Pure Ruby SVD implementation (`Matrix#SV_decomp`) - used as fallback
+- Vector normalization and magnitude calculations
+
+### Native C Extension (`ext/classifier/`)
+
+LSI uses a native C extension for fast linear algebra operations:
+- `Classifier::Linalg::Vector` - Vector operations (alloc, normalize, dot product)
+- `Classifier::Linalg::Matrix` - Matrix operations (alloc, transpose, multiply)
+- Jacobi SVD implementation for singular value decomposition
+
+Check current backend: `Classifier::LSI.backend` returns `:native` or `:ruby`
+Force pure Ruby: `NATIVE_VECTOR=true bundle exec rake test`
+
+### Content Nodes (`lib/classifier/lsi/content_node.rb`)
+
+Internal data structure storing:
+- `word_hash` - term frequencies
+- `raw_vector` / `raw_norm` - initial vector representation
+- `lsi_vector` / `lsi_norm` - reduced dimensionality representation after SVD
diff --git a/Gemfile b/Gemfile
index 05a1d05..b32af93 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,5 +1,21 @@
source 'https://rubygems.org'
-gem 'rake'
-gem 'rspec', :require => 'spec'
-gem 'rdoc'
+gemspec
+
gem 'fast-stemmer'
+gem 'matrix'
+gem 'mutex_m'
+gem 'ostruct'
+
+group :test do
+ gem 'rantly', require: false
+ gem 'simplecov', require: false
+end
+
+group :development do
+ gem 'rubocop', require: false
+ gem 'rubocop-minitest', require: false
+ # steep's ffi dependency doesn't support Ruby 4.0 yet
+ install_if -> { RUBY_VERSION < '4.0' } do
+ gem 'steep', require: false
+ end
+end
diff --git a/Gemfile.lock b/Gemfile.lock
index 810db3d..f4766f5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,26 +1,175 @@
+PATH
+ remote: .
+ specs:
+ classifier (2.6.0)
+ fast-stemmer (~> 1.0)
+ matrix
+ mutex_m (~> 0.2)
+ rake
+
GEM
remote: https://rubygems.org/
specs:
- diff-lcs (1.2.5)
+ activesupport (8.1.1)
+ base64
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.3.1)
+ connection_pool (>= 2.2.5)
+ drb
+ i18n (>= 1.6, < 2)
+ json
+ logger (>= 1.4.2)
+ minitest (>= 5.1)
+ securerandom (>= 0.3)
+ tzinfo (~> 2.0, >= 2.0.5)
+ uri (>= 0.13.1)
+ addressable (2.9.0)
+ public_suffix (>= 2.0.2, < 8.0)
+ ast (2.4.3)
+ base64 (0.3.0)
+ bigdecimal (4.1.0)
+ cgi (0.5.1)
+ concurrent-ruby (1.3.7)
+ connection_pool (3.0.2)
+ crack (1.0.1)
+ bigdecimal
+ rexml
+ csv (3.3.5)
+ date (3.5.1)
+ docile (1.4.1)
+ drb (2.2.3)
+ erb (4.0.4.1)
+ cgi (>= 0.3.3)
fast-stemmer (1.0.2)
- json (1.8.1)
- rake (10.1.1)
- rdoc (4.1.0)
- json (~> 1.4)
- rspec (2.14.1)
- rspec-core (~> 2.14.0)
- rspec-expectations (~> 2.14.0)
- rspec-mocks (~> 2.14.0)
- rspec-core (2.14.7)
- rspec-expectations (2.14.4)
- diff-lcs (>= 1.1.3, < 2.0)
- rspec-mocks (2.14.4)
+ ffi (1.17.2-arm64-darwin)
+ ffi (1.17.2-x86_64-linux-gnu)
+ fileutils (1.8.0)
+ hashdiff (1.2.1)
+ i18n (1.14.8)
+ concurrent-ruby (~> 1.0)
+ json (2.19.9)
+ language_server-protocol (3.17.0.5)
+ lint_roller (1.1.0)
+ listen (3.9.0)
+ rb-fsevent (~> 0.10, >= 0.10.3)
+ rb-inotify (~> 0.9, >= 0.9.10)
+ logger (1.7.0)
+ matrix (0.4.3)
+ minitest (6.0.1)
+ prism (~> 1.5)
+ mutex_m (0.3.0)
+ ostruct (0.6.3)
+ parallel (1.28.0)
+ parser (3.3.11.1)
+ ast (~> 2.4.1)
+ racc
+ prism (1.9.0)
+ psych (5.3.1)
+ date
+ stringio
+ public_suffix (6.0.2)
+ racc (1.8.1)
+ rainbow (3.1.1)
+ rake (13.4.2)
+ rake-compiler (1.3.1)
+ rake
+ rantly (3.0.0)
+ rb-fsevent (0.11.2)
+ rb-inotify (0.11.1)
+ ffi (~> 1.0)
+ rbs (3.10.3)
+ logger
+ tsort
+ rbs-inline (0.13.0)
+ prism (>= 0.29)
+ rbs (>= 3.8.0)
+ rdoc (7.2.0)
+ erb
+ psych (>= 4.0.0)
+ tsort
+ regexp_parser (2.12.0)
+ rexml (3.4.4)
+ rubocop (1.88.0)
+ json (~> 2.3)
+ language_server-protocol (~> 3.17.0.2)
+ lint_roller (~> 1.1.0)
+ parallel (>= 1.10)
+ parser (>= 3.3.0.2)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 2.9.3, < 3.0)
+ rubocop-ast (>= 1.49.0, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 4.0)
+ rubocop-ast (1.49.1)
+ parser (>= 3.3.7.2)
+ prism (~> 1.7)
+ rubocop-minitest (0.39.1)
+ lint_roller (~> 1.1)
+ rubocop (>= 1.75.0, < 2.0)
+ rubocop-ast (>= 1.38.0, < 2.0)
+ ruby-progressbar (1.13.0)
+ securerandom (0.4.1)
+ simplecov (0.22.0)
+ docile (~> 1.1)
+ simplecov-html (~> 0.11)
+ simplecov_json_formatter (~> 0.1)
+ simplecov-html (0.13.2)
+ simplecov_json_formatter (0.1.4)
+ steep (1.10.0)
+ activesupport (>= 5.1)
+ concurrent-ruby (>= 1.1.10)
+ csv (>= 3.0.9)
+ fileutils (>= 1.1.0)
+ json (>= 2.1.0)
+ language_server-protocol (>= 3.17.0.4, < 4.0)
+ listen (~> 3.0)
+ logger (>= 1.3.0)
+ mutex_m (>= 0.3.0)
+ parser (>= 3.1)
+ rainbow (>= 2.2.2, < 4.0)
+ rbs (~> 3.9)
+ securerandom (>= 0.1)
+ strscan (>= 1.0.0)
+ terminal-table (>= 2, < 5)
+ uri (>= 0.12.0)
+ stringio (3.2.0)
+ strscan (3.1.6)
+ terminal-table (4.0.0)
+ unicode-display_width (>= 1.1.1, < 4)
+ tsort (0.2.0)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (3.2.0)
+ unicode-emoji (~> 4.1)
+ unicode-emoji (4.2.0)
+ uri (1.1.1)
+ webmock (3.26.2)
+ addressable (>= 2.8.0)
+ crack (>= 0.3.2)
+ hashdiff (>= 0.4.0, < 2.0.0)
PLATFORMS
- ruby
+ arm64-darwin-22
+ arm64-darwin-23
+ arm64-darwin-25
+ x86_64-linux
DEPENDENCIES
+ classifier!
fast-stemmer
- rake
+ matrix
+ minitest
+ mutex_m
+ ostruct
+ rake-compiler
+ rantly
+ rbs-inline
rdoc
- rspec
+ rubocop
+ rubocop-minitest
+ simplecov
+ steep
+ webmock
+
+BUNDLED WITH
+ 4.0.3
diff --git a/LICENSE b/LICENSE
index cf14acc..3b4333e 100644
--- a/LICENSE
+++ b/LICENSE
@@ -146,7 +146,7 @@ such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
-
+
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
@@ -426,4 +426,4 @@ the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
-copyrighted by \ No newline at end of file
+copyrighted by
diff --git a/README.markdown b/README.markdown
deleted file mode 100644
index 6304bb0..0000000
--- a/README.markdown
+++ /dev/null
@@ -1,97 +0,0 @@
-## Welcome to Classifier
-
-Classifier is a general module to allow Bayesian and other types of classifications.
-
-## Download
-
-* https://github.com/cardmagic/classifier
-* gem install classifier
-* git clone https://github.com/cardmagic/classifier.git
-
-## Dependencies
-
-If you install Classifier from source, you'll need to install Roman Shterenzon's fast-stemmer gem with RubyGems as follows:
-
- gem install fast-stemmer
-
-If you would like to speed up LSI classification by at least 10x, please install the following libraries:
-GNU GSL:: http://www.gnu.org/software/gsl
-rb-gsl:: http://rb-gsl.rubyforge.org
-
-Notice that LSI will work without these libraries, but as soon as they are installed, Classifier will make use of them. No configuration changes are needed, we like to keep things ridiculously easy for you.
-
-## Bayes
-
-A Bayesian classifier by Lucas Carlson. Bayesian Classifiers are accurate, fast, and have modest memory requirements.
-
-### Usage
-
- require 'classifier'
- b = Classifier::Bayes.new 'Interesting', 'Uninteresting'
- b.train_interesting "here are some good words. I hope you love them"
- b.train_uninteresting "here are some bad words, I hate you"
- b.classify "I hate bad words and you" # returns 'Uninteresting'
-
- require 'madeleine'
- m = SnapshotMadeleine.new("bayes_data") {
- Classifier::Bayes.new 'Interesting', 'Uninteresting'
- }
- m.system.train_interesting "here are some good words. I hope you love them"
- m.system.train_uninteresting "here are some bad words, I hate you"
- m.take_snapshot
- m.system.classify "I love you" # returns 'Interesting'
-
-Using Madeleine, your application can persist the learned data over time.
-
-### Bayesian Classification
-
-* http://www.process.com/precisemail/bayesian_filtering.htm
-* http://en.wikipedia.org/wiki/Bayesian_filtering
-* http://www.paulgraham.com/spam.html
-
-## LSI
-
-A Latent Semantic Indexer by David Fayram. Latent Semantic Indexing engines
-are not as fast or as small as Bayesian classifiers, but are more flexible, providing
-fast search and clustering detection as well as semantic analysis of the text that
-theoretically simulates human learning.
-
-### Usage
-
- require 'classifier'
- lsi = Classifier::LSI.new
- strings = [ ["This text deals with dogs. Dogs.", :dog],
- ["This text involves dogs too. Dogs! ", :dog],
- ["This text revolves around cats. Cats.", :cat],
- ["This text also involves cats. Cats!", :cat],
- ["This text involves birds. Birds.",:bird ]]
- strings.each {|x| lsi.add_item x.first, x.last}
-
- lsi.search("dog", 3)
- # returns => ["This text deals with dogs. Dogs.", "This text involves dogs too. Dogs! ",
- # "This text also involves cats. Cats!"]
-
- lsi.find_related(strings[2], 2)
- # returns => ["This text revolves around cats. Cats.", "This text also involves cats. Cats!"]
-
- lsi.classify "This text is also about dogs!"
- # returns => :dog
-
-Please see the Classifier::LSI documentation for more information. It is possible to index, search and classify
-with more than just simple strings.
-
-### Latent Semantic Indexing
-
-* http://www.c2.com/cgi/wiki?LatentSemanticIndexing
-* http://www.chadfowler.com/index.cgi/Computing/LatentSemanticIndexing.rdoc
-* http://en.wikipedia.org/wiki/Latent_semantic_analysis
-
-## Authors
-
-* Lucas Carlson (lucas@rufy.com)
-* David Fayram II (dfayram@gmail.com)
-* Cameron McBride (cameron.mcbride@gmail.com)
-* Ivan Acosta-Rubio (ivan@softwarecriollo.com)
-
-This library is released under the terms of the GNU LGPL. See LICENSE for more details.
-
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b783fdf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,202 @@
+# Classifier
+
+[![Gem Version](https://badge.fury.io/rb/classifier.svg)](https://badge.fury.io/rb/classifier)
+[![CI](https://github.com/cardmagic/classifier/actions/workflows/ruby.yml/badge.svg)](https://github.com/cardmagic/classifier/actions/workflows/ruby.yml)
+[![License: LGPL](https://img.shields.io/badge/License-LGPL_2.1-blue.svg)](https://opensource.org/licenses/LGPL-2.1)
+
+Text classification in Ruby. Five algorithms, native performance, streaming support.
+
+**[Documentation](https://rubyclassifier.com/docs)** · **[Tutorials](https://rubyclassifier.com/docs/tutorials)** · **[API Reference](https://rubydoc.info/gems/classifier)**
+
+## Why This Library?
+
+| | This Gem | Other Forks |
+|:--|:--|:--|
+| **Algorithms** | ✅ 5 classifiers | ❌ 2 only |
+| **Incremental LSI** | ✅ Brand's algorithm (no rebuild) | ❌ Full SVD rebuild on every add |
+| **LSI Performance** | ✅ Native C extension (5-50x faster) | ❌ Pure Ruby or requires GSL |
+| **Streaming** | ✅ Train on multi-GB datasets | ❌ Must load all data in memory |
+| **Persistence** | ✅ Pluggable (file, Redis, S3, SQL, Custom) | ❌ Marshal only |
+
+## Installation
+
+```ruby
+gem 'classifier'
+```
+
+Or install via Homebrew for CLI-only usage:
+
+```bash
+brew install cardmagic/tap/classifier
+```
+
+## Command Line
+
+Classify text instantly with pre-trained models—no coding required:
+
+```bash
+# Detect spam
+classifier -r sms-spam-filter "You won a free iPhone"
+# => spam
+
+# Analyze sentiment
+classifier -r imdb-sentiment "This movie was absolutely amazing"
+# => positive
+
+# Detect emotions
+classifier -r emotion-detection "I am so happy today"
+# => joy
+
+# List all available models
+classifier models
+```
+
+Train your own model:
+
+```bash
+# Train from files
+classifier train positive reviews/good/*.txt
+classifier train negative reviews/bad/*.txt
+
+# Classify new text
+classifier "Great product, highly recommend"
+# => positive
+```
+
+[CLI Guide →](https://rubyclassifier.com/docs/guides/cli/basics)
+
+### Claude Code Plugin
+
+Install as a plugin to get skills (auto-invoked) and slash commands:
+
+```bash
+# Add the marketplace
+claude plugin marketplace add cardmagic/ai-marketplace
+
+# Install the plugin
+claude plugin install classifier@cardmagic
+```
+
+This gives you:
+- **Skill**: Claude automatically classifies text when you ask about spam, sentiment, or emotions
+- **Slash commands**: `/classifier:classify`, `/classifier:train`, `/classifier:models`
+
+## Quick Start
+
+### Bayesian
+
+```ruby
+classifier = Classifier::Bayes.new(:spam, :ham)
+classifier.train(spam: "Buy viagra cheap pills now")
+classifier.train(spam: "You won million dollars prize")
+classifier.train(ham: ["Meeting tomorrow at 3pm", "Quarterly report attached"])
+classifier.classify("Cheap pills!") # => "Spam"
+```
+[Bayesian Guide →](https://rubyclassifier.com/docs/guides/bayes/basics)
+
+### Logistic Regression
+
+```ruby
+classifier = Classifier::LogisticRegression.new(:positive, :negative)
+classifier.train(positive: "love amazing great wonderful")
+classifier.train(negative: "hate terrible awful bad")
+classifier.classify("I love it!") # => "Positive"
+```
+[Logistic Regression Guide →](https://rubyclassifier.com/docs/guides/logisticregression/basics)
+
+### LSI (Latent Semantic Indexing)
+
+```ruby
+lsi = Classifier::LSI.new
+lsi.add(dog: "dog puppy canine bark fetch", cat: "cat kitten feline meow purr")
+lsi.classify("My puppy barks") # => "dog"
+```
+[LSI Guide →](https://rubyclassifier.com/docs/guides/lsi/basics)
+
+### k-Nearest Neighbors
+
+```ruby
+knn = Classifier::KNN.new(k: 3)
+%w[laptop coding software developer programming].each { |w| knn.add(tech: w) }
+%w[football basketball soccer goal team].each { |w| knn.add(sports: w) }
+knn.classify("programming code") # => "tech"
+```
+[k-Nearest Neighbors Guide →](https://rubyclassifier.com/docs/guides/knn/basics)
+
+### TF-IDF
+
+```ruby
+tfidf = Classifier::TFIDF.new
+tfidf.fit(["Ruby is great", "Python is great", "Ruby on Rails"])
+tfidf.transform("Ruby programming") # => {:rubi => 1.0}
+```
+[TF-IDF Guide →](https://rubyclassifier.com/docs/guides/tfidf/basics)
+
+## Key Features
+
+### Incremental LSI
+
+Add documents without rebuilding the entire index—400x faster for streaming data:
+
+```ruby
+lsi = Classifier::LSI.new(incremental: true)
+lsi.add(tech: ["Ruby is elegant", "Python is popular"])
+lsi.build_index
+
+# These use Brand's algorithm—no full rebuild
+lsi.add(tech: "Go is fast")
+lsi.add(tech: "Rust is safe")
+```
+
+[Learn more →](https://rubyclassifier.com/docs/guides/lsi/basics)
+
+### Persistence
+
+```ruby
+classifier.storage = Classifier::Storage::File.new(path: "model.json")
+classifier.save
+
+loaded = Classifier::Bayes.load(storage: classifier.storage)
+```
+
+[Learn more →](https://rubyclassifier.com/docs/guides/persistence/basics)
+
+### Streaming Training
+
+```ruby
+classifier.train_from_stream(:spam, File.open("spam_corpus.txt"))
+```
+
+[Learn more →](https://rubyclassifier.com/docs/tutorials/streaming-training)
+
+## Performance
+
+Native C extension provides 5-50x speedup for LSI operations:
+
+| Documents | Speedup |
+|-----------|---------|
+| 10 | 25x |
+| 20 | 50x |
+
+```bash
+rake benchmark:compare # Run your own comparison
+```
+
+## Development
+
+```bash
+bundle install
+rake compile # Build native extension
+rake test # Run tests
+```
+
+## Authors
+
+- **Lucas Carlson** - lucas@rufy.com
+- **David Fayram II** - dfayram@gmail.com
+- **Cameron McBride** - cameron.mcbride@gmail.com
+- **Ivan Acosta-Rubio** - ivan@softwarecriollo.com
+
+## License
+
+[LGPL 2.1](LICENSE)
diff --git a/Rakefile b/Rakefile
index feaa506..e65dcd7 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,84 +1,152 @@
-require 'rubygems'
require 'rake'
require 'rake/testtask'
require 'rdoc/task'
-require 'rake/contrib/rubyforgepublisher'
-desc "Default Task"
-task :default => [ :test ]
+# Try to load rake-compiler for native extension support
+begin
+ require 'rake/extensiontask'
+ Rake::ExtensionTask.new('classifier_ext') do |ext|
+ ext.lib_dir = 'lib/classifier'
+ ext.ext_dir = 'ext/classifier'
+ end
+ HAVE_EXTENSION = true
+rescue LoadError
+ HAVE_EXTENSION = false
+end
+
+desc 'Default Task'
+task default: HAVE_EXTENSION ? %i[compile test] : [:test]
# Run the unit tests
-desc "Run all unit tests"
-Rake::TestTask.new("test") { |t|
- t.libs << "lib"
+desc 'Run all unit tests'
+Rake::TestTask.new('test') do |t|
+ t.libs << 'lib'
t.pattern = 'test/*/*_test.rb'
t.verbose = true
-}
+end
# Make a console, useful when working on tests
-desc "Generate a test console"
+desc 'Generate a test console'
task :console do
- verbose( false ) { sh "irb -I lib/ -r 'classifier'" }
+ verbose(false) { sh "irb -I lib/ -r 'classifier'" }
end
# Genereate the RDoc documentation
-desc "Create documentation"
-Rake::RDocTask.new("doc") { |rdoc|
- rdoc.title = "Ruby Classifier - Bayesian and LSI classification library"
+desc 'Create documentation'
+Rake::RDocTask.new('doc') do |rdoc|
+ rdoc.title = 'Ruby Classifier - Bayesian and LSI classification library'
rdoc.rdoc_dir = 'html'
- rdoc.rdoc_files.include('README')
+ rdoc.rdoc_files.include('README.md')
rdoc.rdoc_files.include('lib/**/*.rb')
-}
-
-# Genereate the package
-spec = Gem::Specification.new do |s|
-
- #### Basic information.
-
- s.name = 'classifier'
- s.version = PKG_VERSION
- s.summary = <<-EOF
- A general classifier module to allow Bayesian and other types of classifications.
- EOF
- s.description = <<-EOF
- A general classifier module to allow Bayesian and other types of classifications.
- EOF
-
- #### Which files are to be included in this gem? Everything! (Except CVS directories.)
-
- s.files = PKG_FILES
-
- #### Load-time details: library and application (you will need one or both).
-
- s.require_path = 'lib'
- s.autorequire = 'classifier'
-
- #### Documentation and testing.
-
- s.has_rdoc = true
-
- #### Dependencies and requirements.
+end
- s.add_dependency('fast-stemmer', '>= 1.0.0')
- s.requirements << "A porter-stemmer module to split word stems."
+# Benchmarks
+desc 'Run LSI benchmark with current configuration'
+task :benchmark do
+ ruby 'benchmark/lsi_benchmark.rb'
+end
- #### Author and project details.
- s.author = "Lucas Carlson"
- s.email = "lucas@rufy.com"
- s.homepage = "http://classifier.rufy.com/"
+desc 'Run LSI benchmark comparing GSL vs Native Ruby'
+task 'benchmark:compare' do
+ ruby 'benchmark/lsi_benchmark.rb --compare'
end
-desc "Report code statistics (KLOCs, etc) from the application"
+desc 'Report code statistics (KLOCs, etc) from the application'
task :stats do
require 'code_statistics'
CodeStatistics.new(
- ["Library", "lib"],
- ["Units", "test"]
+ %w[Library lib],
+ %w[Units test]
).to_s
end
-desc "Publish new documentation"
+desc 'Publish new documentation'
task :publish do
- `ssh rufy update-classifier-doc`
- Rake::RubyForgePublisher.new('classifier', 'cardmagic').upload
+ `ssh rufy update-classifier-doc`
+ Rake::RubyForgePublisher.new('classifier', 'cardmagic').upload
+end
+
+# C Code Coverage tasks
+namespace :coverage do # rubocop:disable Metrics/BlockLength
+ desc 'Clean C coverage data files'
+ task :clean do
+ FileUtils.rm_f(Dir.glob('ext/classifier/**/*.gcda'))
+ FileUtils.rm_f(Dir.glob('ext/classifier/**/*.gcno'))
+ FileUtils.rm_f(Dir.glob('tmp/**/classifier/**/*.gcda'))
+ FileUtils.rm_f(Dir.glob('tmp/**/classifier/**/*.gcno'))
+ FileUtils.rm_rf('coverage/c')
+ end
+
+ desc 'Compile C extension with coverage instrumentation'
+ task :compile do
+ ENV['COVERAGE'] = '1'
+ Rake::Task['clobber'].invoke if Rake::Task.task_defined?('clobber')
+ Rake::Task['compile'].reenable
+ Rake::Task['compile'].invoke
+ end
+
+ desc 'Generate C coverage report using lcov'
+ task :report do # rubocop:disable Metrics/BlockLength
+ project_root = File.expand_path(__dir__)
+ ext_dir = File.join(project_root, 'ext/classifier')
+ # Find the directory containing .gcda files (build directory varies by platform/Ruby version)
+ tmp_ext_dir = Dir.glob('tmp/**/classifier_ext/**/*.gcda').first&.then { |f| File.dirname(f) }
+ coverage_dir = 'coverage/c'
+
+ unless tmp_ext_dir
+ puts 'No coverage data found. Run tests with coverage first.'
+ next
+ end
+
+ FileUtils.mkdir_p(coverage_dir)
+
+ # Run gcov manually in the build directory to generate .gcov files
+ Dir.chdir(tmp_ext_dir) do
+ # Find all source files and run gcov on them
+ gcda_files = Dir.glob('*.gcda')
+ gcda_files.each do |gcda|
+ # Source file is in ext/classifier, referenced via relative path in the gcno
+ sh "gcov -o . #{gcda} 2>/dev/null || true"
+ end
+ end
+
+ # Capture coverage data with base directory for source resolution
+ sh "lcov --capture --directory #{tmp_ext_dir} --base-directory #{ext_dir} " \
+ "--output-file #{coverage_dir}/coverage.info " \
+ '--ignore-errors inconsistent,gcov,source 2>&1 || true'
+
+ if File.exist?("#{coverage_dir}/coverage.info") && File.size("#{coverage_dir}/coverage.info").positive?
+ # Filter out system headers
+ sh "lcov --remove #{coverage_dir}/coverage.info '/usr/*' '*/ruby/*' " \
+ "--output-file #{coverage_dir}/coverage.info --ignore-errors unused 2>/dev/null || true"
+
+ # Fix source paths: the gcov relative paths resolve incorrectly
+ # Substitute wrong paths with correct absolute paths
+ info_content = File.read("#{coverage_dir}/coverage.info")
+ info_content.gsub!(%r{SF:.*/ext/classifier/}, "SF:#{ext_dir}/")
+ File.write("#{coverage_dir}/coverage.info", info_content)
+
+ # Generate HTML report
+ sh "genhtml #{coverage_dir}/coverage.info --output-directory #{coverage_dir}/html " \
+ "--prefix #{project_root} --ignore-errors unmapped,source 2>&1 || true"
+
+ puts "\nC coverage report generated at: #{coverage_dir}/html/index.html"
+
+ # Print summary
+ sh "lcov --summary #{coverage_dir}/coverage.info 2>/dev/null || true"
+ else
+ puts 'Coverage data capture failed. Check that tests exercise the C extension.'
+ end
+ end
+
+ desc 'Run tests and generate C coverage report'
+ task :run do
+ Rake::Task['coverage:clean'].invoke
+ Rake::Task['coverage:compile'].invoke
+ Rake::Task['test'].invoke
+ Rake::Task['coverage:report'].invoke
+ end
end
+
+desc 'Run C code coverage (alias for coverage:run)'
+task 'coverage:c' => 'coverage:run'
diff --git a/Steepfile b/Steepfile
new file mode 100644
index 0000000..d5120ce
--- /dev/null
+++ b/Steepfile
@@ -0,0 +1,27 @@
+D = Steep::Diagnostic
+
+target :lib do
+ signature 'sig'
+
+ check 'lib'
+
+ # Stdlib dependencies for CLI
+ library 'fileutils'
+ library 'uri'
+ library 'net-http'
+ library 'json'
+
+ # Strict mode: report methods without type annotations
+ configure_code_diagnostics(D::Ruby.strict)
+
+ # Ignore files that patch stdlib classes (these cause conflicts)
+ ignore 'lib/classifier/extensions/vector.rb'
+ ignore 'lib/classifier/extensions/vector_serialize.rb'
+ ignore 'lib/classifier/extensions/string.rb'
+ ignore 'lib/classifier/extensions/word_hash.rb'
+
+ # Ignore LSI files for now due to complex GSL/Matrix dual-mode typing
+ ignore 'lib/classifier/lsi.rb'
+ ignore 'lib/classifier/lsi/content_node.rb'
+ ignore 'lib/classifier/lsi/summary.rb'
+end
diff --git a/benchmark/lsi_benchmark.rb b/benchmark/lsi_benchmark.rb
new file mode 100755
index 0000000..d3aedc5
--- /dev/null
+++ b/benchmark/lsi_benchmark.rb
@@ -0,0 +1,295 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# LSI Benchmark Script
+# Compares performance between native C extension and pure Ruby
+#
+# Usage:
+# rake benchmark # Run with current configuration
+# rake benchmark:compare # Run both native C and pure Ruby, show comparison
+# NATIVE_VECTOR=true rake benchmark # Force pure Ruby mode
+#
+# The native C extension provides 5-10x speedup over pure Ruby.
+
+$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
+
+require 'benchmark'
+require 'json'
+
+# Sample documents with diverse vocabulary to avoid SVD dimension issues
+CATEGORIES = {
+ dog: [
+ 'Dogs are loyal companions who love to play fetch in the park.',
+ 'My golden retriever enjoys swimming and chasing squirrels.',
+ 'Puppies need training and patience to become well-behaved dogs.',
+ 'The veterinarian recommended a new diet for my aging dog.'
+ ],
+ cat: [
+ 'Cats are independent creatures who enjoy napping in sunny spots.',
+ 'My tabby cat loves to chase laser pointers around the room.',
+ 'Kittens are playful and curious about everything they see.',
+ 'The feline groomed herself after eating her favorite treats.'
+ ],
+ bird: [
+ 'Parrots can learn to mimic human speech with practice.',
+ 'The cardinal built a nest in our backyard oak tree.',
+ 'Hummingbirds visit our feeder every morning for nectar.',
+ 'Owls are nocturnal hunters with excellent night vision.'
+ ],
+ fish: [
+ 'Tropical aquariums require careful temperature and pH monitoring.',
+ 'Salmon swim upstream to spawn in their birthplace rivers.',
+ 'The coral reef teems with colorful marine life and fish.',
+ 'Goldfish are popular pets that can live for many years.'
+ ],
+ horse: [
+ 'Thoroughbreds are bred for speed and excel at racing.',
+ 'The rancher trained wild mustangs using gentle methods.',
+ 'Equestrian sports include dressage, jumping, and polo.',
+ 'Horses communicate through body language and vocalizations.'
+ ]
+}.freeze
+
+def generate_documents(count)
+ docs = []
+ categories = CATEGORIES.keys
+
+ count.times do |i|
+ category = categories[i % categories.size]
+ base_texts = CATEGORIES[category]
+ text = base_texts[i % base_texts.size]
+ docs << ["#{text} Number #{i}.", category]
+ end
+ docs
+end
+
+def run_benchmark(doc_count)
+ require 'classifier'
+
+ backend_name = Classifier::LSI.backend == :native ? 'Native C Extension' : 'Pure Ruby'
+ docs = generate_documents(doc_count)
+
+ puts "\n#{'=' * 60}"
+ puts "LSI Benchmark: #{doc_count} documents (#{backend_name})"
+ puts '=' * 60
+
+ results = {}
+
+ begin
+ # Benchmark: Adding items (without auto-rebuild)
+ lsi = nil
+ results[:add_items] = Benchmark.measure do
+ lsi = Classifier::LSI.new(auto_rebuild: false)
+ docs.each { |doc, category| lsi.add_item(doc, category) }
+ end
+
+ # Benchmark: Building index
+ results[:build_index] = Benchmark.measure do
+ lsi.build_index
+ end
+
+ # Benchmark: Classification (100 iterations)
+ test_doc = 'My puppy loves playing fetch and going for walks.'
+ results[:classify] = Benchmark.measure do
+ 100.times { lsi.classify(test_doc) }
+ end
+
+ # Benchmark: Search (100 iterations)
+ results[:search] = Benchmark.measure do
+ 100.times { lsi.search('loyal companion pet', 5) }
+ end
+
+ # Benchmark: Find related (100 iterations)
+ sample_doc = docs.first[0]
+ results[:find_related] = Benchmark.measure do
+ 100.times { lsi.find_related(sample_doc, 5) }
+ end
+
+ # Print results
+ puts format("\n%-20s %10s %10s %10s", 'Operation', 'User', 'System', 'Total')
+ puts '-' * 52
+ results.each do |name, bm|
+ puts format("%-20s %10.4f %10.4f %10.4f", name, bm.utime, bm.stime, bm.total)
+ end
+
+ total = results.values.sum(&:total)
+ puts '-' * 52
+ puts format("%-20s %10s %10s %10.4f", 'TOTAL', '', '', total)
+
+ { results: results, backend: Classifier::LSI.backend, success: true }
+ rescue Math::DomainError, ExceptionForMatrix::ErrDimensionMismatch => e
+ puts "\nFAILED: SVD numerical instability"
+ puts "Error: #{e.class.name} - #{e.message}"
+ { results: {}, backend: :ruby, success: false, error: e.message }
+ end
+end
+
+def run_single
+ sizes = [5, 10, 15, 20]
+ failed = false
+
+ sizes.each do |size|
+ result = run_benchmark(size)
+ if !result[:success]
+ failed = true
+ break
+ end
+ end
+
+ if failed
+ puts "\n" + '=' * 60
+ puts 'Note: SVD may have numerical stability limits with very large datasets.'
+ end
+end
+
+def run_subprocess(docs_json, env_vars = {})
+ lib_path = File.expand_path('../lib', __dir__)
+
+ # Build environment hash
+ env = env_vars.transform_keys(&:to_s)
+
+ popen_block = lambda do
+ IO.popen([env, RbConfig.ruby, '-I', lib_path, '-W0', '-e', <<~'RUBY'], 'r+')
+ require 'benchmark'
+ require 'json'
+ require 'classifier'
+
+ docs = JSON.parse($stdin.read)
+ lsi = Classifier::LSI.new(auto_rebuild: false)
+ docs.each { |doc, cat| lsi.add_item(doc, cat.to_sym) }
+
+ times = {}
+ times[:add_items] = 0
+ times[:backend] = Classifier::LSI.backend.to_s
+
+ times[:build_index] = Benchmark.measure { lsi.build_index }.total
+
+ test_doc = 'My puppy loves playing fetch and going for walks.'
+ times[:classify] = Benchmark.measure { 100.times { lsi.classify(test_doc) } }.total
+ times[:search] = Benchmark.measure { 100.times { lsi.search('loyal companion pet', 5) } }.total
+
+ sample = docs.first[0]
+ times[:find_related] = Benchmark.measure { 100.times { lsi.find_related(sample, 5) } }.total
+
+ print Marshal.dump(times)
+ RUBY
+ end
+
+ io = if defined?(Bundler)
+ Bundler.with_unbundled_env(&popen_block)
+ else
+ popen_block.call
+ end
+
+ begin
+ io.write(docs_json)
+ io.close_write
+ output = io.read
+ { available: true, times: Marshal.load(output) }
+ rescue StandardError => e
+ { available: false, error: e }
+ ensure
+ io.close
+ end
+end
+
+def run_comparison
+ sizes = [5, 10, 15, 20]
+
+ puts "#{'#' * 70}"
+ puts '# LSI BENCHMARK: Native C Extension vs Pure Ruby Comparison'
+ puts "#{'#' * 70}"
+
+ ruby_results = {}
+ native_results = {}
+
+ # Generate documents once for consistency
+ doc_sets = sizes.map { |s| [s, generate_documents(s)] }.to_h
+
+ # Run pure Ruby benchmarks in subprocess
+ puts "\n>>> Running Pure Ruby benchmarks..."
+ sizes.each do |size|
+ docs_json = JSON.generate(doc_sets[size])
+ result = run_subprocess(docs_json, NATIVE_VECTOR: 'true', SUPPRESS_LSI_WARNING: 'true')
+
+ if result[:error]
+ puts " #{size} docs: FAILED (#{result[:error].class.name.split('::').last})"
+ ruby_results[size] = nil
+ else
+ ruby_results[size] = result[:times]
+ puts " #{size} docs: OK (backend: #{result[:times][:backend]})"
+ end
+ end
+
+ # Run native C extension benchmarks in subprocess
+ puts "\n>>> Running Native C Extension benchmarks..."
+ sizes.each do |size|
+ docs_json = JSON.generate(doc_sets[size])
+ result = run_subprocess(docs_json, SUPPRESS_LSI_WARNING: 'true')
+
+ if result[:error]
+ puts " #{size} docs: FAILED (#{result[:error].class.name.split('::').last})"
+ native_results[size] = nil
+ else
+ native_results[size] = result[:times]
+ puts " #{size} docs: OK (backend: #{result[:times][:backend]})"
+ end
+ end
+
+ # Print comparison
+ puts "\n#{'=' * 70}"
+ puts 'COMPARISON SUMMARY (seconds, lower is better)'
+ puts '=' * 70
+
+ operations = %i[build_index classify search find_related]
+
+ sizes.each do |size|
+ ruby = ruby_results[size]
+ native = native_results[size]
+
+ next unless ruby || native
+
+ puts "\n--- #{size} Documents ---"
+ puts format("%-20s %12s %12s %12s", 'Operation', 'Pure Ruby', 'Native C', 'Speedup')
+ puts '-' * 58
+
+ operations.each do |op|
+ ruby_time = ruby ? ruby[op] : nil
+ native_time = native ? native[op] : nil
+
+ ruby_str = ruby_time ? format('%0.4f', ruby_time) : 'N/A'
+ native_str = native_time ? format('%0.4f', native_time) : 'N/A'
+
+ speedup = if ruby_time && native_time && native_time > 0
+ format('%0.1fx', ruby_time / native_time)
+ else
+ 'N/A'
+ end
+
+ puts format("%-20s %12s %12s %12s", op, ruby_str, native_str, speedup)
+ end
+
+ if ruby && native
+ ruby_total = operations.sum { |op| ruby[op] }
+ native_total = operations.sum { |op| native[op] }
+ speedup = native_total > 0 ? ruby_total / native_total : 0
+ puts '-' * 58
+ puts format("%-20s %12.4f %12.4f %11.1fx", 'TOTAL', ruby_total, native_total, speedup)
+ end
+ end
+
+ puts "\n" + '=' * 70
+ if native_results.values.all? { |v| v.nil? || v[:backend] == 'ruby' }
+ puts 'Note: Native C extension not available. Using pure Ruby fallback.'
+ puts ' Compile with: rake compile'
+ end
+end
+
+# Main
+if $PROGRAM_NAME == __FILE__
+ if ARGV.include?('--compare')
+ run_comparison
+ else
+ run_single
+ end
+end
diff --git a/checksums.yaml.gz b/checksums.yaml.gz
deleted file mode 100644
index 43d6112..0000000
--- a/checksums.yaml.gz
+++ /dev/null
Binary files differ
diff --git a/classifier.gemspec b/classifier.gemspec
new file mode 100644
index 0000000..9ebfbbe
--- /dev/null
+++ b/classifier.gemspec
@@ -0,0 +1,36 @@
+require_relative 'lib/classifier/version'
+
+Gem::Specification.new do |s|
+ s.name = 'classifier'
+ s.version = Classifier::VERSION
+ s.summary = 'Text classification with Bayesian, LSI, Logistic Regression, kNN, and TF-IDF vectorization.'
+ s.description = 'A Ruby library for text classification featuring Naive Bayes, LSI (Latent Semantic Indexing), ' \
+ 'Logistic Regression, and k-Nearest Neighbors classifiers. Includes TF-IDF vectorization, ' \
+ 'streaming/incremental training, pluggable persistence backends, thread safety, and a native ' \
+ 'C extension for fast LSI operations.'
+ s.author = 'Lucas Carlson'
+ s.email = 'lucas@rufy.com'
+ s.homepage = 'https://rubyclassifier.com'
+ s.metadata = {
+ 'documentation_uri' => 'https://rubyclassifier.com/docs',
+ 'source_code_uri' => 'https://github.com/cardmagic/classifier',
+ 'bug_tracker_uri' => 'https://github.com/cardmagic/classifier/issues',
+ 'changelog_uri' => 'https://github.com/cardmagic/classifier/releases'
+ }
+ s.required_ruby_version = '>= 3.1'
+ s.files = Dir['{lib,sig,exe}/**/*.{rb,rbs}', 'ext/**/*.{c,h,rb}', 'exe/*', 'bin/*', 'LICENSE', '*.md', 'test/*']
+ s.bindir = 'exe'
+ s.executables = ['classifier']
+ s.extensions = ['ext/classifier/extconf.rb']
+ s.license = 'LGPL'
+
+ s.add_dependency 'fast-stemmer', '~> 1.0'
+ s.add_dependency 'mutex_m', '~> 0.2'
+ s.add_dependency 'rake'
+ s.add_dependency 'matrix'
+ s.add_development_dependency 'minitest'
+ s.add_development_dependency 'rbs-inline'
+ s.add_development_dependency 'rdoc'
+ s.add_development_dependency 'rake-compiler'
+ s.add_development_dependency 'webmock'
+end
diff --git a/cloving.json b/cloving.json
new file mode 100644
index 0000000..bf81615
--- /dev/null
+++ b/cloving.json
@@ -0,0 +1,27 @@
+{
+ "languages": [
+ {
+ "name": "Ruby",
+ "primary": true,
+ "directory": "lib",
+ "extension": ".rb"
+ }
+ ],
+ "frameworks": [],
+ "testingFrameworks": [
+ {
+ "name": "Test::Unit",
+ "type": "Testing framework",
+ "directory": "test"
+ }
+ ],
+ "buildTools": [
+ {
+ "name": "Rake",
+ "type": "Build tool"
+ }
+ ],
+ "packageManager": "Bundler",
+ "linters": [],
+ "projectType": "Ruby Gem"
+} \ No newline at end of file
diff --git a/commands/classify.md b/commands/classify.md
new file mode 100644
index 0000000..ad55ce0
--- /dev/null
+++ b/commands/classify.md
@@ -0,0 +1,32 @@
+# /classifier:classify
+
+Classify text using a pre-trained or custom model.
+
+## Usage
+
+```
+/classifier:classify <model> <text>
+```
+
+## Examples
+
+```
+/classifier:classify sms-spam-filter "Congratulations! You won $1000"
+/classifier:classify imdb-sentiment "This movie was fantastic"
+/classifier:classify emotion-detection "I feel so frustrated today"
+```
+
+## Instructions
+
+Run the classifier command with the specified model and text:
+
+```bash
+classifier -r "$model" "$text"
+```
+
+If no model is specified, list available models with `classifier models` and ask the user which one to use.
+
+Report the classification result clearly, e.g.:
+- "The text was classified as: **spam**"
+- "Sentiment: **positive**"
+- "Detected emotion: **anger**"
diff --git a/commands/models.md b/commands/models.md
new file mode 100644
index 0000000..c55112e
--- /dev/null
+++ b/commands/models.md
@@ -0,0 +1,24 @@
+# /classifier:models
+
+List all available pre-trained classification models.
+
+## Usage
+
+```
+/classifier:models
+```
+
+## Instructions
+
+Run:
+
+```bash
+classifier models
+```
+
+Present the results in a formatted table showing:
+- Model name
+- Description/use case
+- Categories it can classify
+
+Suggest relevant models based on the user's context if known.
diff --git a/commands/train.md b/commands/train.md
new file mode 100644
index 0000000..34ccc83
--- /dev/null
+++ b/commands/train.md
@@ -0,0 +1,33 @@
+# /classifier:train
+
+Train a classifier with labeled examples.
+
+## Usage
+
+```
+/classifier:train <category> <text or file pattern>
+```
+
+## Examples
+
+```
+/classifier:train spam "Buy cheap viagra now"
+/classifier:train ham "Meeting scheduled for tomorrow"
+/classifier:train positive reviews/good/*.txt
+/classifier:train negative reviews/bad/*.txt
+```
+
+## Instructions
+
+Run the classifier train command:
+
+```bash
+classifier train "$category" "$text_or_pattern"
+```
+
+After training, inform the user they can:
+1. Add more training examples with additional `/classifier:train` commands
+2. Classify new text with `classifier "text to classify"`
+3. Save the model with `classifier save model-name.json`
+
+For best results, recommend balanced training data across all categories.
diff --git a/debian/changelog b/debian/changelog
index 90d9b21..92c2a96 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,39 @@
+ruby-classifier (2.6.0-1) unstable; urgency=medium
+
+ * Team upload.
+
+ [ Debian Janitor ]
+ * Set upstream metadata fields: Bug-Submit.
+ * Update standards version to 4.4.1, no changes needed.
+ * Update watch file format version to 4.
+ * Bump debhelper from old 12 to 13.
+ * Update standards version to 4.5.1, no changes needed.
+ * Update standards version to 4.6.1, no changes needed.
+
+ [ Cédric Boutillier ]
+ * Update team name
+ * Add .gitattributes to keep unwanted files out of the source package
+
+ [ Lucas Nussbaum ]
+ * debian/gbp.conf: Add for DEP-14
+ * debian/gbp.conf: remove trailing empty lines
+ * debian/.gitattributes: remove
+ * debian/salsa-ci.yml: use team-specific include
+
+ [ Simon Quigley ]
+ * Upgrade the watch file to version 5.
+ * New upstream release.
+ * Refresh the upstream metadata.
+ * Refresh the rules file.
+ * Drop {XS,XB}-Ruby-Versions from control.
+ * Update Standards-Version to 4.7.4.
+ * Bump debhelper-compat to 14, dropping ${misc:Depends},
+ ${shlibs:Depends}, and ${ruby:Depends} from runtime dependencies.
+ * Mark the package as Architecture: any.
+ * Add Forwarded: not-needed to the patch.
+
+ -- Simon Quigley <tsimonq2@debian.org> Fri, 26 Jun 2026 15:36:16 -0500
+
ruby-classifier (1.3.4-4) unstable; urgency=medium
* Team upload
diff --git a/debian/control b/debian/control
index 119d6f9..bfe535b 100644
--- a/debian/control
+++ b/debian/control
@@ -1,26 +1,20 @@
Source: ruby-classifier
Section: ruby
-Priority: optional
-Maintainer: Debian Ruby Extras Maintainers <pkg-ruby-extras-maintainers@lists.alioth.debian.org>
+Maintainer: Debian Ruby Team <pkg-ruby-extras-maintainers@lists.alioth.debian.org>
Uploaders: Youhei SASAKI <uwabami@gfd-dennou.org>
-Build-Depends: debhelper-compat (= 12),
+Build-Depends: debhelper-compat (= 14),
gem2deb,
rake,
ruby-fast-stemmer
-Standards-Version: 4.4.0
+Standards-Version: 4.7.4
Vcs-Git: https://salsa.debian.org/ruby-team/ruby-classifier.git
Vcs-Browser: https://salsa.debian.org/ruby-team/ruby-classifier
Homepage: https://github.com/cardmagic/classifier
-XS-Ruby-Versions: all
Testsuite: autopkgtest-pkg-ruby
Package: ruby-classifier
-Architecture: all
-XB-Ruby-Versions: ${ruby:Versions}
-Depends: ruby | ruby-interpreter,
- ruby-fast-stemmer,
- ${misc:Depends},
- ${shlibs:Depends}
+Architecture: any
+Depends: ruby
Recommends: ruby-gsl
Description: Ruby module to allow Bayesian and other types of classifications
Classifier is a general module to allow Bayesian and other types of
diff --git a/debian/copyright b/debian/copyright
index 9d81cc0..b8ce2fb 100644
--- a/debian/copyright
+++ b/debian/copyright
@@ -24,7 +24,10 @@ License: LGPL-2.0+
Public License can be found in "/usr/share/common-licenses/LGPL-2".
Files: debian/*
-Copyright: 2012 Youhei SASAKI <uwabami@gfd-dennou.org>
+Copyright: 2012-2019 Youhei SASAKI <uwabami@gfd-dennou.org>
+ 2014 Jonas Genannt <jonas.genannt@capi2name.de>
+ 2019 Cédric Boutillier <boutil@debian.org>
+ 2026 Simon Quigley <tsimonq2@debian.org>
License: Expat
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
diff --git a/debian/gbp.conf b/debian/gbp.conf
new file mode 100644
index 0000000..6b65fe0
--- /dev/null
+++ b/debian/gbp.conf
@@ -0,0 +1,4 @@
+[DEFAULT]
+debian-branch = debian/latest
+upstream-branch = upstream/latest
+pristine-tar = True
diff --git a/debian/patches/0001-Remove-RubyGems-Depends.patch b/debian/patches/0001-Remove-RubyGems-Depends.patch
index c949630..fdc2a7f 100644
--- a/debian/patches/0001-Remove-RubyGems-Depends.patch
+++ b/debian/patches/0001-Remove-RubyGems-Depends.patch
@@ -1,6 +1,7 @@
From: Youhei SASAKI <uwabami@gfd-dennou.org>
Date: Fri, 11 May 2012 04:06:18 +0900
Subject: Remove RubyGems Depends
+Forwarded: not-needed
Signed-off-by: Youhei SASAKI <uwabami@gfd-dennou.org>
---
@@ -9,8 +10,6 @@ Signed-off-by: Youhei SASAKI <uwabami@gfd-dennou.org>
lib/classifier.rb | 3 +--
3 files changed, 3 insertions(+), 16 deletions(-)
-diff --git a/bin/bayes.rb b/bin/bayes.rb
-index f446b8c..5382d14 100755
--- a/bin/bayes.rb
+++ b/bin/bayes.rb
@@ -1,12 +1,6 @@
@@ -27,8 +26,6 @@ index f446b8c..5382d14 100755
require 'madeleine'
m = SnapshotMadeleine.new(File.expand_path("~/.bayes_data")) {
-diff --git a/bin/summarize.rb b/bin/summarize.rb
-index 4de6ef2..857f0fa 100755
--- a/bin/summarize.rb
+++ b/bin/summarize.rb
@@ -1,12 +1,6 @@
@@ -45,8 +42,6 @@ index 4de6ef2..857f0fa 100755
require 'open-uri'
num = ARGV[1].to_i
-diff --git a/lib/classifier.rb b/lib/classifier.rb
-index 6c99960..5606bc0 100644
--- a/lib/classifier.rb
+++ b/lib/classifier.rb
@@ -24,7 +24,6 @@
@@ -54,8 +49,14 @@ index 6c99960..5606bc0 100644
# License:: LGPL
-require 'rubygems'
+ require 'classifier/version'
+ require 'classifier/errors'
+ require 'classifier/storage'
+@@ -32,7 +31,6 @@ require 'classifier/streaming'
require 'classifier/extensions/string'
+ require 'classifier/extensions/vector'
require 'classifier/bayes'
-require 'classifier/lsi'
-\ No newline at end of file
-+require 'classifier/lsi'
+ require 'classifier/knn'
+ require 'classifier/tfidf'
+ require 'classifier/logistic_regression'
diff --git a/debian/ruby-classifier.docs b/debian/ruby-classifier.docs
index 8d526b9..b43bf86 100644
--- a/debian/ruby-classifier.docs
+++ b/debian/ruby-classifier.docs
@@ -1 +1 @@
-README.markdown
+README.md
diff --git a/debian/rules b/debian/rules
index a9394f9..608e0ad 100755
--- a/debian/rules
+++ b/debian/rules
@@ -1,4 +1,8 @@
#!/usr/bin/make -f
+
+export GEM2DEB_TEST_RUNNER = --check-dependencies
+export DH_RUBY = --gem-install
+
%:
dh $@ --buildsystem=ruby --with ruby
diff --git a/debian/salsa-ci.yml b/debian/salsa-ci.yml
index 33c3a64..7b5d2f7 100644
--- a/debian/salsa-ci.yml
+++ b/debian/salsa-ci.yml
@@ -1,4 +1,3 @@
---
include:
- - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/salsa-ci.yml
- - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/pipeline-jobs.yml
+ - https://salsa.debian.org/ruby-team/meta/raw/master/salsa-ci.yml
diff --git a/debian/upstream/metadata b/debian/upstream/metadata
index e591e1b..1111813 100644
--- a/debian/upstream/metadata
+++ b/debian/upstream/metadata
@@ -1,5 +1,7 @@
---
Archive: GitHub
Bug-Database: https://github.com/cardmagic/classifier/issues
+Bug-Submit: https://github.com/cardmagic/classifier/issues/new
+Changelog: https://github.com/cardmagic/classifier/releases
Repository: https://github.com/cardmagic/classifier.git
Repository-Browse: https://github.com/cardmagic/classifier
diff --git a/debian/watch b/debian/watch
index 1a1ce60..e4dc52b 100644
--- a/debian/watch
+++ b/debian/watch
@@ -1,2 +1,4 @@
-version=3
-https://gemwatch.debian.net/classifier .*/classifier-(.*).tar.gz
+Version: 5
+Template: GitHub
+Owner: cardmagic
+Project: classifier
diff --git a/exe/classifier b/exe/classifier
new file mode 100755
index 0000000..49ecd10
--- /dev/null
+++ b/exe/classifier
@@ -0,0 +1,15 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Force UTF-8 encoding for proper handling of model data and user input
+Encoding.default_external = Encoding::UTF_8
+Encoding.default_internal = Encoding::UTF_8
+
+require 'classifier/cli'
+
+result = Classifier::CLI.new(ARGV).run
+
+warn result[:error] unless result[:error].empty?
+puts result[:output] unless result[:output].empty?
+
+exit result[:exit_code]
diff --git a/ext/classifier/classifier_ext.c b/ext/classifier/classifier_ext.c
new file mode 100644
index 0000000..45ebad4
--- /dev/null
+++ b/ext/classifier/classifier_ext.c
@@ -0,0 +1,26 @@
+/*
+ * classifier_ext.c
+ * Main entry point for the Classifier native linear algebra extension
+ *
+ * This extension provides zero-dependency Vector, Matrix, and SVD
+ * implementations for the Classifier gem's LSI functionality.
+ */
+
+#include "linalg.h"
+
+VALUE mClassifierLinalg;
+VALUE cClassifierVector;
+VALUE cClassifierMatrix;
+
+void Init_classifier_ext(void)
+{
+ /* Define Classifier::Linalg module */
+ VALUE mClassifier = rb_define_module("Classifier");
+ mClassifierLinalg = rb_define_module_under(mClassifier, "Linalg");
+
+ /* Initialize Vector and Matrix classes */
+ Init_vector();
+ Init_matrix();
+ Init_svd();
+ Init_incremental_svd();
+}
diff --git a/ext/classifier/extconf.rb b/ext/classifier/extconf.rb
new file mode 100644
index 0000000..de6f16a
--- /dev/null
+++ b/ext/classifier/extconf.rb
@@ -0,0 +1,15 @@
+require 'mkmf'
+
+# rubocop:disable Style/GlobalVars
+if ENV['COVERAGE']
+ # Coverage flags: disable optimization for accurate line coverage
+ $CFLAGS << ' -O0 -g --coverage -Wall'
+ $LDFLAGS << ' --coverage'
+else
+ # Optimization flags for performance
+ $CFLAGS << ' -O3 -ffast-math -Wall'
+end
+# rubocop:enable Style/GlobalVars
+
+# Create the Makefile
+create_makefile('classifier/classifier_ext')
diff --git a/ext/classifier/incremental_svd.c b/ext/classifier/incremental_svd.c
new file mode 100644
index 0000000..ab390d9
--- /dev/null
+++ b/ext/classifier/incremental_svd.c
@@ -0,0 +1,393 @@
+/*
+ * incremental_svd.c
+ * Native C implementation of Brand's incremental SVD operations
+ *
+ * Provides fast matrix operations for:
+ * - Matrix column extension
+ * - Vertical stacking (vstack)
+ * - Vector subtraction
+ * - Batch document projection
+ */
+
+#include "linalg.h"
+
+/*
+ * Extend a matrix with a new column
+ * Returns a new matrix [M | col] with one additional column
+ */
+CMatrix *cmatrix_extend_column(CMatrix *m, CVector *col)
+{
+ if (m->rows != col->size) {
+ rb_raise(rb_eArgError,
+ "Matrix rows (%ld) must match vector size (%ld)",
+ (long)m->rows, (long)col->size);
+ }
+
+ CMatrix *result = cmatrix_alloc(m->rows, m->cols + 1);
+
+ for (size_t i = 0; i < m->rows; i++) {
+ memcpy(&MAT_AT(result, i, 0), &MAT_AT(m, i, 0), m->cols * sizeof(double));
+ MAT_AT(result, i, m->cols) = col->data[i];
+ }
+
+ return result;
+}
+
+/*
+ * Vertically stack two matrices
+ * Returns a new matrix [top; bottom]
+ */
+CMatrix *cmatrix_vstack(CMatrix *top, CMatrix *bottom)
+{
+ if (top->cols != bottom->cols) {
+ rb_raise(rb_eArgError,
+ "Matrices must have same column count: %ld vs %ld",
+ (long)top->cols, (long)bottom->cols);
+ }
+
+ size_t new_rows = top->rows + bottom->rows;
+ CMatrix *result = cmatrix_alloc(new_rows, top->cols);
+
+ memcpy(result->data, top->data, top->rows * top->cols * sizeof(double));
+ memcpy(result->data + top->rows * top->cols,
+ bottom->data,
+ bottom->rows * bottom->cols * sizeof(double));
+
+ return result;
+}
+
+/*
+ * Vector subtraction: a - b
+ */
+CVector *cvector_subtract(CVector *a, CVector *b)
+{
+ if (a->size != b->size) {
+ rb_raise(rb_eArgError,
+ "Vector sizes must match: %ld vs %ld",
+ (long)a->size, (long)b->size);
+ }
+
+ CVector *result = cvector_alloc(a->size);
+ for (size_t i = 0; i < a->size; i++) {
+ result->data[i] = a->data[i] - b->data[i];
+ }
+ return result;
+}
+
+/*
+ * Batch project multiple vectors onto U matrix
+ * Computes lsi_vector = U^T * raw_vector for each vector
+ * This is the most performance-critical operation for incremental updates
+ */
+void cbatch_project(CMatrix *u, CVector **raw_vectors, size_t num_vectors,
+ CVector **lsi_vectors_out)
+{
+ size_t m = u->rows; /* vocabulary size */
+ size_t k = u->cols; /* rank */
+
+ for (size_t v = 0; v < num_vectors; v++) {
+ CVector *raw = raw_vectors[v];
+ if (raw->size != m) {
+ rb_raise(rb_eArgError,
+ "Vector %ld size (%ld) must match matrix rows (%ld)",
+ (long)v, (long)raw->size, (long)m);
+ }
+
+ CVector *lsi = cvector_alloc(k);
+
+ /* Compute U^T * raw (project onto k-dimensional space) */
+ for (size_t j = 0; j < k; j++) {
+ double sum = 0.0;
+ for (size_t i = 0; i < m; i++) {
+ sum += MAT_AT(u, i, j) * raw->data[i];
+ }
+ lsi->data[j] = sum;
+ }
+
+ lsi_vectors_out[v] = lsi;
+ }
+}
+
+/*
+ * Build the K matrix for Brand's algorithm when rank grows
+ * K = | diag(s) m_vec |
+ * | 0 p_norm |
+ */
+static CMatrix *build_k_matrix_with_growth(CVector *s, CVector *m_vec, double p_norm)
+{
+ size_t k = s->size;
+ CMatrix *result = cmatrix_alloc(k + 1, k + 1);
+
+ /* First k rows: diagonal s values and m_vec in last column */
+ for (size_t i = 0; i < k; i++) {
+ MAT_AT(result, i, i) = s->data[i];
+ MAT_AT(result, i, k) = m_vec->data[i];
+ }
+
+ /* Last row: zeros except p_norm in last position */
+ MAT_AT(result, k, k) = p_norm;
+
+ return result;
+}
+
+/*
+ * Perform one incremental SVD update using Brand's algorithm
+ *
+ * @param u Current U matrix (m x k)
+ * @param s Current singular values (k values)
+ * @param c New document vector (m x 1)
+ * @param max_rank Maximum rank to maintain
+ * @param epsilon Threshold for detecting new directions
+ * @param u_out Output: updated U matrix
+ * @param s_out Output: updated singular values
+ */
+static void incremental_update(CMatrix *u, CVector *s, CVector *c, int max_rank,
+ double epsilon, CMatrix **u_out, CVector **s_out)
+{
+ size_t m = u->rows;
+ size_t k = u->cols;
+
+ /* Step 1: Project c onto column space of U */
+ /* m_vec = U^T * c */
+ CVector *m_vec = cvector_alloc(k);
+ for (size_t j = 0; j < k; j++) {
+ double sum = 0.0;
+ for (size_t i = 0; i < m; i++) {
+ sum += MAT_AT(u, i, j) * c->data[i];
+ }
+ m_vec->data[j] = sum;
+ }
+
+ /* Step 2: Compute residual p = c - U * m_vec */
+ CVector *u_times_m = cmatrix_multiply_vector(u, m_vec);
+ CVector *p = cvector_subtract(c, u_times_m);
+ double p_norm = cvector_magnitude(p);
+
+ cvector_free(u_times_m);
+
+ if (p_norm > epsilon) {
+ /* New direction found - rank may increase */
+
+ /* Step 3: Normalize residual */
+ CVector *p_hat = cvector_alloc(m);
+ double inv_p_norm = 1.0 / p_norm;
+ for (size_t i = 0; i < m; i++) {
+ p_hat->data[i] = p->data[i] * inv_p_norm;
+ }
+
+ /* Step 4: Build K matrix */
+ CMatrix *k_mat = build_k_matrix_with_growth(s, m_vec, p_norm);
+
+ /* Step 5: SVD of K matrix */
+ CMatrix *u_prime, *v_prime;
+ CVector *s_prime;
+ jacobi_svd(k_mat, &u_prime, &v_prime, &s_prime);
+ cmatrix_free(k_mat);
+ cmatrix_free(v_prime);
+
+ /* Step 6: Update U = [U | p_hat] * U' */
+ CMatrix *u_extended = cmatrix_extend_column(u, p_hat);
+ CMatrix *u_new = cmatrix_multiply(u_extended, u_prime);
+ cmatrix_free(u_extended);
+ cmatrix_free(u_prime);
+ cvector_free(p_hat);
+
+ /* Truncate if needed */
+ if (s_prime->size > (size_t)max_rank) {
+ /* Create truncated U (keep first max_rank columns) */
+ CMatrix *u_trunc = cmatrix_alloc(u_new->rows, (size_t)max_rank);
+ for (size_t i = 0; i < u_new->rows; i++) {
+ memcpy(&MAT_AT(u_trunc, i, 0), &MAT_AT(u_new, i, 0),
+ (size_t)max_rank * sizeof(double));
+ }
+ cmatrix_free(u_new);
+ u_new = u_trunc;
+
+ /* Truncate singular values */
+ CVector *s_trunc = cvector_alloc((size_t)max_rank);
+ memcpy(s_trunc->data, s_prime->data, (size_t)max_rank * sizeof(double));
+ cvector_free(s_prime);
+ s_prime = s_trunc;
+ }
+
+ *u_out = u_new;
+ *s_out = s_prime;
+ } else {
+ /* Vector in span - use simpler update */
+ /* For now, just return unchanged (projection handles this) */
+ *u_out = cmatrix_alloc(u->rows, u->cols);
+ memcpy((*u_out)->data, u->data, u->rows * u->cols * sizeof(double));
+ *s_out = cvector_alloc(s->size);
+ memcpy((*s_out)->data, s->data, s->size * sizeof(double));
+ }
+
+ cvector_free(p);
+ cvector_free(m_vec);
+}
+
+/* ========== Ruby Wrappers ========== */
+
+/*
+ * Matrix.extend_column(matrix, vector)
+ * Returns [matrix | vector]
+ */
+static VALUE rb_cmatrix_extend_column(VALUE klass, VALUE rb_matrix, VALUE rb_vector)
+{
+ CMatrix *m;
+ CVector *v;
+
+ GET_CMATRIX(rb_matrix, m);
+ GET_CVECTOR(rb_vector, v);
+
+ CMatrix *result = cmatrix_extend_column(m, v);
+ return TypedData_Wrap_Struct(klass, &cmatrix_type, result);
+
+ (void)klass;
+}
+
+/*
+ * Matrix.vstack(top, bottom)
+ * Vertically stack two matrices
+ */
+static VALUE rb_cmatrix_vstack(VALUE klass, VALUE rb_top, VALUE rb_bottom)
+{
+ CMatrix *top, *bottom;
+
+ GET_CMATRIX(rb_top, top);
+ GET_CMATRIX(rb_bottom, bottom);
+
+ CMatrix *result = cmatrix_vstack(top, bottom);
+ return TypedData_Wrap_Struct(klass, &cmatrix_type, result);
+
+ (void)klass;
+}
+
+/*
+ * Matrix.zeros(rows, cols)
+ * Create a zero matrix
+ */
+static VALUE rb_cmatrix_zeros(VALUE klass, VALUE rb_rows, VALUE rb_cols)
+{
+ size_t rows = NUM2SIZET(rb_rows);
+ size_t cols = NUM2SIZET(rb_cols);
+
+ CMatrix *result = cmatrix_alloc(rows, cols);
+ return TypedData_Wrap_Struct(klass, &cmatrix_type, result);
+
+ (void)klass;
+}
+
+/*
+ * Vector#-(other)
+ * Vector subtraction
+ */
+static VALUE rb_cvector_subtract(VALUE self, VALUE other)
+{
+ CVector *a, *b;
+
+ GET_CVECTOR(self, a);
+
+ if (rb_obj_is_kind_of(other, cClassifierVector)) {
+ GET_CVECTOR(other, b);
+ CVector *result = cvector_subtract(a, b);
+ return TypedData_Wrap_Struct(cClassifierVector, &cvector_type, result);
+ }
+
+ rb_raise(rb_eTypeError, "Cannot subtract %s from Vector",
+ rb_obj_classname(other));
+ return Qnil;
+}
+
+/*
+ * Matrix#batch_project(vectors_array)
+ * Project multiple vectors onto this matrix (as U)
+ * Returns array of projected vectors
+ *
+ * This is the high-performance batch operation for re-projecting documents
+ */
+static VALUE rb_cmatrix_batch_project(VALUE self, VALUE rb_vectors)
+{
+ CMatrix *u;
+ GET_CMATRIX(self, u);
+
+ Check_Type(rb_vectors, T_ARRAY);
+ long num_vectors = RARRAY_LEN(rb_vectors);
+
+ if (num_vectors == 0) {
+ return rb_ary_new();
+ }
+
+ CVector **raw_vectors = ALLOC_N(CVector *, num_vectors);
+ for (long i = 0; i < num_vectors; i++) {
+ VALUE rb_vec = rb_ary_entry(rb_vectors, i);
+ if (!rb_obj_is_kind_of(rb_vec, cClassifierVector)) {
+ xfree(raw_vectors);
+ rb_raise(rb_eTypeError, "Expected array of Vectors");
+ }
+ GET_CVECTOR(rb_vec, raw_vectors[i]);
+ }
+
+ CVector **lsi_vectors = ALLOC_N(CVector *, num_vectors);
+ cbatch_project(u, raw_vectors, (size_t)num_vectors, lsi_vectors);
+
+ VALUE result = rb_ary_new_capa(num_vectors);
+ for (long i = 0; i < num_vectors; i++) {
+ VALUE rb_lsi = TypedData_Wrap_Struct(cClassifierVector, &cvector_type,
+ lsi_vectors[i]);
+ rb_ary_push(result, rb_lsi);
+ }
+
+ xfree(raw_vectors);
+ xfree(lsi_vectors);
+
+ return result;
+}
+
+/*
+ * Matrix#incremental_svd_update(singular_values, new_vector, max_rank, epsilon)
+ * Perform one Brand's incremental SVD update
+ * Returns [new_u, new_singular_values]
+ */
+static VALUE rb_cmatrix_incremental_update(VALUE self, VALUE rb_s, VALUE rb_c,
+ VALUE rb_max_rank, VALUE rb_epsilon)
+{
+ CMatrix *u;
+ CVector *s, *c;
+
+ GET_CMATRIX(self, u);
+ GET_CVECTOR(rb_s, s);
+ GET_CVECTOR(rb_c, c);
+
+ int max_rank = NUM2INT(rb_max_rank);
+ double epsilon = NUM2DBL(rb_epsilon);
+
+ CMatrix *u_new;
+ CVector *s_new;
+
+ incremental_update(u, s, c, max_rank, epsilon, &u_new, &s_new);
+
+ VALUE rb_u_new = TypedData_Wrap_Struct(cClassifierMatrix, &cmatrix_type, u_new);
+ VALUE rb_s_new = TypedData_Wrap_Struct(cClassifierVector, &cvector_type, s_new);
+
+ return rb_ary_new_from_args(2, rb_u_new, rb_s_new);
+}
+
+void Init_incremental_svd(void)
+{
+ /* Matrix class methods for incremental SVD */
+ rb_define_singleton_method(cClassifierMatrix, "extend_column",
+ rb_cmatrix_extend_column, 2);
+ rb_define_singleton_method(cClassifierMatrix, "vstack",
+ rb_cmatrix_vstack, 2);
+ rb_define_singleton_method(cClassifierMatrix, "zeros",
+ rb_cmatrix_zeros, 2);
+
+ /* Instance methods */
+ rb_define_method(cClassifierMatrix, "batch_project",
+ rb_cmatrix_batch_project, 1);
+ rb_define_method(cClassifierMatrix, "incremental_svd_update",
+ rb_cmatrix_incremental_update, 4);
+
+ /* Vector subtraction */
+ rb_define_method(cClassifierVector, "-", rb_cvector_subtract, 1);
+}
diff --git a/ext/classifier/linalg.h b/ext/classifier/linalg.h
new file mode 100644
index 0000000..71bbc30
--- /dev/null
+++ b/ext/classifier/linalg.h
@@ -0,0 +1,72 @@
+#ifndef CLASSIFIER_LINALG_H
+#define CLASSIFIER_LINALG_H
+
+#include <ruby.h>
+#include <math.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* Epsilon for numerical comparisons */
+#define CLASSIFIER_EPSILON 1e-10
+
+/* Vector structure */
+typedef struct {
+ size_t size;
+ double *data;
+ int is_col; /* 0 = row vector, 1 = column vector */
+} CVector;
+
+/* Matrix structure */
+typedef struct {
+ size_t rows;
+ size_t cols;
+ double *data; /* Row-major storage */
+} CMatrix;
+
+/* Ruby class references */
+extern VALUE cClassifierVector;
+extern VALUE cClassifierMatrix;
+extern VALUE mClassifierLinalg;
+
+/* Vector functions */
+void Init_vector(void);
+CVector *cvector_alloc(size_t size);
+void cvector_free(void *ptr);
+double cvector_magnitude(CVector *v);
+CVector *cvector_normalize(CVector *v);
+double cvector_sum(CVector *v);
+double cvector_dot(CVector *a, CVector *b);
+
+/* Matrix functions */
+void Init_matrix(void);
+CMatrix *cmatrix_alloc(size_t rows, size_t cols);
+void cmatrix_free(void *ptr);
+CMatrix *cmatrix_transpose(CMatrix *m);
+CMatrix *cmatrix_multiply(CMatrix *a, CMatrix *b);
+CVector *cmatrix_multiply_vector(CMatrix *m, CVector *v);
+CMatrix *cmatrix_diagonal(CVector *v);
+
+/* SVD functions */
+void Init_svd(void);
+void jacobi_svd(CMatrix *a, CMatrix **u, CMatrix **v, CVector **s);
+
+/* Incremental SVD functions */
+void Init_incremental_svd(void);
+CMatrix *cmatrix_extend_column(CMatrix *m, CVector *col);
+CMatrix *cmatrix_vstack(CMatrix *top, CMatrix *bottom);
+CVector *cvector_subtract(CVector *a, CVector *b);
+void cbatch_project(CMatrix *u, CVector **raw_vectors, size_t num_vectors,
+ CVector **lsi_vectors_out);
+
+/* TypedData definitions */
+extern const rb_data_type_t cvector_type;
+extern const rb_data_type_t cmatrix_type;
+
+/* Helper macros */
+#define GET_CVECTOR(obj, ptr) TypedData_Get_Struct(obj, CVector, &cvector_type, ptr)
+#define GET_CMATRIX(obj, ptr) TypedData_Get_Struct(obj, CMatrix, &cmatrix_type, ptr)
+
+/* Matrix element access (row-major) */
+#define MAT_AT(m, i, j) ((m)->data[(i) * (m)->cols + (j)])
+
+#endif /* CLASSIFIER_LINALG_H */
diff --git a/ext/classifier/matrix.c b/ext/classifier/matrix.c
new file mode 100644
index 0000000..558423b
--- /dev/null
+++ b/ext/classifier/matrix.c
@@ -0,0 +1,387 @@
+/*
+ * matrix.c
+ * Matrix implementation for Classifier native linear algebra
+ */
+
+#include "linalg.h"
+
+const rb_data_type_t cmatrix_type = {
+ .wrap_struct_name = "Classifier::Linalg::Matrix",
+ .function = {
+ .dmark = NULL,
+ .dfree = cmatrix_free,
+ .dsize = NULL,
+ },
+ .flags = RUBY_TYPED_FREE_IMMEDIATELY
+};
+
+/* Allocate a new CMatrix */
+CMatrix *cmatrix_alloc(size_t rows, size_t cols)
+{
+ CMatrix *m = ALLOC(CMatrix);
+ m->rows = rows;
+ m->cols = cols;
+ m->data = ALLOC_N(double, rows * cols);
+ memset(m->data, 0, rows * cols * sizeof(double));
+ return m;
+}
+
+/* Free a CMatrix */
+void cmatrix_free(void *ptr)
+{
+ CMatrix *m = (CMatrix *)ptr;
+ if (m) {
+ if (m->data) xfree(m->data);
+ xfree(m);
+ }
+}
+
+/* Transpose a matrix */
+CMatrix *cmatrix_transpose(CMatrix *m)
+{
+ CMatrix *result = cmatrix_alloc(m->cols, m->rows);
+ for (size_t i = 0; i < m->rows; i++) {
+ for (size_t j = 0; j < m->cols; j++) {
+ MAT_AT(result, j, i) = MAT_AT(m, i, j);
+ }
+ }
+ return result;
+}
+
+/* Matrix multiplication */
+CMatrix *cmatrix_multiply(CMatrix *a, CMatrix *b)
+{
+ if (a->cols != b->rows) {
+ rb_raise(rb_eArgError, "Matrix dimensions don't match for multiplication: %ldx%ld * %ldx%ld",
+ (long)a->rows, (long)a->cols, (long)b->rows, (long)b->cols);
+ }
+
+ CMatrix *result = cmatrix_alloc(a->rows, b->cols);
+
+ for (size_t i = 0; i < a->rows; i++) {
+ for (size_t j = 0; j < b->cols; j++) {
+ double sum = 0.0;
+ for (size_t k = 0; k < a->cols; k++) {
+ sum += MAT_AT(a, i, k) * MAT_AT(b, k, j);
+ }
+ MAT_AT(result, i, j) = sum;
+ }
+ }
+ return result;
+}
+
+/* Matrix-vector multiplication */
+CVector *cmatrix_multiply_vector(CMatrix *m, CVector *v)
+{
+ if (m->cols != v->size) {
+ rb_raise(rb_eArgError, "Matrix columns (%ld) must match vector size (%ld)",
+ (long)m->cols, (long)v->size);
+ }
+
+ CVector *result = cvector_alloc(m->rows);
+
+ for (size_t i = 0; i < m->rows; i++) {
+ double sum = 0.0;
+ for (size_t j = 0; j < m->cols; j++) {
+ sum += MAT_AT(m, i, j) * v->data[j];
+ }
+ result->data[i] = sum;
+ }
+ return result;
+}
+
+/* Create diagonal matrix from vector */
+CMatrix *cmatrix_diagonal(CVector *v)
+{
+ CMatrix *result = cmatrix_alloc(v->size, v->size);
+ for (size_t i = 0; i < v->size; i++) {
+ MAT_AT(result, i, i) = v->data[i];
+ }
+ return result;
+}
+
+/* Ruby allocation function */
+static VALUE rb_cmatrix_alloc_func(VALUE klass)
+{
+ CMatrix *m = cmatrix_alloc(0, 0);
+ return TypedData_Wrap_Struct(klass, &cmatrix_type, m);
+}
+
+/*
+ * Matrix.alloc(*rows)
+ * Create a new matrix from nested arrays
+ */
+static VALUE rb_cmatrix_s_alloc(int argc, VALUE *argv, VALUE klass)
+{
+ CMatrix *m;
+ VALUE result;
+
+ if (argc == 0) {
+ rb_raise(rb_eArgError, "Matrix.alloc requires at least one row");
+ }
+
+ /* Handle single array argument containing rows */
+ VALUE rows_ary;
+ if (argc == 1 && RB_TYPE_P(argv[0], T_ARRAY)) {
+ VALUE first = rb_ary_entry(argv[0], 0);
+ if (RB_TYPE_P(first, T_ARRAY)) {
+ rows_ary = argv[0];
+ } else {
+ /* Single row */
+ rows_ary = rb_ary_new_from_values(argc, argv);
+ }
+ } else {
+ rows_ary = rb_ary_new_from_values(argc, argv);
+ }
+
+ long num_rows = RARRAY_LEN(rows_ary);
+ if (num_rows == 0) {
+ rb_raise(rb_eArgError, "Matrix cannot be empty");
+ }
+
+ VALUE first_row = rb_ary_entry(rows_ary, 0);
+ long num_cols = RARRAY_LEN(first_row);
+
+ m = cmatrix_alloc((size_t)num_rows, (size_t)num_cols);
+
+ for (long i = 0; i < num_rows; i++) {
+ VALUE row = rb_ary_entry(rows_ary, i);
+ if (RARRAY_LEN(row) != num_cols) {
+ cmatrix_free(m);
+ rb_raise(rb_eArgError, "All rows must have the same length");
+ }
+ for (long j = 0; j < num_cols; j++) {
+ MAT_AT(m, i, j) = NUM2DBL(rb_ary_entry(row, j));
+ }
+ }
+
+ result = TypedData_Wrap_Struct(klass, &cmatrix_type, m);
+ return result;
+}
+
+/*
+ * Matrix.diag(vector_or_array)
+ * Create diagonal matrix from vector
+ */
+static VALUE rb_cmatrix_s_diag(VALUE klass, VALUE arg)
+{
+ CVector *v;
+ CMatrix *m;
+ int free_v = 0;
+
+ if (rb_obj_is_kind_of(arg, cClassifierVector)) {
+ GET_CVECTOR(arg, v);
+ } else if (RB_TYPE_P(arg, T_ARRAY)) {
+ long len = RARRAY_LEN(arg);
+ v = cvector_alloc((size_t)len);
+ free_v = 1;
+ for (long i = 0; i < len; i++) {
+ v->data[i] = NUM2DBL(rb_ary_entry(arg, i));
+ }
+ } else {
+ rb_raise(rb_eTypeError, "Expected Vector or Array");
+ return Qnil;
+ }
+
+ m = cmatrix_diagonal(v);
+ if (free_v) cvector_free(v);
+
+ return TypedData_Wrap_Struct(klass, &cmatrix_type, m);
+}
+
+/* Matrix#size -> [rows, cols] */
+static VALUE rb_cmatrix_size(VALUE self)
+{
+ CMatrix *m;
+ GET_CMATRIX(self, m);
+ return rb_ary_new_from_args(2, SIZET2NUM(m->rows), SIZET2NUM(m->cols));
+}
+
+/* Matrix#row_size */
+static VALUE rb_cmatrix_row_size(VALUE self)
+{
+ CMatrix *m;
+ GET_CMATRIX(self, m);
+ return SIZET2NUM(m->rows);
+}
+
+/* Matrix#column_size */
+static VALUE rb_cmatrix_column_size(VALUE self)
+{
+ CMatrix *m;
+ GET_CMATRIX(self, m);
+ return SIZET2NUM(m->cols);
+}
+
+/* Matrix#[](i, j) */
+static VALUE rb_cmatrix_aref(VALUE self, VALUE row, VALUE col)
+{
+ CMatrix *m;
+ GET_CMATRIX(self, m);
+ long i = NUM2LONG(row);
+ long j = NUM2LONG(col);
+
+ if (i < 0) i += m->rows;
+ if (j < 0) j += m->cols;
+ if (i < 0 || (size_t)i >= m->rows || j < 0 || (size_t)j >= m->cols) {
+ rb_raise(rb_eIndexError, "index out of bounds");
+ }
+
+ return DBL2NUM(MAT_AT(m, i, j));
+}
+
+/* Matrix#[]=(i, j, val) */
+static VALUE rb_cmatrix_aset(VALUE self, VALUE row, VALUE col, VALUE val)
+{
+ CMatrix *m;
+ GET_CMATRIX(self, m);
+ long i = NUM2LONG(row);
+ long j = NUM2LONG(col);
+
+ if (i < 0) i += m->rows;
+ if (j < 0) j += m->cols;
+ if (i < 0 || (size_t)i >= m->rows || j < 0 || (size_t)j >= m->cols) {
+ rb_raise(rb_eIndexError, "index out of bounds");
+ }
+
+ MAT_AT(m, i, j) = NUM2DBL(val);
+ return val;
+}
+
+/* Matrix#trans (transpose) */
+static VALUE rb_cmatrix_trans(VALUE self)
+{
+ CMatrix *m;
+ GET_CMATRIX(self, m);
+ CMatrix *result = cmatrix_transpose(m);
+ return TypedData_Wrap_Struct(cClassifierMatrix, &cmatrix_type, result);
+}
+
+/* Matrix#column(n) -> Vector */
+static VALUE rb_cmatrix_column(VALUE self, VALUE col_idx)
+{
+ CMatrix *m;
+ GET_CMATRIX(self, m);
+ long j = NUM2LONG(col_idx);
+
+ if (j < 0) j += m->cols;
+ if (j < 0 || (size_t)j >= m->cols) {
+ rb_raise(rb_eIndexError, "column index out of bounds");
+ }
+
+ CVector *v = cvector_alloc(m->rows);
+ v->is_col = 1;
+ for (size_t i = 0; i < m->rows; i++) {
+ v->data[i] = MAT_AT(m, i, j);
+ }
+
+ return TypedData_Wrap_Struct(cClassifierVector, &cvector_type, v);
+}
+
+/* Matrix#row(n) -> Vector */
+static VALUE rb_cmatrix_row(VALUE self, VALUE row_idx)
+{
+ CMatrix *m;
+ GET_CMATRIX(self, m);
+ long i = NUM2LONG(row_idx);
+
+ if (i < 0) i += m->rows;
+ if (i < 0 || (size_t)i >= m->rows) {
+ rb_raise(rb_eIndexError, "row index out of bounds");
+ }
+
+ CVector *v = cvector_alloc(m->cols);
+ v->is_col = 0;
+ memcpy(v->data, &MAT_AT(m, i, 0), m->cols * sizeof(double));
+
+ return TypedData_Wrap_Struct(cClassifierVector, &cvector_type, v);
+}
+
+/* Matrix#to_a */
+static VALUE rb_cmatrix_to_a(VALUE self)
+{
+ CMatrix *m;
+ GET_CMATRIX(self, m);
+ VALUE ary = rb_ary_new_capa((long)m->rows);
+
+ for (size_t i = 0; i < m->rows; i++) {
+ VALUE row = rb_ary_new_capa((long)m->cols);
+ for (size_t j = 0; j < m->cols; j++) {
+ rb_ary_push(row, DBL2NUM(MAT_AT(m, i, j)));
+ }
+ rb_ary_push(ary, row);
+ }
+ return ary;
+}
+
+/* Matrix#* - multiply with matrix or vector */
+static VALUE rb_cmatrix_mul(VALUE self, VALUE other)
+{
+ CMatrix *m;
+ GET_CMATRIX(self, m);
+
+ if (rb_obj_is_kind_of(other, cClassifierMatrix)) {
+ CMatrix *b;
+ GET_CMATRIX(other, b);
+ CMatrix *result = cmatrix_multiply(m, b);
+ return TypedData_Wrap_Struct(cClassifierMatrix, &cmatrix_type, result);
+ } else if (rb_obj_is_kind_of(other, cClassifierVector)) {
+ CVector *v;
+ GET_CVECTOR(other, v);
+ CVector *result = cmatrix_multiply_vector(m, v);
+ return TypedData_Wrap_Struct(cClassifierVector, &cvector_type, result);
+ } else if (RB_TYPE_P(other, T_FLOAT) || RB_TYPE_P(other, T_FIXNUM)) {
+ /* Scalar multiplication */
+ double scalar = NUM2DBL(other);
+ CMatrix *result = cmatrix_alloc(m->rows, m->cols);
+ for (size_t i = 0; i < m->rows * m->cols; i++) {
+ result->data[i] = m->data[i] * scalar;
+ }
+ return TypedData_Wrap_Struct(cClassifierMatrix, &cmatrix_type, result);
+ }
+
+ rb_raise(rb_eTypeError, "Cannot multiply Matrix with %s", rb_obj_classname(other));
+ return Qnil;
+}
+
+/* Matrix#_dump for Marshal */
+static VALUE rb_cmatrix_dump(VALUE self, VALUE depth)
+{
+ CMatrix *m;
+ GET_CMATRIX(self, m);
+ VALUE ary = rb_cmatrix_to_a(self);
+ return rb_marshal_dump(ary, Qnil);
+}
+
+/* Matrix._load for Marshal */
+static VALUE rb_cmatrix_s_load(VALUE klass, VALUE str)
+{
+ VALUE ary = rb_marshal_load(str);
+ long argc = RARRAY_LEN(ary);
+ VALUE *argv = RARRAY_PTR(ary);
+ return rb_cmatrix_s_alloc((int)argc, argv, klass);
+}
+
+void Init_matrix(void)
+{
+ cClassifierMatrix = rb_define_class_under(mClassifierLinalg, "Matrix", rb_cObject);
+
+ rb_define_alloc_func(cClassifierMatrix, rb_cmatrix_alloc_func);
+ rb_define_singleton_method(cClassifierMatrix, "alloc", rb_cmatrix_s_alloc, -1);
+ rb_define_singleton_method(cClassifierMatrix, "diag", rb_cmatrix_s_diag, 1);
+ rb_define_singleton_method(cClassifierMatrix, "diagonal", rb_cmatrix_s_diag, 1);
+ rb_define_singleton_method(cClassifierMatrix, "_load", rb_cmatrix_s_load, 1);
+
+ rb_define_method(cClassifierMatrix, "size", rb_cmatrix_size, 0);
+ rb_define_method(cClassifierMatrix, "row_size", rb_cmatrix_row_size, 0);
+ rb_define_method(cClassifierMatrix, "column_size", rb_cmatrix_column_size, 0);
+ rb_define_method(cClassifierMatrix, "[]", rb_cmatrix_aref, 2);
+ rb_define_method(cClassifierMatrix, "[]=", rb_cmatrix_aset, 3);
+ rb_define_method(cClassifierMatrix, "trans", rb_cmatrix_trans, 0);
+ rb_define_alias(cClassifierMatrix, "transpose", "trans");
+ rb_define_method(cClassifierMatrix, "column", rb_cmatrix_column, 1);
+ rb_define_method(cClassifierMatrix, "row", rb_cmatrix_row, 1);
+ rb_define_method(cClassifierMatrix, "to_a", rb_cmatrix_to_a, 0);
+ rb_define_method(cClassifierMatrix, "*", rb_cmatrix_mul, 1);
+ rb_define_method(cClassifierMatrix, "_dump", rb_cmatrix_dump, 1);
+}
diff --git a/ext/classifier/svd.c b/ext/classifier/svd.c
new file mode 100644
index 0000000..4455af3
--- /dev/null
+++ b/ext/classifier/svd.c
@@ -0,0 +1,208 @@
+/*
+ * svd.c
+ * Jacobi SVD implementation for Classifier native linear algebra
+ *
+ * This is a port of the pure Ruby SVD implementation from
+ * lib/classifier/extensions/vector.rb
+ */
+
+#include "linalg.h"
+
+#define SVD_MAX_SWEEPS 20
+#define SVD_CONVERGENCE_THRESHOLD 0.001
+
+/* Helper: Create identity matrix */
+static CMatrix *cmatrix_identity(size_t n)
+{
+ CMatrix *m = cmatrix_alloc(n, n);
+ for (size_t i = 0; i < n; i++) {
+ MAT_AT(m, i, i) = 1.0;
+ }
+ return m;
+}
+
+/* Helper: Clone a matrix */
+static CMatrix *cmatrix_clone(CMatrix *m)
+{
+ CMatrix *result = cmatrix_alloc(m->rows, m->cols);
+ memcpy(result->data, m->data, m->rows * m->cols * sizeof(double));
+ return result;
+}
+
+/* Helper: Apply Jacobi rotation to matrix Q and accumulator V */
+static void apply_jacobi_rotation(CMatrix *q, CMatrix *v, size_t p, size_t r,
+ double cosine, double sine)
+{
+ size_t n = q->rows;
+
+ /* Apply rotation to Q: Q = R^T * Q * R */
+ /* First: Q = Q * R (affects columns p and r) */
+ for (size_t i = 0; i < n; i++) {
+ double qip = MAT_AT(q, i, p);
+ double qir = MAT_AT(q, i, r);
+ MAT_AT(q, i, p) = cosine * qip - sine * qir;
+ MAT_AT(q, i, r) = sine * qip + cosine * qir;
+ }
+
+ /* Second: Q = R^T * Q (affects rows p and r) */
+ for (size_t j = 0; j < n; j++) {
+ double qpj = MAT_AT(q, p, j);
+ double qrj = MAT_AT(q, r, j);
+ MAT_AT(q, p, j) = cosine * qpj - sine * qrj;
+ MAT_AT(q, r, j) = sine * qpj + cosine * qrj;
+ }
+
+ /* Accumulate rotation in V: V = V * R */
+ for (size_t i = 0; i < v->rows; i++) {
+ double vip = MAT_AT(v, i, p);
+ double vir = MAT_AT(v, i, r);
+ MAT_AT(v, i, p) = cosine * vip - sine * vir;
+ MAT_AT(v, i, r) = sine * vip + cosine * vir;
+ }
+}
+
+/*
+ * Jacobi SVD decomposition
+ *
+ * Computes A = U * S * V^T where:
+ * - U is m x m orthogonal
+ * - S is m x n diagonal (returned as vector of singular values)
+ * - V is n x n orthogonal
+ *
+ * Based on one-sided Jacobi algorithm from vector.rb
+ */
+void jacobi_svd(CMatrix *a, CMatrix **u_out, CMatrix **v_out, CVector **s_out)
+{
+ size_t m = a->rows;
+ size_t n = a->cols;
+ int transposed = 0;
+ CMatrix *work_matrix;
+
+ /* Ensure we work with a "tall" matrix for numerical stability */
+ if (m >= n) {
+ /* A^T * A */
+ CMatrix *at = cmatrix_transpose(a);
+ work_matrix = cmatrix_multiply(at, a);
+ cmatrix_free(at);
+ } else {
+ /* A * A^T */
+ CMatrix *at = cmatrix_transpose(a);
+ work_matrix = cmatrix_multiply(a, at);
+ cmatrix_free(at);
+ transposed = 1;
+ }
+
+ size_t size = work_matrix->rows; /* This is min(m, n) effectively */
+ CMatrix *q = cmatrix_clone(work_matrix);
+ CMatrix *v = cmatrix_identity(size);
+ CMatrix *prev_q = NULL;
+
+ /* Jacobi iteration */
+ for (int sweep = 0; sweep < SVD_MAX_SWEEPS; sweep++) {
+ /* Apply rotations to diagonalize Q */
+ for (size_t p = 0; p < size - 1; p++) {
+ for (size_t r = p + 1; r < size; r++) {
+ double qpr = MAT_AT(q, p, r);
+ double qpp = MAT_AT(q, p, p);
+ double qrr = MAT_AT(q, r, r);
+
+ /* Compute rotation angle */
+ double numerator = 2.0 * qpr;
+ double denominator = qpp - qrr;
+ double angle;
+
+ if (fabs(denominator) < CLASSIFIER_EPSILON) {
+ angle = (numerator >= 0) ? M_PI / 4.0 : -M_PI / 4.0;
+ } else {
+ angle = atan(numerator / denominator) / 2.0;
+ }
+
+ double cosine = cos(angle);
+ double sine = sin(angle);
+
+ apply_jacobi_rotation(q, v, p, r, cosine, sine);
+ }
+ }
+
+ /* Check for convergence */
+ if (sweep == 0) {
+ prev_q = cmatrix_clone(q);
+ } else {
+ double sum_diff = 0.0;
+ for (size_t i = 0; i < size; i++) {
+ double diff = fabs(MAT_AT(q, i, i) - MAT_AT(prev_q, i, i));
+ if (diff > SVD_CONVERGENCE_THRESHOLD) {
+ sum_diff += diff;
+ }
+ }
+
+ /* Update prev_q for next iteration */
+ memcpy(prev_q->data, q->data, size * size * sizeof(double));
+
+ if (sum_diff <= SVD_CONVERGENCE_THRESHOLD) {
+ break;
+ }
+ }
+ }
+
+ if (prev_q) cmatrix_free(prev_q);
+
+ /* Extract singular values (sqrt of diagonal elements of Q) */
+ CVector *s = cvector_alloc(size);
+ for (size_t i = 0; i < size; i++) {
+ double val = MAT_AT(q, i, i);
+ s->data[i] = (val > 0) ? sqrt(val) : 0.0;
+ }
+
+ /* Compute U = A * V * S^(-1) or handle transposed case */
+ /* Create S^(-1) diagonal matrix */
+ CMatrix *s_inv = cmatrix_alloc(size, size);
+ for (size_t i = 0; i < size; i++) {
+ double sv = s->data[i];
+ MAT_AT(s_inv, i, i) = (sv > CLASSIFIER_EPSILON) ? (1.0 / sv) : 0.0;
+ }
+
+ CMatrix *u;
+ CMatrix *source = transposed ? cmatrix_transpose(a) : cmatrix_clone(a);
+
+ /* U = source * V * S^(-1) */
+ CMatrix *temp = cmatrix_multiply(source, v);
+ u = cmatrix_multiply(temp, s_inv);
+
+ cmatrix_free(temp);
+ cmatrix_free(s_inv);
+ cmatrix_free(source);
+ cmatrix_free(q);
+ cmatrix_free(work_matrix);
+
+ *u_out = u;
+ *v_out = v;
+ *s_out = s;
+}
+
+/* Ruby wrapper: Matrix#SV_decomp */
+static VALUE rb_cmatrix_sv_decomp(int argc, VALUE *argv, VALUE self)
+{
+ CMatrix *m;
+ GET_CMATRIX(self, m);
+
+ /* Optional max_sweeps argument (ignored for now, using default) */
+ (void)argc;
+ (void)argv;
+
+ CMatrix *u, *v;
+ CVector *s;
+ jacobi_svd(m, &u, &v, &s);
+
+ VALUE rb_u = TypedData_Wrap_Struct(cClassifierMatrix, &cmatrix_type, u);
+ VALUE rb_v = TypedData_Wrap_Struct(cClassifierMatrix, &cmatrix_type, v);
+ VALUE rb_s = TypedData_Wrap_Struct(cClassifierVector, &cvector_type, s);
+
+ return rb_ary_new_from_args(3, rb_u, rb_v, rb_s);
+}
+
+void Init_svd(void)
+{
+ rb_define_method(cClassifierMatrix, "SV_decomp", rb_cmatrix_sv_decomp, -1);
+ rb_define_alias(cClassifierMatrix, "svd", "SV_decomp");
+}
diff --git a/ext/classifier/vector.c b/ext/classifier/vector.c
new file mode 100644
index 0000000..fb1f31a
--- /dev/null
+++ b/ext/classifier/vector.c
@@ -0,0 +1,319 @@
+/*
+ * vector.c
+ * Vector implementation for Classifier native linear algebra
+ */
+
+#include "linalg.h"
+
+const rb_data_type_t cvector_type = {
+ .wrap_struct_name = "Classifier::Linalg::Vector",
+ .function = {
+ .dmark = NULL,
+ .dfree = cvector_free,
+ .dsize = NULL,
+ },
+ .flags = RUBY_TYPED_FREE_IMMEDIATELY
+};
+
+/* Allocate a new CVector */
+CVector *cvector_alloc(size_t size)
+{
+ CVector *v = ALLOC(CVector);
+ v->size = size;
+ v->data = ALLOC_N(double, size);
+ v->is_col = 0; /* Default to row vector */
+ memset(v->data, 0, size * sizeof(double));
+ return v;
+}
+
+/* Free a CVector */
+void cvector_free(void *ptr)
+{
+ CVector *v = (CVector *)ptr;
+ if (v) {
+ if (v->data) xfree(v->data);
+ xfree(v);
+ }
+}
+
+/* Calculate magnitude (Euclidean norm) */
+double cvector_magnitude(CVector *v)
+{
+ double sum = 0.0;
+ for (size_t i = 0; i < v->size; i++) {
+ sum += v->data[i] * v->data[i];
+ }
+ return sqrt(sum);
+}
+
+/* Return normalized copy */
+CVector *cvector_normalize(CVector *v)
+{
+ CVector *result = cvector_alloc(v->size);
+ result->is_col = v->is_col;
+ double mag = cvector_magnitude(v);
+
+ if (mag <= CLASSIFIER_EPSILON) {
+ /* Return zero vector if magnitude is too small */
+ return result;
+ }
+
+ for (size_t i = 0; i < v->size; i++) {
+ result->data[i] = v->data[i] / mag;
+ }
+ return result;
+}
+
+/* Sum all elements */
+double cvector_sum(CVector *v)
+{
+ double sum = 0.0;
+ for (size_t i = 0; i < v->size; i++) {
+ sum += v->data[i];
+ }
+ return sum;
+}
+
+/* Dot product */
+double cvector_dot(CVector *a, CVector *b)
+{
+ if (a->size != b->size) {
+ rb_raise(rb_eArgError, "Vector sizes must match for dot product");
+ }
+ double sum = 0.0;
+ for (size_t i = 0; i < a->size; i++) {
+ sum += a->data[i] * b->data[i];
+ }
+ return sum;
+}
+
+/* Ruby allocation function */
+static VALUE rb_cvector_alloc(VALUE klass)
+{
+ CVector *v = cvector_alloc(0);
+ return TypedData_Wrap_Struct(klass, &cvector_type, v);
+}
+
+/*
+ * Vector.alloc(size_or_array)
+ * Create a new vector from size (zero-filled) or array of values
+ */
+static VALUE rb_cvector_s_alloc(VALUE klass, VALUE arg)
+{
+ CVector *v;
+ VALUE result;
+
+ if (RB_TYPE_P(arg, T_ARRAY)) {
+ long len = RARRAY_LEN(arg);
+ v = cvector_alloc((size_t)len);
+ for (long i = 0; i < len; i++) {
+ v->data[i] = NUM2DBL(rb_ary_entry(arg, i));
+ }
+ } else {
+ size_t size = NUM2SIZET(arg);
+ v = cvector_alloc(size);
+ }
+
+ result = TypedData_Wrap_Struct(klass, &cvector_type, v);
+ return result;
+}
+
+/* Vector#size */
+static VALUE rb_cvector_size(VALUE self)
+{
+ CVector *v;
+ GET_CVECTOR(self, v);
+ return SIZET2NUM(v->size);
+}
+
+/* Vector#[] */
+static VALUE rb_cvector_aref(VALUE self, VALUE idx)
+{
+ CVector *v;
+ GET_CVECTOR(self, v);
+ long i = NUM2LONG(idx);
+
+ if (i < 0) i += v->size;
+ if (i < 0 || (size_t)i >= v->size) {
+ rb_raise(rb_eIndexError, "index %ld out of bounds", i);
+ }
+
+ return DBL2NUM(v->data[i]);
+}
+
+/* Vector#[]= */
+static VALUE rb_cvector_aset(VALUE self, VALUE idx, VALUE val)
+{
+ CVector *v;
+ GET_CVECTOR(self, v);
+ long i = NUM2LONG(idx);
+
+ if (i < 0) i += v->size;
+ if (i < 0 || (size_t)i >= v->size) {
+ rb_raise(rb_eIndexError, "index %ld out of bounds", i);
+ }
+
+ v->data[i] = NUM2DBL(val);
+ return val;
+}
+
+/* Vector#to_a */
+static VALUE rb_cvector_to_a(VALUE self)
+{
+ CVector *v;
+ GET_CVECTOR(self, v);
+ VALUE ary = rb_ary_new_capa((long)v->size);
+
+ for (size_t i = 0; i < v->size; i++) {
+ rb_ary_push(ary, DBL2NUM(v->data[i]));
+ }
+ return ary;
+}
+
+/* Vector#sum */
+static VALUE rb_cvector_sum(VALUE self)
+{
+ CVector *v;
+ GET_CVECTOR(self, v);
+ return DBL2NUM(cvector_sum(v));
+}
+
+/* Vector#each */
+static VALUE rb_cvector_each(VALUE self)
+{
+ CVector *v;
+ GET_CVECTOR(self, v);
+
+ RETURN_ENUMERATOR(self, 0, 0);
+
+ for (size_t i = 0; i < v->size; i++) {
+ rb_yield(DBL2NUM(v->data[i]));
+ }
+ return self;
+}
+
+/* Vector#collect (map) */
+static VALUE rb_cvector_collect(VALUE self)
+{
+ CVector *v;
+ GET_CVECTOR(self, v);
+
+ RETURN_ENUMERATOR(self, 0, 0);
+
+ CVector *result = cvector_alloc(v->size);
+ result->is_col = v->is_col;
+
+ for (size_t i = 0; i < v->size; i++) {
+ VALUE val = rb_yield(DBL2NUM(v->data[i]));
+ result->data[i] = NUM2DBL(val);
+ }
+
+ return TypedData_Wrap_Struct(cClassifierVector, &cvector_type, result);
+}
+
+/* Vector#normalize */
+static VALUE rb_cvector_normalize(VALUE self)
+{
+ CVector *v;
+ GET_CVECTOR(self, v);
+ CVector *result = cvector_normalize(v);
+ return TypedData_Wrap_Struct(cClassifierVector, &cvector_type, result);
+}
+
+/* Vector#row - return self as row vector */
+static VALUE rb_cvector_row(VALUE self)
+{
+ CVector *v;
+ GET_CVECTOR(self, v);
+
+ CVector *result = cvector_alloc(v->size);
+ memcpy(result->data, v->data, v->size * sizeof(double));
+ result->is_col = 0;
+
+ return TypedData_Wrap_Struct(cClassifierVector, &cvector_type, result);
+}
+
+/* Vector#col - return self as column vector */
+static VALUE rb_cvector_col(VALUE self)
+{
+ CVector *v;
+ GET_CVECTOR(self, v);
+
+ CVector *result = cvector_alloc(v->size);
+ memcpy(result->data, v->data, v->size * sizeof(double));
+ result->is_col = 1;
+
+ return TypedData_Wrap_Struct(cClassifierVector, &cvector_type, result);
+}
+
+/* Vector#* - dot product with vector, or matrix multiplication */
+static VALUE rb_cvector_mul(VALUE self, VALUE other)
+{
+ CVector *v;
+ GET_CVECTOR(self, v);
+
+ if (rb_obj_is_kind_of(other, cClassifierVector)) {
+ CVector *w;
+ GET_CVECTOR(other, w);
+ return DBL2NUM(cvector_dot(v, w));
+ } else if (RB_TYPE_P(other, T_FLOAT) || RB_TYPE_P(other, T_FIXNUM)) {
+ /* Scalar multiplication */
+ double scalar = NUM2DBL(other);
+ CVector *result = cvector_alloc(v->size);
+ result->is_col = v->is_col;
+ for (size_t i = 0; i < v->size; i++) {
+ result->data[i] = v->data[i] * scalar;
+ }
+ return TypedData_Wrap_Struct(cClassifierVector, &cvector_type, result);
+ }
+
+ rb_raise(rb_eTypeError, "Cannot multiply Vector with %s", rb_obj_classname(other));
+ return Qnil;
+}
+
+/* Vector#_dump for Marshal */
+static VALUE rb_cvector_dump(VALUE self, VALUE depth)
+{
+ CVector *v;
+ GET_CVECTOR(self, v);
+ VALUE ary = rb_cvector_to_a(self);
+ rb_ary_push(ary, v->is_col ? Qtrue : Qfalse);
+ return rb_marshal_dump(ary, Qnil);
+}
+
+/* Vector._load for Marshal */
+static VALUE rb_cvector_s_load(VALUE klass, VALUE str)
+{
+ VALUE ary = rb_marshal_load(str);
+ VALUE is_col = rb_ary_pop(ary);
+ VALUE result = rb_cvector_s_alloc(klass, ary);
+ CVector *v;
+ GET_CVECTOR(result, v);
+ v->is_col = RTEST(is_col) ? 1 : 0;
+ return result;
+}
+
+void Init_vector(void)
+{
+ cClassifierVector = rb_define_class_under(mClassifierLinalg, "Vector", rb_cObject);
+
+ rb_define_alloc_func(cClassifierVector, rb_cvector_alloc);
+ rb_define_singleton_method(cClassifierVector, "alloc", rb_cvector_s_alloc, 1);
+ rb_define_singleton_method(cClassifierVector, "_load", rb_cvector_s_load, 1);
+
+ rb_define_method(cClassifierVector, "size", rb_cvector_size, 0);
+ rb_define_method(cClassifierVector, "[]", rb_cvector_aref, 1);
+ rb_define_method(cClassifierVector, "[]=", rb_cvector_aset, 2);
+ rb_define_method(cClassifierVector, "to_a", rb_cvector_to_a, 0);
+ rb_define_method(cClassifierVector, "sum", rb_cvector_sum, 0);
+ rb_define_method(cClassifierVector, "each", rb_cvector_each, 0);
+ rb_define_method(cClassifierVector, "collect", rb_cvector_collect, 0);
+ rb_define_alias(cClassifierVector, "map", "collect");
+ rb_define_method(cClassifierVector, "normalize", rb_cvector_normalize, 0);
+ rb_define_method(cClassifierVector, "row", rb_cvector_row, 0);
+ rb_define_method(cClassifierVector, "col", rb_cvector_col, 0);
+ rb_define_method(cClassifierVector, "*", rb_cvector_mul, 1);
+ rb_define_method(cClassifierVector, "_dump", rb_cvector_dump, 1);
+
+ rb_include_module(cClassifierVector, rb_mEnumerable);
+}
diff --git a/install.rb b/install.rb
new file mode 100644
index 0000000..03b7758
--- /dev/null
+++ b/install.rb
@@ -0,0 +1,50 @@
+require 'rbconfig'
+require 'find'
+require 'ftools'
+
+include Config
+
+# this was adapted from rdoc's install.rb by ways of Log4r
+
+$sitedir = CONFIG["sitelibdir"]
+unless $sitedir
+ version = CONFIG["MAJOR"] + "." + CONFIG["MINOR"]
+ $libdir = File.join(CONFIG["libdir"], "ruby", version)
+ $sitedir = $:.find {|x| x =~ /site_ruby/ }
+ if !$sitedir
+ $sitedir = File.join($libdir, "site_ruby")
+ elsif $sitedir !~ Regexp.quote(version)
+ $sitedir = File.join($sitedir, version)
+ end
+end
+
+makedirs = %w{ classifier }
+makedirs = %w{ classifier/extensions }
+makedirs = %w{ classifier/lsi }
+makedirs.each {|f| File::makedirs(File.join($sitedir, *f.split(/\//)))}
+
+Dir.chdir("lib")
+begin
+ require 'rubygems'
+ require 'rake'
+rescue LoadError
+ puts
+ puts "Please install Gem and Rake from http://rubyforge.org/projects/rubygems and http://rubyforge.org/projects/rake"
+ puts
+ exit(-1)
+end
+
+files = FileList["**/*"]
+
+# File::safe_unlink *deprecated.collect{|f| File.join($sitedir, f.split(/\//))}
+files.each {|f|
+ File::install(f, File.join($sitedir, *f.split(/\//)), 0644, true)
+}
+
+begin
+ require 'stemmer'
+rescue LoadError
+ puts
+ puts "Please install Stemmer from http://rubyforge.org/projects/stemmer or via 'gem install stemmer'"
+ puts
+end
diff --git a/lib/classifier.rb b/lib/classifier.rb
index 6c99960..26a5488 100644
--- a/lib/classifier.rb
+++ b/lib/classifier.rb
@@ -25,6 +25,15 @@
# License:: LGPL
require 'rubygems'
+require 'classifier/version'
+require 'classifier/errors'
+require 'classifier/storage'
+require 'classifier/streaming'
require 'classifier/extensions/string'
+require 'classifier/extensions/vector'
require 'classifier/bayes'
-require 'classifier/lsi' \ No newline at end of file
+require 'classifier/lsi'
+require 'classifier/knn'
+require 'classifier/tfidf'
+require 'classifier/logistic_regression'
+require 'classifier/config'
diff --git a/lib/classifier/bayes.rb b/lib/classifier/bayes.rb
index 39a25b2..622360d 100644
--- a/lib/classifier/bayes.rb
+++ b/lib/classifier/bayes.rb
@@ -1,135 +1,538 @@
+# rbs_inline: enabled
+
# Author:: Lucas Carlson (mailto:lucas@rufy.com)
# Copyright:: Copyright (c) 2005 Lucas Carlson
# License:: LGPL
+require 'json'
+require 'mutex_m'
+
module Classifier
+ class Bayes # rubocop:disable Metrics/ClassLength
+ include Mutex_m
+ include Streaming
-class Bayes
- # The class can be created with one or more categories, each of which will be
- # initialized and given a training method. E.g.,
- # b = Classifier::Bayes.new 'Interesting', 'Uninteresting', 'Spam'
- def initialize(*categories)
- @categories = Hash.new
- categories.each { |category| @categories[category.prepare_category_name] = Hash.new }
- @total_words = 0
- @category_counts = Hash.new(0)
- end
-
- #
- # Provides a general training method for all categories specified in Bayes#new
- # For example:
- # b = Classifier::Bayes.new 'This', 'That', 'the_other'
- # b.train :this, "This text"
- # b.train "that", "That text"
- # b.train "The other", "The other text"
- def train(category, text)
- category = category.prepare_category_name
- @category_counts[category] += 1
- text.word_hash.each do |word, count|
- @categories[category][word] ||= 0
- @categories[category][word] += count
- @total_words += count
- end
- end
-
- #
- # Provides a untraining method for all categories specified in Bayes#new
- # Be very careful with this method.
- #
- # For example:
- # b = Classifier::Bayes.new 'This', 'That', 'the_other'
- # b.train :this, "This text"
- # b.untrain :this, "This text"
- def untrain(category, text)
- category = category.prepare_category_name
- @category_counts[category] -= 1
- text.word_hash.each do |word, count|
- if @total_words >= 0
- orig = @categories[category][word]
- @categories[category][word] ||= 0
- @categories[category][word] -= count
- if @categories[category][word] <= 0
- @categories[category].delete(word)
- count = orig
- end
- @total_words -= count
- end
- end
- end
-
- #
- # Returns the scores in each category the provided +text+. E.g.,
- # b.classifications "I hate bad words and you"
- # => {"Uninteresting"=>-12.6997928013932, "Interesting"=>-18.4206807439524}
- # The largest of these scores (the one closest to 0) is the one picked out by #classify
- def classifications(text)
- score = Hash.new
- training_count = @category_counts.values.inject { |x,y| x+y }.to_f
- @categories.each do |category, category_words|
- score[category.to_s] = 0
- total = category_words.values.inject(0) {|sum, element| sum+element}
- text.word_hash.each do |word, count|
- s = category_words.has_key?(word) ? category_words[word] : 0.1
- score[category.to_s] += Math.log(s/total.to_f)
- end
- # now add prior probability for the category
- s = @category_counts.has_key?(category) ? @category_counts[category] : 0.1
- score[category.to_s] += Math.log(s / training_count)
- end
- return score
- end
-
- #
- # Returns the classification of the provided +text+, which is one of the
- # categories given in the initializer. E.g.,
- # b.classify "I hate bad words and you"
- # => 'Uninteresting'
- def classify(text)
- (classifications(text).sort_by { |a| -a[1] })[0][0]
- end
-
- #
- # Provides training and untraining methods for the categories specified in Bayes#new
- # For example:
- # b = Classifier::Bayes.new 'This', 'That', 'the_other'
- # b.train_this "This text"
- # b.train_that "That text"
- # b.untrain_that "That text"
- # b.train_the_other "The other text"
- def method_missing(name, *args)
- category = name.to_s.gsub(/(un)?train_([\w]+)/, '\2').prepare_category_name
- if @categories.has_key? category
- args.each { |text| eval("#{$1}train(category, text)") }
- elsif name.to_s =~ /(un)?train_([\w]+)/
- raise StandardError, "No such category: #{category}"
- else
- super #raise StandardError, "No such method: #{name}"
- end
- end
-
- #
- # Provides a list of category names
- # For example:
- # b.categories
- # => ['This', 'That', 'the_other']
- def categories # :nodoc:
- @categories.keys.collect {|c| c.to_s}
- end
-
- #
- # Allows you to add categories to the classifier.
- # For example:
- # b.add_category "Not spam"
- #
- # WARNING: Adding categories to a trained classifier will
- # result in an undertrained category that will tend to match
- # more criteria than the trained selective categories. In short,
- # try to initialize your categories at initialization.
- def add_category(category)
- @categories[category.prepare_category_name] = Hash.new
- end
-
- alias append_category add_category
-end
+ # @rbs @categories: Hash[Symbol, Hash[Symbol, Integer]]
+ # @rbs @total_words: Integer
+ # @rbs @category_counts: Hash[Symbol, Integer]
+ # @rbs @category_word_count: Hash[Symbol, Integer]
+ # @rbs @cached_training_count: Float?
+ # @rbs @cached_vocab_size: Integer?
+ # @rbs @dirty: bool
+ # @rbs @storage: Storage::Base?
+ # @rbs @min_word_length: Integer
+
+ attr_accessor :storage
+
+ # The class can be created with one or more categories, each of which will be
+ # initialized and given a training method. E.g.,
+ # b = Classifier::Bayes.new 'Interesting', 'Uninteresting', 'Spam'
+ # b = Classifier::Bayes.new ['Interesting', 'Uninteresting', 'Spam']
+ # b = Classifier::Bayes.new 'Spam', min_word_length: 1
+ # @rbs (*String | Symbol | Array[String | Symbol], ?min_word_length: Integer) -> void
+ def initialize(*categories, min_word_length: Classifier.config.min_word_length)
+ super()
+ @categories = {}
+ categories.flatten.each { |category| @categories[category.prepare_category_name] = {} }
+ @total_words = 0
+ @category_counts = Hash.new(0)
+ @category_word_count = Hash.new(0)
+ @cached_training_count = nil
+ @cached_vocab_size = nil
+ @dirty = false
+ @storage = nil
+ @min_word_length = min_word_length
+ end
+
+ # Trains the classifier with text for a category.
+ #
+ # b.train(spam: "Buy now!", ham: ["Hello", "Meeting tomorrow"])
+ # b.train(:spam, "legacy positional API")
+ #
+ # @rbs (?(String | Symbol)?, ?String?, **(String | Array[String])) -> void
+ def train(category = nil, text = nil, **categories)
+ return train_single(category, text) if category && text
+
+ categories.each do |cat, texts|
+ (texts.is_a?(Array) ? texts : [texts]).each { |t| train_single(cat, t) }
+ end
+ end
+
+ # Removes training data. Be careful with this method.
+ #
+ # b.untrain(spam: "Buy now!")
+ # b.untrain(:spam, "legacy positional API")
+ #
+ # @rbs (?(String | Symbol)?, ?String?, **(String | Array[String])) -> void
+ def untrain(category = nil, text = nil, **categories)
+ return untrain_single(category, text) if category && text
+
+ categories.each do |cat, texts|
+ (texts.is_a?(Array) ? texts : [texts]).each { |t| untrain_single(cat, t) }
+ end
+ end
+
+ # Returns the scores in each category the provided +text+. E.g.,
+ # b.classifications "I hate bad words and you"
+ # => {"Uninteresting"=>-12.6997928013932, "Interesting"=>-18.4206807439524}
+ # The largest of these scores (the one closest to 0) is the one picked out by #classify
+ #
+ # @rbs (String) -> Hash[String, Float]
+ def classifications(text)
+ words = text.word_hash(@min_word_length).keys
+ synchronize do
+ training_count = cached_training_count
+ vocab_size = cached_vocab_size
+
+ @categories.to_h do |category, category_words|
+ smoothed_total = ((@category_word_count[category] || 0) + vocab_size).to_f
+
+ # Laplace smoothing: P(word|category) = (count + α) / (total + α * V)
+ word_score = words.sum { |w| Math.log(((category_words[w] || 0) + 1) / smoothed_total) }
+ prior_score = Math.log((@category_counts[category] || 0.1) / training_count)
+
+ [category.to_s, word_score + prior_score]
+ end
+ end
+ end
+
+ # Returns the classification of the provided +text+, which is one of the
+ # categories given in the initializer. E.g.,
+ # b.classify "I hate bad words and you"
+ # => 'Uninteresting'
+ #
+ # @rbs (String) -> String
+ def classify(text)
+ best = classifications(text).min_by { |a| -a[1] }
+ raise StandardError, 'No classifications available' unless best
+
+ best.first.to_s
+ end
+
+ # Returns a hash representation of the classifier state.
+ # This can be converted to JSON or used directly.
+ #
+ # @rbs (?untyped) -> untyped
+ def as_json(_options = nil)
+ {
+ version: 1,
+ type: 'bayes',
+ categories: @categories.transform_keys(&:to_s).transform_values { |v| v.transform_keys(&:to_s) },
+ total_words: @total_words,
+ category_counts: @category_counts.transform_keys(&:to_s),
+ category_word_count: @category_word_count.transform_keys(&:to_s),
+ min_word_length: @min_word_length
+ }
+ end
+
+ # Serializes the classifier state to a JSON string.
+ # This can be saved to a file and later loaded with Bayes.from_json.
+ #
+ # @rbs (?untyped) -> String
+ def to_json(_options = nil)
+ as_json.to_json
+ end
+
+ # Loads a classifier from a JSON string or a Hash created by #to_json or #as_json.
+ #
+ # @rbs (String | Hash[String, untyped]) -> Bayes
+ def self.from_json(json)
+ data = json.is_a?(String) ? JSON.parse(json) : json
+ raise ArgumentError, "Invalid classifier type: #{data['type']}" unless data['type'] == 'bayes'
+
+ instance = allocate
+ instance.send(:restore_state, data)
+ instance
+ end
+
+ # Saves the classifier to the configured storage.
+ # Raises ArgumentError if no storage is configured.
+ #
+ # @rbs () -> void
+ def save
+ raise ArgumentError, 'No storage configured. Use save_to_file(path) or set storage=' unless storage
+
+ storage.write(to_json)
+ @dirty = false
+ end
+
+ # Saves the classifier state to a file (legacy API).
+ #
+ # @rbs (String) -> Integer
+ def save_to_file(path)
+ result = File.write(path, to_json)
+ @dirty = false
+ result
+ end
+
+ # Reloads the classifier from the configured storage.
+ # Raises UnsavedChangesError if there are unsaved changes.
+ # Use reload! to force reload and discard changes.
+ #
+ # @rbs () -> self
+ def reload
+ raise ArgumentError, 'No storage configured' unless storage
+ raise UnsavedChangesError, 'Unsaved changes would be lost. Call save first or use reload!' if @dirty
+
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ restore_from_json(data)
+ @dirty = false
+ self
+ end
+
+ # Force reloads the classifier from storage, discarding any unsaved changes.
+ #
+ # @rbs () -> self
+ def reload!
+ raise ArgumentError, 'No storage configured' unless storage
+
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ restore_from_json(data)
+ @dirty = false
+ self
+ end
+
+ # Returns true if there are unsaved changes.
+ #
+ # @rbs () -> bool
+ def dirty?
+ @dirty
+ end
+
+ # Loads a classifier from the configured storage.
+ # The storage is set on the returned instance.
+ #
+ # @rbs (storage: Storage::Base) -> Bayes
+ def self.load(storage:)
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ instance = from_json(data)
+ instance.storage = storage
+ instance
+ end
+
+ # Loads a classifier from a file (legacy API).
+ #
+ # @rbs (String) -> Bayes
+ def self.load_from_file(path)
+ from_json(File.read(path))
+ end
+
+ #
+ # Provides training and untraining methods for the categories specified in Bayes#new
+ # For example:
+ # b = Classifier::Bayes.new 'This', 'That', 'the_other'
+ # b.train_this "This text"
+ # b.train_that "That text"
+ # b.untrain_that "That text"
+ # b.train_the_other "The other text"
+ def method_missing(name, *args)
+ return super unless name.to_s =~ /(un)?train_(\w+)/
+
+ category = name.to_s.gsub(/(un)?train_(\w+)/, '\2').prepare_category_name
+ raise StandardError, "No such category: #{category}" unless @categories.key?(category)
+
+ method = name.to_s.start_with?('untrain_') ? :untrain : :train
+ args.each { |text| send(method, category, text) }
+ end
+
+ # @rbs (Symbol, ?bool) -> bool
+ def respond_to_missing?(name, include_private = false)
+ !!(name.to_s =~ /(un)?train_(\w+)/) || super
+ end
+
+ # Provides a list of category names
+ # For example:
+ # b.categories
+ # => ['This', 'That', 'the_other']
+ #
+ # @rbs () -> Array[String]
+ def categories
+ synchronize { @categories.keys.collect(&:to_s) }
+ end
+
+ # Allows you to add categories to the classifier.
+ # For example:
+ # b.add_category "Not spam"
+ #
+ # WARNING: Adding categories to a trained classifier will
+ # result in an undertrained category that will tend to match
+ # more criteria than the trained selective categories. In short,
+ # try to initialize your categories at initialization.
+ #
+ # @rbs (String | Symbol) -> Hash[Symbol, Integer]
+ def add_category(category)
+ synchronize do
+ invalidate_caches
+ @dirty = true
+ @categories[category.prepare_category_name] = {}
+ end
+ end
+
+ alias append_category add_category
+
+ # Custom marshal serialization to exclude mutex state
+ # @rbs () -> Array[untyped]
+ def marshal_dump
+ [@categories, @total_words, @category_counts, @category_word_count, @dirty]
+ end
+
+ # Custom marshal deserialization to recreate mutex
+ # @rbs (Array[untyped]) -> void
+ def marshal_load(data)
+ mu_initialize
+ @categories, @total_words, @category_counts, @category_word_count, @dirty = data
+ @cached_training_count = nil
+ @cached_vocab_size = nil
+ @storage = nil
+ end
+
+ # Allows you to remove categories from the classifier.
+ # For example:
+ # b.remove_category "Spam"
+ #
+ # WARNING: Removing categories from a trained classifier will
+ # result in the loss of all training data for that category.
+ # Make sure you really want to do this before calling this method.
+ #
+ # @rbs (String | Symbol) -> void
+ def remove_category(category)
+ category = category.prepare_category_name
+ synchronize do
+ raise StandardError, "No such category: #{category}" unless @categories.key?(category)
+
+ invalidate_caches
+ @dirty = true
+ @total_words -= @category_word_count[category].to_i
+
+ @categories.delete(category)
+ @category_counts.delete(category)
+ @category_word_count.delete(category)
+ end
+ end
+
+ # Trains the classifier from an IO stream.
+ # Each line in the stream is treated as a separate document.
+ # This is memory-efficient for large corpora.
+ #
+ # @example Train from a file
+ # classifier.train_from_stream(:spam, File.open('spam_corpus.txt'))
+ #
+ # @example With progress tracking
+ # classifier.train_from_stream(:spam, io, batch_size: 500) do |progress|
+ # puts "#{progress.completed} documents processed"
+ # end
+ #
+ # @rbs (?(String | Symbol | nil), ?IO?, ?batch_size: Integer, **IO) { (Streaming::Progress) -> void } -> void
+ def train_from_stream(category = nil, io = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &)
+ raise ArgumentError, 'Provide either (category, io) or keyword category: io pairs' if category.nil? && io.nil? && categories.empty?
+ raise ArgumentError, 'Provide both category and io, or use keyword arguments' if [category, io].one?(&:nil?)
+
+ pairs = category && io ? { category => io } : categories
+ pairs.each do |cat, stream|
+ stream_train_category(cat, stream, batch_size: batch_size, &)
+ end
+ end
+
+ # Trains the classifier with an array of documents in batches.
+ # Reduces lock contention by processing multiple documents per synchronize call.
+ #
+ # @example Positional style
+ # classifier.train_batch(:spam, documents, batch_size: 100)
+ #
+ # @example Keyword style
+ # classifier.train_batch(spam: documents, ham: other_docs, batch_size: 100)
+ #
+ # @example With progress tracking
+ # classifier.train_batch(:spam, documents, batch_size: 100) do |progress|
+ # puts "#{progress.percent}% complete"
+ # end
+ #
+ # @rbs (?(String | Symbol)?, ?Array[String]?, ?batch_size: Integer, **Array[String]) { (Streaming::Progress) -> void } -> void
+ def train_batch(category = nil, documents = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &block)
+ if category && documents
+ train_batch_for_category(category, documents, batch_size: batch_size, &block)
+ else
+ categories.each do |cat, docs|
+ train_batch_for_category(cat, Array(docs), batch_size: batch_size, &block)
+ end
+ end
+ end
+
+ # Loads a classifier from a checkpoint.
+ #
+ # @rbs (storage: Storage::Base, checkpoint_id: String) -> Bayes
+ def self.load_checkpoint(storage:, checkpoint_id:)
+ raise ArgumentError, 'Storage must be File storage for checkpoints' unless storage.is_a?(Storage::File)
+
+ dir = File.dirname(storage.path)
+ base = File.basename(storage.path, '.*')
+ ext = File.extname(storage.path)
+ checkpoint_path = File.join(dir, "#{base}_checkpoint_#{checkpoint_id}#{ext}")
+
+ checkpoint_storage = Storage::File.new(path: checkpoint_path)
+ instance = load(storage: checkpoint_storage)
+ instance.storage = storage
+ instance
+ end
+
+ private
+
+ # Trains from an IO stream with a single category.
+ # @rbs (String | Symbol, IO, batch_size: Integer) { (Streaming::Progress) -> void } -> void
+ def stream_train_category(category, io, batch_size:)
+ category = category.prepare_category_name
+ raise ArgumentError, "No such category: #{category}" unless @categories.key?(category)
+ raise ArgumentError, 'Stream must respond to #each_line' unless io.respond_to?(:each_line)
+
+ reader = Streaming::LineReader.new(io, batch_size: batch_size)
+ total = reader.estimate_line_count
+ progress = Streaming::Progress.new(total: total)
+
+ reader.each_batch do |batch|
+ train_batch_internal(category, batch)
+ progress.completed += batch.size
+ progress.current_batch += 1
+ yield progress if block_given?
+ end
+ end
+
+ # Trains a batch of documents for a single category.
+ # @rbs (String | Symbol, Array[String], ?batch_size: Integer) { (Streaming::Progress) -> void } -> void
+ def train_batch_for_category(category, documents, batch_size: Streaming::DEFAULT_BATCH_SIZE)
+ category = category.prepare_category_name
+ raise StandardError, "No such category: #{category}" unless @categories.key?(category)
+
+ progress = Streaming::Progress.new(total: documents.size)
+
+ documents.each_slice(batch_size) do |batch|
+ train_batch_internal(category, batch)
+ progress.completed += batch.size
+ progress.current_batch += 1
+ yield progress if block_given?
+ end
+ end
+
+ # Internal method to train a batch of documents.
+ # Uses a single synchronize block for the entire batch.
+ # @rbs (Symbol, Array[String]) -> void
+ def train_batch_internal(category, batch)
+ synchronize do
+ invalidate_caches
+ @dirty = true
+ batch.each do |text|
+ word_hash = text.word_hash(@min_word_length)
+ @category_counts[category] += 1
+ word_hash.each do |word, count|
+ @categories[category][word] ||= 0
+ @categories[category][word] += count
+ @total_words += count
+ @category_word_count[category] += count
+ end
+ end
+ end
+ end
+
+ # Core training logic for a single category and text.
+ # @rbs (String | Symbol, String) -> void
+ def train_single(category, text)
+ category = category.prepare_category_name
+ word_hash = text.word_hash(@min_word_length)
+ synchronize do
+ invalidate_caches
+ @dirty = true
+ @category_counts[category] += 1
+ word_hash.each do |word, count|
+ @categories[category][word] ||= 0
+ @categories[category][word] += count
+ @total_words += count
+ @category_word_count[category] += count
+ end
+ end
+ end
+
+ # Core untraining logic for a single category and text.
+ # @rbs (String | Symbol, String) -> void
+ def untrain_single(category, text)
+ category = category.prepare_category_name
+ word_hash = text.word_hash(@min_word_length)
+ synchronize do
+ invalidate_caches
+ @dirty = true
+ @category_counts[category] -= 1
+ word_hash.each do |word, count|
+ next unless @total_words >= 0
+
+ orig = @categories[category][word] || 0
+ @categories[category][word] ||= 0
+ @categories[category][word] -= count
+ if @categories[category][word] <= 0
+ @categories[category].delete(word)
+ count = orig
+ end
+ @category_word_count[category] -= count if @category_word_count[category] >= count
+ @total_words -= count
+ end
+ end
+ end
+
+ # Restores classifier state from a JSON string (used by reload)
+ # @rbs (String) -> void
+ def restore_from_json(json)
+ data = JSON.parse(json)
+ raise ArgumentError, "Invalid classifier type: #{data['type']}" unless data['type'] == 'bayes'
+
+ synchronize do
+ restore_state(data)
+ end
+ end
+
+ # Restores classifier state from a hash (used by from_json)
+ # @rbs (Hash[String, untyped]) -> void
+ def restore_state(data)
+ mu_initialize
+ @categories = {} #: Hash[Symbol, Hash[Symbol, Integer]]
+ @total_words = data['total_words']
+ @category_counts = Hash.new(0) #: Hash[Symbol, Integer]
+ @category_word_count = Hash.new(0) #: Hash[Symbol, Integer]
+ @cached_training_count = nil
+ @cached_vocab_size = nil
+ @dirty = false
+ @storage = nil
+ @min_word_length = data['min_word_length'] || Classifier.config.min_word_length
+
+ data['categories'].each do |cat_name, words|
+ @categories[cat_name.to_sym] = words.transform_keys(&:to_sym)
+ end
+
+ data['category_counts'].each do |cat_name, count|
+ @category_counts[cat_name.to_sym] = count
+ end
+
+ data['category_word_count'].each do |cat_name, count|
+ @category_word_count[cat_name.to_sym] = count
+ end
+ end
+
+ # @rbs () -> void
+ def invalidate_caches
+ @cached_training_count = nil
+ @cached_vocab_size = nil
+ end
+
+ # @rbs () -> Float
+ def cached_training_count
+ @cached_training_count ||= @category_counts.values.sum.to_f
+ end
+ # @rbs () -> Integer
+ def cached_vocab_size
+ @cached_vocab_size ||= [@categories.values.flat_map(&:keys).uniq.size, 1].max
+ end
+ end
end
diff --git a/lib/classifier/cli.rb b/lib/classifier/cli.rb
new file mode 100644
index 0000000..d394ffb
--- /dev/null
+++ b/lib/classifier/cli.rb
@@ -0,0 +1,958 @@
+# rbs_inline: enabled
+
+require 'json'
+require 'optparse'
+require 'net/http'
+require 'uri'
+require 'fileutils'
+require 'classifier'
+
+module Classifier
+ class CLI
+ # @rbs @args: Array[String]
+ # @rbs @stdin: String?
+ # @rbs @options: Hash[Symbol, untyped]
+ # @rbs @output: Array[String]
+ # @rbs @error: Array[String]
+ # @rbs @exit_code: Integer
+ # @rbs @parser: OptionParser
+
+ CLASSIFIER_TYPES = {
+ 'bayes' => :bayes,
+ 'lsi' => :lsi,
+ 'knn' => :knn,
+ 'lr' => :logistic_regression,
+ 'logistic_regression' => :logistic_regression
+ }.freeze
+
+ DEFAULT_REGISTRY = ENV.fetch('CLASSIFIER_REGISTRY', 'cardmagic/classifier-models') #: String
+ CACHE_DIR = ENV.fetch('CLASSIFIER_CACHE', File.expand_path('~/.classifier')) #: String
+
+ def initialize(args, stdin: nil)
+ @args = args.dup
+ @stdin = stdin
+ @options = {
+ model: ENV.fetch('CLASSIFIER_MODEL', './classifier.json'),
+ type: ENV.fetch('CLASSIFIER_TYPE', 'bayes'),
+ probabilities: false,
+ quiet: false,
+ count: 10,
+ k: 5,
+ weighted: false,
+ learning_rate: nil,
+ regularization: nil,
+ max_iterations: nil,
+ remote: nil,
+ output_path: nil
+ }
+ @output = [] #: Array[String]
+ @error = [] #: Array[String]
+ @exit_code = 0
+ end
+
+ def run
+ parse_options
+ execute_command
+ { output: @output.join("\n"), error: @error.join("\n"), exit_code: @exit_code }
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::InvalidArgument => e
+ @error << "Error: #{e.message}"
+ @exit_code = 2
+ { output: @output.join("\n"), error: @error.join("\n"), exit_code: @exit_code }
+ rescue StandardError => e
+ @error << "Error: #{e.message}"
+ @exit_code = 1
+ { output: @output.join("\n"), error: @error.join("\n"), exit_code: @exit_code }
+ end
+
+ private
+
+ def parse_options
+ @parser = OptionParser.new do |opts|
+ opts.banner = 'Usage: classifier [options] [command] [arguments]'
+ opts.separator ''
+ opts.separator 'Commands:'
+ opts.separator ' train <category> [files...] Train a category from files or stdin'
+ opts.separator ' info Show model information'
+ opts.separator ' fit Fit the model (logistic regression)'
+ opts.separator ' search <query> Semantic search (LSI only)'
+ opts.separator ' related <item> Find related documents (LSI only)'
+ opts.separator ' models [registry] List models in registry'
+ opts.separator ' pull <model> Download model from registry'
+ opts.separator ' push <file> Contribute model to registry'
+ opts.separator ' <text> Classify text (default action)'
+ opts.separator ''
+ opts.separator 'Options:'
+
+ opts.on('-f', '--file FILE', 'Model file (default: ./classifier.json)') do |file|
+ @options[:model] = file
+ end
+
+ opts.on('-m', '--model TYPE', 'Classifier model: bayes, lsi, knn, lr (default: bayes)') do |type|
+ unless CLASSIFIER_TYPES.key?(type)
+ raise OptionParser::InvalidArgument, "Unknown classifier model: #{type}. Valid models: #{CLASSIFIER_TYPES.keys.join(', ')}"
+ end
+
+ @options[:type] = type
+ end
+
+ opts.on(
+ '--search TEXT',
+ 'Search remote models by name/description, and local models by name only. Use quotes for multiword search'
+ ) do |text|
+ @options[:search] = text
+ end
+
+ opts.on('-r', '--remote MODEL', 'Use remote model: name or @user/repo:name') do |model|
+ @options[:remote] = model
+ end
+
+ opts.on('-o', '--output FILE', 'Output path for pull command') do |file|
+ @options[:output_path] = file
+ end
+
+ opts.on('-p', 'Show probabilities') do
+ @options[:probabilities] = true
+ end
+
+ opts.on('-n', '--count N', Integer, 'Number of results for search/related (default: 10)') do |n|
+ @options[:count] = n
+ end
+
+ opts.on('-k', '--neighbors N', Integer, 'Number of neighbors for KNN (default: 5)') do |n|
+ @options[:k] = n
+ end
+
+ opts.on('--weighted', 'Use distance-weighted voting for KNN') do
+ @options[:weighted] = true
+ end
+
+ opts.on('--learning-rate N', Float, 'Learning rate for logistic regression (default: 0.1)') do |n|
+ @options[:learning_rate] = n
+ end
+
+ opts.on('--regularization N', Float, 'L2 regularization for logistic regression (default: 0.01)') do |n|
+ @options[:regularization] = n
+ end
+
+ opts.on('--max-iterations N', Integer, 'Max iterations for logistic regression (default: 100)') do |n|
+ @options[:max_iterations] = n
+ end
+
+ opts.on('-q', 'Quiet mode') do
+ @options[:quiet] = true
+ end
+
+ opts.on('--local', 'List locally cached models (for models command)') do
+ @options[:local] = true
+ end
+
+ opts.on('-v', '--version', 'Show version') do
+ @output << Classifier::VERSION
+ @exit_code = 0
+ throw :done
+ end
+
+ opts.on('-h', '--help', 'Show help') do
+ @output << opts.to_s
+ @exit_code = 0
+ throw :done
+ end
+ end
+
+ catch(:done) do
+ @parser.parse!(@args)
+ end
+ end
+
+ def execute_command
+ return if @exit_code != 0 || @output.any?
+
+ command = @args.first
+
+ case command
+ when 'train'
+ command_train
+ when 'info'
+ command_info
+ when 'fit'
+ command_fit
+ when 'search'
+ command_search
+ when 'related'
+ command_related
+ when 'models'
+ command_models
+ when 'pull'
+ command_pull
+ when 'push'
+ command_push
+ else
+ command_classify
+ end
+ end
+
+ def command_train
+ @args.shift # remove 'train'
+ category = @args.shift
+
+ unless category
+ @error << 'Error: category required for train command'
+ @exit_code = 2
+ return
+ end
+
+ classifier = load_or_create_classifier
+
+ if classifier.is_a?(LSI) && @args.any?
+ train_lsi_from_files(classifier, category, @args)
+ save_classifier(classifier)
+ return
+ end
+
+ text = read_training_input
+ if text.empty?
+ @error << 'Error: no training data provided'
+ @exit_code = 2
+ return
+ end
+
+ train_classifier(classifier, category, text)
+ save_classifier(classifier)
+ end
+
+ def command_info
+ unless File.exist?(@options[:model])
+ @error << "Error: model not found at #{@options[:model]}"
+ @exit_code = 1
+ return
+ end
+
+ classifier = load_classifier
+ info = build_model_info(classifier)
+ @output << JSON.pretty_generate(info)
+ end
+
+ def build_model_info(classifier)
+ info = { file: @options[:model], type: classifier_type_name(classifier) }
+ add_common_info(info, classifier)
+ add_classifier_specific_info(info, classifier)
+ info
+ end
+
+ def add_common_info(info, classifier)
+ info[:categories] = classifier.categories.map(&:to_s) if classifier.respond_to?(:categories)
+ info[:training_count] = classifier.training_count if classifier.respond_to?(:training_count)
+ info[:vocab_size] = classifier.vocab_size if classifier.respond_to?(:vocab_size)
+ info[:fitted] = classifier.fitted? if classifier.respond_to?(:fitted?)
+ end
+
+ def add_classifier_specific_info(info, classifier)
+ case classifier
+ when Bayes then add_bayes_info(info, classifier)
+ when LSI then add_lsi_info(info, classifier)
+ when KNN then add_knn_info(info, classifier)
+ end
+ end
+
+ def add_bayes_info(info, classifier)
+ categories_data = classifier.instance_variable_get(:@categories)
+ info[:category_stats] = classifier.categories.to_h do |cat|
+ cat_data = categories_data[cat.to_sym] || {}
+ [cat.to_s, { unique_words: cat_data.size, total_words: cat_data.values.sum }]
+ end
+ end
+
+ def add_lsi_info(info, classifier)
+ info[:documents] = classifier.items.size
+ info[:items] = classifier.items
+ categories = classifier.items.map { |item| classifier.categories_for(item) }.flatten.uniq
+ info[:categories] = categories.map(&:to_s) unless categories.empty?
+ end
+
+ def add_knn_info(info, classifier)
+ data = classifier.instance_variable_get(:@data) || []
+ info[:documents] = data.size
+ categories = data.map { |d| d[:category] }.uniq
+ info[:categories] = categories.map(&:to_s) unless categories.empty?
+ end
+
+ def command_fit
+ unless File.exist?(@options[:model])
+ @error << "Error: model not found at #{@options[:model]}"
+ @exit_code = 1
+ return
+ end
+
+ classifier = load_classifier
+
+ unless classifier.respond_to?(:fit)
+ @output << 'Model does not require fitting' unless @options[:quiet]
+ return
+ end
+
+ classifier.fit
+ save_classifier(classifier)
+ @output << 'Model fitted successfully' unless @options[:quiet]
+ end
+
+ def command_search
+ @args.shift # remove 'search'
+
+ unless File.exist?(@options[:model])
+ @error << "Error: model not found at #{@options[:model]}"
+ @exit_code = 1
+ return
+ end
+
+ classifier = load_classifier
+
+ unless classifier.is_a?(LSI)
+ @error << 'Error: search requires LSI model (use -t lsi)'
+ @exit_code = 1
+ return
+ end
+
+ query = @args.join(' ')
+ query = read_stdin_line if query.empty?
+
+ if query.empty?
+ @error << 'Error: search query required'
+ @exit_code = 2
+ return
+ end
+
+ results = classifier.search(query, @options[:count])
+ results.each do |item|
+ score = classifier.proximity_norms_for_content(query).find { |i, _| i == item }&.last || 0
+ @output << "#{item}:#{format('%.2f', score)}"
+ end
+ end
+
+ def command_related
+ @args.shift # remove 'related'
+ item = @args.shift
+
+ unless item
+ @error << 'Error: item required for related command'
+ @exit_code = 2
+ return
+ end
+
+ unless File.exist?(@options[:model])
+ @error << "Error: model not found at #{@options[:model]}"
+ @exit_code = 1
+ return
+ end
+
+ classifier = load_classifier
+
+ unless classifier.is_a?(LSI)
+ @error << 'Error: related requires LSI model (use -t lsi)'
+ @exit_code = 1
+ return
+ end
+
+ unless classifier.items.include?(item)
+ @error << "Error: item not found in model: #{item}"
+ @exit_code = 1
+ return
+ end
+
+ results = classifier.find_related(item, @options[:count])
+ results.each do |related_item|
+ scores = classifier.proximity_array_for_content(item)
+ score = scores.find { |i, _| i == related_item }&.last || 0
+ @output << "#{related_item}:#{format('%.2f', score)}"
+ end
+ end
+
+ def command_models
+ @args.shift # remove 'models'
+
+ if @options[:local]
+ list_local_models
+ else
+ list_remote_models
+ end
+ end
+
+ def list_remote_models
+ registry_or_model_arg = @args.shift
+ registry, model = detect_registry_and_model(registry_or_model_arg)
+ registry = parse_registry(registry) || DEFAULT_REGISTRY
+ index = fetch_registry_index(registry)
+
+ return if @exit_code != 0
+
+ models = index['models']
+
+ if model
+ info = models[model]
+ if info.nil?
+ @output << "No model #{model.inspect} found in registry"
+ return
+ end
+
+ type = info['type'] || 'unknown'
+ size = info['size'] || 'unknown'
+ desc = info['description'] || ''
+ categories = (info['categories'] || []).map(&:downcase).join(', ')
+ version = info['version'] || ''
+ author = info['author'] || ''
+ @output << format(
+ "Name: %<name>s\nDescription: %<desc>s\nType: %<type>s\n" \
+ "Categories: %<categories>s\nVersion: %<version>s\nAuthor: %<author>s\nSize: %<size>s",
+ name: model, desc: desc, type: type, categories: categories,
+ version: version, author: author, size: size
+ )
+ return
+ end
+
+ if @options[:search]
+ models = models.filter do |name, info|
+ [name, info['description']].any?(/#{Regexp.escape(@options[:search])}/i)
+ end
+ end
+
+ if models.empty?
+ @output << 'No models found in registry'
+ return
+ end
+
+ models.each do |name, info|
+ type = info['type'] || 'unknown'
+ size = info['size'] || 'unknown'
+ desc = info['description'] || ''
+ @output << format('%-20<name>s %<desc>s (%<type>s, %<size>s)', name: name, desc: desc.slice(0, 40), type: type, size: size)
+ end
+ end
+
+ def list_local_models
+ model_arg = @args.shift
+
+ models_dir = File.join(CACHE_DIR, 'models')
+
+ unless Dir.exist?(models_dir)
+ @output << 'No local models found'
+ return
+ end
+
+ # Find models from default registry
+ default_models = Dir.glob(File.join(models_dir, '*.json')).map do |path|
+ { name: File.basename(path, '.json'), registry: nil, path: path }
+ end
+
+ # Find models from custom registries (@user/repo structure)
+ custom_models = Dir.glob(File.join(models_dir, '@*', '*', '*.json')).map do |path|
+ # Extract registry from path: .../models/@user/repo/model.json
+ repo_dir = File.dirname(path)
+ user_dir = File.dirname(repo_dir)
+ registry = "#{File.basename(user_dir).delete_prefix('@')}/#{File.basename(repo_dir)}"
+ { name: File.basename(path, '.json'), registry: registry, path: path }
+ end
+
+ models = default_models + custom_models #: Array[{name: String, registry: String?, path: String}]
+
+ models.each do |model|
+ model[:info] = load_model_info(model[:path])
+ end
+
+ if model_arg
+ model = models.find { |model| model[:name] == model_arg }
+ if model.nil?
+ @output << "No local model #{model_arg.inspect} found"
+ return
+ end
+ display_name = model[:registry] ? "@#{model[:registry]}:#{model[:name]}" : model[:name]
+ type = model.dig(:info, 'type') || 'unknown'
+ version = model.dig(:info, 'version')
+ categories = (model.dig(:info, 'categories') || {}).keys.map(&:downcase).join(', ')
+ size = File.size(model[:path])
+ @output << format(
+ "Name: %<name>s\nType: %<type>s\n" \
+ "Categories: %<categories>s\nVersion: %<version>s\nSize: %<size>s",
+ name: display_name, type: type, categories: categories,
+ version: version, size: human_size(size)
+ )
+ return
+ end
+
+ if @options[:search]
+ models = models.filter do |model|
+ [model[:name], model.dig(:info, 'description')].any?(/#{Regexp.escape(@options[:search])}/i)
+ end
+ end
+
+ if models.empty?
+ @output << 'No local models found'
+ return
+ end
+
+ models.each do |model|
+ type = model.dig(:info, 'type') || 'unknown'
+ display_name = model[:registry] ? "@#{model[:registry]}:#{model[:name]}" : model[:name]
+ size = File.size(model[:path])
+ @output << format('%-30<name>s (%<type>s, %<size>s)', name: display_name, type: type, size: human_size(size))
+ end
+ end
+
+ def load_model_info(path)
+ JSON.parse(File.read(path))
+ rescue JSON::ParserError
+ {}
+ end
+
+ def human_size(bytes)
+ units = %w[B KB MB GB]
+ unit_index = 0
+ size = bytes.to_f
+
+ while size >= 1024 && unit_index < units.length - 1
+ size /= 1024
+ unit_index += 1
+ end
+
+ format('%<size>.1f %<unit>s', size: size, unit: units[unit_index])
+ end
+
+ def command_pull
+ @args.shift # remove 'pull'
+ model_spec = @args.shift
+
+ unless model_spec
+ @error << 'Error: model name required for pull command'
+ @exit_code = 2
+ return
+ end
+
+ registry, model_name = parse_model_spec(model_spec)
+ registry ||= DEFAULT_REGISTRY
+
+ if model_name.nil?
+ pull_all_models(registry)
+ else
+ pull_single_model(registry, model_name)
+ end
+ end
+
+ def pull_single_model(registry, model_name)
+ index = fetch_registry_index(registry)
+ return if @exit_code != 0
+
+ model_info = index['models'][model_name]
+ unless model_info
+ @error << "Error: model '#{model_name}' not found in registry #{registry}"
+ @exit_code = 1
+ return
+ end
+
+ file_path = model_info['file'] || "models/#{model_name}.json"
+ output_path = @options[:output_path] || cache_path_for(registry, model_name)
+
+ @output << "Downloading #{model_name} from #{registry}..." unless @options[:quiet]
+
+ content = fetch_github_file(registry, file_path)
+ return if @exit_code != 0
+
+ FileUtils.mkdir_p(File.dirname(output_path))
+ File.write(output_path, content)
+
+ @output << "Saved to #{output_path}" unless @options[:quiet]
+ end
+
+ def pull_all_models(registry)
+ index = fetch_registry_index(registry)
+ return if @exit_code != 0
+
+ if index['models'].empty?
+ @output << 'No models found in registry'
+ return
+ end
+
+ @output << "Downloading #{index['models'].size} models from #{registry}..." unless @options[:quiet]
+
+ index['models'].each_key do |model_name|
+ pull_single_model(registry, model_name)
+ break if @exit_code != 0
+ end
+ end
+
+ def command_push
+ @args.shift # remove 'push'
+
+ @output << 'To contribute a model to the registry:'
+ @output << ''
+ @output << '1. Fork https://github.com/cardmagic/classifier-models'
+ @output << '2. Add your model to the models/ directory'
+ @output << '3. Update models.json with your model metadata'
+ @output << '4. Create a pull request'
+ @output << ''
+ @output << 'Or use the GitHub CLI:'
+ @output << ''
+ @output << ' gh repo fork cardmagic/classifier-models --clone'
+ @output << ' cp ./classifier.json classifier-models/models/my-model.json'
+ @output << ' # Edit classifier-models/models.json to add your model'
+ @output << ' cd classifier-models && gh pr create'
+ end
+
+ def command_classify
+ text = @args.join(' ')
+
+ if @options[:remote]
+ classify_with_remote(text)
+ return
+ end
+
+ if text.empty? && ($stdin.tty? || @stdin.nil?) && !File.exist?(@options[:model])
+ show_getting_started
+ return
+ end
+
+ unless File.exist?(@options[:model])
+ @error << "Error: model not found at #{@options[:model]}"
+ @exit_code = 1
+ return
+ end
+
+ classifier = load_classifier
+
+ if text.empty?
+ lines = read_stdin_lines
+ return show_model_usage(classifier) if lines.empty?
+
+ lines.each { |line| classify_and_output(classifier, line) }
+ else
+ classify_and_output(classifier, text)
+ end
+ end
+
+ def classify_with_remote(text)
+ registry, model_name = parse_model_spec(@options[:remote])
+ registry ||= DEFAULT_REGISTRY
+
+ unless model_name
+ @error << 'Error: model name required for -r option'
+ @exit_code = 2
+ return
+ end
+
+ cached_path = cache_path_for(registry, model_name)
+
+ unless File.exist?(cached_path)
+ pull_single_model(registry, model_name)
+ return if @exit_code != 0
+ end
+
+ original_model = @options[:model]
+ @options[:model] = cached_path
+
+ begin
+ classifier = load_classifier
+
+ if text.empty?
+ lines = read_stdin_lines
+ return show_model_usage(classifier) if lines.empty?
+
+ lines.each { |line| classify_and_output(classifier, line) }
+ else
+ classify_and_output(classifier, text)
+ end
+ ensure
+ @options[:model] = original_model
+ end
+ end
+
+ # @rbs (untyped) -> void
+ def show_model_usage(classifier)
+ type = classifier_type_name(classifier)
+ cats = classifier.categories.map(&:to_s).map(&:downcase)
+ first_cat = cats.first || 'category'
+
+ @output << "Model: #{@options[:model]} (#{type})"
+ @output << "Categories: #{cats.join(', ')}"
+ @output << ''
+ @output << 'Classify text:'
+ @output << ''
+ @output << " classifier 'text to classify'"
+ @output << " echo 'text to classify' | classifier"
+ @output << ''
+ @output << 'Train more data:'
+ @output << ''
+ @output << " echo 'new example text' | classifier train #{first_cat}"
+ @output << " classifier train #{first_cat} file1.txt file2.txt"
+ @output << ''
+ @output << 'Other commands:'
+ @output << ''
+ @output << ' classifier info Show model details (JSON)'
+ end
+
+ def classify_and_output(classifier, text)
+ return if text.strip.empty?
+
+ if classifier.is_a?(LogisticRegression) && !classifier.fitted?
+ raise StandardError, "Model not fitted. Run 'classifier fit' after training."
+ end
+
+ if @options[:probabilities]
+ probs = get_probabilities(classifier, text)
+ formatted = probs.map { |cat, prob| "#{cat.downcase}:#{format('%.2f', prob)}" }.join(' ')
+ @output << formatted
+ else
+ result = classifier.classify(text)
+ @output << result.downcase
+ end
+ end
+
+ def get_probabilities(classifier, text)
+ if classifier.respond_to?(:probabilities)
+ classifier.probabilities(text)
+ elsif classifier.respond_to?(:classifications)
+ scores = classifier.classifications(text)
+ normalize_scores(scores)
+ else
+ { classifier.classify(text) => 1.0 }
+ end
+ end
+
+ def normalize_scores(scores)
+ max_score = scores.values.max
+ exp_scores = scores.transform_values { |s| Math.exp(s - max_score) }
+ total = exp_scores.values.sum.to_f
+ exp_scores.transform_values { |s| (s / total).to_f }
+ end
+
+ def load_or_create_classifier
+ if File.exist?(@options[:model])
+ load_classifier
+ else
+ create_classifier
+ end
+ end
+
+ def load_classifier
+ json = File.read(@options[:model])
+ data = JSON.parse(json)
+ type = data['type']
+
+ case type
+ when 'bayes'
+ Bayes.from_json(data)
+ when 'lsi'
+ LSI.from_json(data)
+ when 'knn'
+ KNN.from_json(data)
+ when 'logistic_regression'
+ LogisticRegression.from_json(data)
+ else
+ raise "Unknown classifier type in model: #{type}"
+ end
+ end
+
+ def create_classifier
+ type = CLASSIFIER_TYPES[@options[:type]] || :bayes
+
+ case type
+ when :lsi
+ LSI.new(auto_rebuild: true)
+ when :knn
+ KNN.new(k: @options[:k], weighted: @options[:weighted])
+ when :logistic_regression
+ lr_opts = {} #: Hash[Symbol, untyped]
+ lr_opts[:learning_rate] = @options[:learning_rate] if @options[:learning_rate]
+ lr_opts[:regularization] = @options[:regularization] if @options[:regularization]
+ lr_opts[:max_iterations] = @options[:max_iterations] if @options[:max_iterations]
+ LogisticRegression.new(**lr_opts)
+ else # :bayes or unknown defaults to Bayes
+ Bayes.new
+ end
+ end
+
+ def train_classifier(classifier, category, text)
+ case classifier
+ when Bayes, LogisticRegression
+ classifier.add_category(category) unless classifier.categories.include?(category)
+ text.each_line { |line| classifier.train(category, line.strip) unless line.strip.empty? }
+ when LSI
+ text.each_line do |line|
+ next if line.strip.empty?
+
+ classifier.add_item(line.strip, category.to_sym)
+ end
+ when KNN
+ text.each_line do |line|
+ next if line.strip.empty?
+
+ classifier.add(category.to_sym => line.strip)
+ end
+ end
+ end
+
+ def train_lsi_from_files(classifier, category, files)
+ files.each do |file|
+ content = File.read(file)
+ classifier.add_item(file, category.to_sym) { content }
+ end
+ end
+
+ def save_classifier(classifier)
+ classifier.storage = Storage::File.new(path: @options[:model])
+ classifier.save
+ end
+
+ def classifier_type_name(classifier)
+ case classifier
+ when Bayes then 'bayes'
+ when LSI then 'lsi'
+ when KNN then 'knn'
+ when LogisticRegression then 'logistic_regression'
+ else 'unknown'
+ end
+ end
+
+ def read_training_input
+ if @args.any?
+ @args.map { |file| File.read(file) }.join("\n")
+ else
+ read_stdin
+ end
+ end
+
+ def read_stdin
+ @stdin || ($stdin.tty? ? '' : $stdin.read)
+ end
+
+ def read_stdin_line
+ (@stdin || ($stdin.tty? ? '' : $stdin.read)).to_s.strip
+ end
+
+ def read_stdin_lines
+ read_stdin.to_s.split("\n").map(&:strip).reject(&:empty?)
+ end
+
+ # @rbs () -> void
+ def show_getting_started
+ @output << 'Classifier - Text classification from the command line'
+ @output << ''
+ @output << 'Get started by training some categories:'
+ @output << ''
+ @output << ' # Train from files'
+ @output << ' classifier train spam spam_emails/*.txt'
+ @output << ' classifier train ham good_emails/*.txt'
+ @output << ''
+ @output << ' # Train from stdin'
+ @output << " echo 'buy viagra now free pills cheap meds' | classifier train spam"
+ @output << " echo 'meeting scheduled for tomorrow to discuss project' | classifier train ham"
+ @output << ''
+ @output << 'Then classify text:'
+ @output << ''
+ @output << " classifier 'free money buy now'"
+ @output << " classifier 'meeting postponed to friday'"
+ @output << ''
+ @output << 'Use LSI for semantic search:'
+ @output << ''
+ @output << " echo 'ruby is a dynamic programming language' | classifier train docs -m lsi"
+ @output << " echo 'python is great for data science' | classifier train docs -m lsi"
+ @output << " classifier search 'programming'"
+ @output << ''
+ @output << 'Options:'
+ @output << ' -f FILE Model file (default: ./classifier.json)'
+ @output << ' -m TYPE Model type: bayes, lsi, knn, lr (default: bayes)'
+ @output << ' -r MODEL Use remote model from registry'
+ @output << ' -p Show probabilities'
+ @output << ''
+ @output << 'Use pre-trained models:'
+ @output << ''
+ @output << ' classifier models List available models'
+ @output << ' classifier pull sentiment Download a model'
+ @output << " classifier -r sentiment 'I love this!' Classify with remote model"
+ @output << ''
+ @output << 'Run "classifier --help" for full usage.'
+ end
+
+ # @rbs (String?) -> [String?, String?]
+ def detect_registry_and_model(arg)
+ return nil, nil if arg.nil?
+ return *arg.split(':', 2) if arg.include?(':')
+ return nil, arg unless arg.start_with?('@')
+
+ [arg, nil]
+ end
+
+ # Parse @user/repo format to extract registry
+ # @rbs (String?) -> String?
+ def parse_registry(arg)
+ return nil unless arg
+ return nil unless arg.start_with?('@')
+
+ # @user/repo format
+ arg[1..] # Remove @ prefix
+ end
+
+ # Parse model spec: name, @user/repo:name, or @user/repo (for all models)
+ # Returns [registry, model_name] where model_name is nil if pulling all
+ # @rbs (String) -> [String?, String?]
+ def parse_model_spec(spec)
+ if spec.start_with?('@')
+ # @user/repo:model or @user/repo
+ rest = spec[1..] || ''
+ if spec.include?(':')
+ parts = rest.split(':', 2)
+ [parts[0], parts[1]]
+ else
+ # @user/repo - pull all models from registry
+ [rest, nil]
+ end
+ else
+ # Just a model name from default registry
+ [nil, spec]
+ end
+ end
+
+ # Get cache path for a model
+ # @rbs (String, String) -> String
+ def cache_path_for(registry, model_name)
+ if registry == DEFAULT_REGISTRY
+ File.join(CACHE_DIR, 'models', "#{model_name}.json")
+ else
+ File.join(CACHE_DIR, 'models', "@#{registry}", "#{model_name}.json")
+ end
+ end
+
+ # Fetch models.json index from a registry
+ # @rbs (String) -> Hash[String, untyped]
+ def fetch_registry_index(registry)
+ content = fetch_github_file(registry, 'models.json')
+ return { 'models' => {} } if @exit_code != 0
+
+ JSON.parse(content)
+ rescue JSON::ParserError => e
+ @error << "Error: invalid models.json in registry: #{e.message}"
+ @exit_code = 1
+ { 'models' => {} }
+ end
+
+ # Fetch a file from GitHub raw content
+ # @rbs (String, String) -> String
+ def fetch_github_file(registry, file_path)
+ url = "https://raw.githubusercontent.com/#{registry}/main/#{file_path}"
+ uri = URI.parse(url)
+
+ response = Net::HTTP.get_response(uri)
+
+ unless response.is_a?(Net::HTTPSuccess)
+ # Try master branch if main fails
+ url = "https://raw.githubusercontent.com/#{registry}/master/#{file_path}"
+ uri = URI.parse(url)
+ response = Net::HTTP.get_response(uri)
+ end
+
+ unless response.is_a?(Net::HTTPSuccess)
+ @error << "Error: failed to fetch #{file_path} from #{registry} (#{response.code})"
+ @exit_code = 1
+ return ''
+ end
+
+ response.body.force_encoding(Encoding::UTF_8)
+ end
+ end
+end
diff --git a/lib/classifier/config.rb b/lib/classifier/config.rb
new file mode 100644
index 0000000..9cb070b
--- /dev/null
+++ b/lib/classifier/config.rb
@@ -0,0 +1,31 @@
+# rbs_inline: enabled
+
+module Classifier
+ # @rbs @config: Config?
+
+ # This lazy initialization is not thread-safe.
+ # In multi-threaded environments, ensure this method is called
+ # or configuration is set explicitly during startup before using classifiers.
+ # @rbs () -> Config
+ def config
+ @config ||= Config.new
+ end
+
+ # @rbs () { (Config) -> void } -> void
+ def configure(&block)
+ block&.call(config)
+ end
+
+ module_function :config, :configure
+
+ class Config
+ # @rbs @min_word_length: Integer
+
+ attr_accessor :min_word_length #: Integer
+
+ # @rbs () -> void
+ def initialize
+ @min_word_length = 3
+ end
+ end
+end
diff --git a/lib/classifier/errors.rb b/lib/classifier/errors.rb
new file mode 100644
index 0000000..89fd6b0
--- /dev/null
+++ b/lib/classifier/errors.rb
@@ -0,0 +1,19 @@
+# rbs_inline: enabled
+
+# Author:: Lucas Carlson (mailto:lucas@rufy.com)
+# Copyright:: Copyright (c) 2005 Lucas Carlson
+# License:: LGPL
+
+module Classifier
+ # Base error class for all Classifier errors
+ class Error < StandardError; end
+
+ # Raised when reload would discard unsaved changes
+ class UnsavedChangesError < Error; end
+
+ # Raised when a storage operation fails
+ class StorageError < Error; end
+
+ # Raised when using an unfitted model
+ class NotFittedError < Error; end
+end
diff --git a/lib/classifier/extensions/string.rb b/lib/classifier/extensions/string.rb
index 6f144ed..0613395 100644
--- a/lib/classifier/extensions/string.rb
+++ b/lib/classifier/extensions/string.rb
@@ -6,5 +6,7 @@ require 'fast_stemmer'
require 'classifier/extensions/word_hash'
class Object
- def prepare_category_name; to_s.gsub("_"," ").capitalize.intern end
+ def prepare_category_name
+ to_s.gsub('_', ' ').capitalize.intern
+ end
end
diff --git a/lib/classifier/extensions/vector.rb b/lib/classifier/extensions/vector.rb
index 7f8a61d..ef74ef3 100644
--- a/lib/classifier/extensions/vector.rb
+++ b/lib/classifier/extensions/vector.rb
@@ -1,112 +1,122 @@
+# rbs_inline: enabled
+
# Author:: Ernest Ellingson
-# Copyright:: Copyright (c) 2005
+# Copyright:: Copyright (c) 2005
# These are extensions to the std-lib 'matrix' to allow an all ruby SVD
require 'matrix'
-require 'mathn'
+# @rbs skip
class Array
- def sum(identity = 0, &block)
- return identity unless size > 0
-
- if block_given?
- map(&block).sum
- else
- reduce(:+)
- end
+ def sum_with_identity(identity = 0.0, &)
+ return identity unless size.to_i.positive?
+ return map(&).sum_with_identity(identity) if block_given?
+
+ compact.reduce(identity, :+).to_f
end
end
+# @rbs skip
class Vector
+ EPSILON = 1e-10
+
+ # Cache magnitude since Vector is immutable after creation
+ # Note: We undefine the matrix gem's normalize method first, then redefine it
+ # to provide a more robust implementation that handles zero vectors
+ undef_method :normalize if method_defined?(:normalize)
+
def magnitude
- sumsqs = 0.0
- self.size.times do |i|
- sumsqs += self[i] ** 2.0
+ # Cache magnitude since Vector is immutable after creation
+ @magnitude ||= begin
+ sum_of_squares = 0.to_r
+ size.times do |i|
+ sum_of_squares += self[i]**2.to_r
+ end
+ Math.sqrt(sum_of_squares.to_f)
end
- Math.sqrt(sumsqs)
end
- def normalize
- nv = []
- mag = self.magnitude
- self.size.times do |i|
- nv << (self[i] / mag)
+ def normalize
+ magnitude_value = magnitude
+ return Vector[*Array.new(size, 0.0)] if magnitude_value <= 0.0
+ normalized_values = []
+ size.times do |i|
+ normalized_values << (self[i] / magnitude_value)
end
- Vector[*nv]
+ Vector[*normalized_values]
end
end
+# @rbs skip
class Matrix
- def Matrix.diag(s)
- Matrix.diagonal(*s)
+ def self.diag(diagonal_elements)
+ Matrix.diagonal(*diagonal_elements)
end
-
- alias :trans :transpose
-
- def SV_decomp(maxSweeps = 20)
- if self.row_size >= self.column_size
- q = self.trans * self
- else
- q = self * self.trans
- end
-
- qrot = q.dup
- v = Matrix.identity(q.row_size)
- azrot = nil
- mzrot = nil
- cnt = 0
- s_old = nil
- mu = nil
-
- while true do
- cnt += 1
- for row in (0...qrot.row_size-1) do
- for col in (1..qrot.row_size-1) do
+
+ alias trans transpose
+
+ def SV_decomp(max_sweeps = 20)
+ q_matrix = if row_size >= column_size
+ trans * self
+ else
+ self * trans
+ end
+
+ q_rotation_matrix = q_matrix.dup
+ v_matrix = Matrix.identity(q_matrix.row_size)
+ iteration_count = 0
+ previous_s_matrix = nil
+
+ loop do
+ iteration_count += 1
+ (0...(q_rotation_matrix.row_size - 1)).each do |row|
+ (1..(q_rotation_matrix.row_size - 1)).each do |col|
next if row == col
- h = Math.atan((2 * qrot[row,col])/(qrot[row,row]-qrot[col,col]))/2.0
- hcos = Math.cos(h)
- hsin = Math.sin(h)
- mzrot = Matrix.identity(qrot.row_size)
- mzrot[row,row] = hcos
- mzrot[row,col] = -hsin
- mzrot[col,row] = hsin
- mzrot[col,col] = hcos
- qrot = mzrot.trans * qrot * mzrot
- v = v * mzrot
- end
+
+ numerator = 2.0 * q_rotation_matrix[row, col]
+ denominator = q_rotation_matrix[row, row] - q_rotation_matrix[col, col]
+
+ angle = if denominator.abs < Vector::EPSILON
+ numerator >= 0 ? Math::PI / 4.0 : -Math::PI / 4.0
+ else
+ Math.atan(numerator / denominator) / 2.0
+ end
+
+ cosine = Math.cos(angle)
+ sine = Math.sin(angle)
+ rotation_matrix = Matrix.identity(q_rotation_matrix.row_size)
+ rotation_matrix[row, row] = cosine
+ rotation_matrix[row, col] = -sine
+ rotation_matrix[col, row] = sine
+ rotation_matrix[col, col] = cosine
+ q_rotation_matrix = rotation_matrix.trans * q_rotation_matrix * rotation_matrix
+ v_matrix *= rotation_matrix
+ end
end
- s_old = qrot.dup if cnt == 1
- sum_qrot = 0.0
- if cnt > 1
- qrot.row_size.times do |r|
- sum_qrot += (qrot[r,r]-s_old[r,r]).abs if (qrot[r,r]-s_old[r,r]).abs > 0.001
+ previous_s_matrix = q_rotation_matrix.dup if iteration_count == 1
+ sum_of_differences = 0.to_r
+ if iteration_count > 1
+ q_rotation_matrix.row_size.times do |r|
+ difference = (q_rotation_matrix[r, r] - previous_s_matrix[r, r]).abs
+ sum_of_differences += difference.to_r if difference > 0.001
end
- s_old = qrot.dup
- end
- break if (sum_qrot <= 0.001 and cnt > 1) or cnt >= maxSweeps
- end # of do while true
- s = []
- qrot.row_size.times do |r|
- s << Math.sqrt(qrot[r,r])
+ previous_s_matrix = q_rotation_matrix.dup
+ end
+ break if (sum_of_differences <= 0.001 && iteration_count > 1) || iteration_count >= max_sweeps
end
- #puts "cnt = #{cnt}"
- if self.row_size >= self.column_size
- mu = self * v * Matrix.diagonal(*s).inverse
- return [mu, v, s]
- else
- puts v.row_size
- puts v.column_size
- puts self.row_size
- puts self.column_size
- puts s.size
-
- mu = (self.trans * v * Matrix.diagonal(*s).inverse)
- return [mu, v, s]
+
+ singular_values = q_rotation_matrix.row_size.times.map do |r|
+ Math.sqrt([q_rotation_matrix[r, r].to_f, 0.0].max)
end
+
+ safe_singular_values = singular_values.map { |v| [v, Vector::EPSILON].max }
+ u_matrix = (row_size >= column_size ? self : trans) * v_matrix * Matrix.diagonal(*safe_singular_values).inverse
+ [u_matrix, v_matrix, singular_values]
end
- def []=(i,j,val)
- @rows[i][j] = val
+
+ def []=(row_index, col_index, value)
+ @rows[row_index][col_index] = value
end
end
diff --git a/lib/classifier/extensions/vector_serialize.rb b/lib/classifier/extensions/vector_serialize.rb
deleted file mode 100644
index 71d8d66..0000000
--- a/lib/classifier/extensions/vector_serialize.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-module GSL
-
- class Vector
- def _dump(v)
- Marshal.dump( self.to_a )
- end
-
- def self._load(arr)
- arry = Marshal.load(arr)
- return GSL::Vector.alloc(arry)
- end
-
- end
-
- class Matrix
- class <<self
- alias :diag :diagonal
- end
- end
-end
diff --git a/lib/classifier/extensions/word_hash.rb b/lib/classifier/extensions/word_hash.rb
index 928387d..c51a8d8 100644
--- a/lib/classifier/extensions/word_hash.rb
+++ b/lib/classifier/extensions/word_hash.rb
@@ -1,136 +1,139 @@
+# rbs_inline: enabled
+
# Author:: Lucas Carlson (mailto:lucas@rufy.com)
# Copyright:: Copyright (c) 2005 Lucas Carlson
# License:: LGPL
-require "set"
+require 'set'
-# These are extensions to the String class to provide convenience
+# These are extensions to the String class to provide convenience
# methods for the Classifier package.
class String
-
- # Removes common punctuation symbols, returning a new string.
+ # Removes common punctuation symbols, returning a new string.
# E.g.,
# "Hello (greeting's), with {braces} < >...?".without_punctuation
# => "Hello greetings with braces "
+ # @rbs () -> String
def without_punctuation
- tr( ',?.!;:"@#$%^&*()_=+[]{}\|<>/`~', " " ) .tr( "'\-", "")
+ tr(',?.!;:"@#$%^&*()_=+[]{}|<>/`~', ' ').tr("'-", '')
end
-
+
# Return a Hash of strings => ints. Each word in the string is stemmed,
- # interned, and indexes to its frequency in the document.
- def word_hash
- word_hash = clean_word_hash()
- symbol_hash = word_hash_for_symbols(gsub(/[\w]/," ").split)
- return word_hash.merge(symbol_hash)
- end
+ # interned, and indexes to its frequency in the document.
+ # @rbs (?Integer) -> Hash[Symbol, Integer]
+ def word_hash(min_word_length = 3)
+ word_hash = clean_word_hash(min_word_length)
+ symbol_hash = word_hash_for_symbols(gsub(/\w/, ' ').split)
+ word_hash.merge(symbol_hash)
+ end
+
+ # Return a word hash without extra punctuation or short symbols, just stemmed words
+ # @rbs (?Integer) -> Hash[Symbol, Integer]
+ def clean_word_hash(min_word_length = 3)
+ word_hash_for_words(gsub(/[^\w\s]/, '').split, min_word_length)
+ end
+
+ private
- # Return a word hash without extra punctuation or short symbols, just stemmed words
- def clean_word_hash
- word_hash_for_words gsub(/[^\w\s]/,"").split
- end
-
- private
-
- def word_hash_for_words(words)
- d = Hash.new(0)
- words.each do |word|
- word.downcase!
- if ! CORPUS_SKIP_WORDS.include?(word) && word.length > 2
- d[word.stem.intern] += 1
- end
- end
- return d
- end
+ # @rbs (Array[String], Integer) -> Hash[Symbol, Integer]
+ def word_hash_for_words(words, min_word_length)
+ d = Hash.new(0)
+ words.each do |word|
+ word.downcase!
+ d[word.stem.intern] += 1 if !CORPUS_SKIP_WORDS.include?(word) && word.length >= min_word_length
+ end
+ d
+ end
+ # @rbs (Array[String]) -> Hash[Symbol, Integer]
+ def word_hash_for_symbols(words)
+ d = Hash.new(0)
+ words.each do |word|
+ d[word.intern] += 1
+ end
+ d
+ end
- def word_hash_for_symbols(words)
- d = Hash.new(0)
- words.each do |word|
- d[word.intern] += 1
- end
- return d
- end
-
- CORPUS_SKIP_WORDS = Set.new([
- "a",
- "again",
- "all",
- "along",
- "are",
- "also",
- "an",
- "and",
- "as",
- "at",
- "but",
- "by",
- "came",
- "can",
- "cant",
- "couldnt",
- "did",
- "didn",
- "didnt",
- "do",
- "doesnt",
- "dont",
- "ever",
- "first",
- "from",
- "have",
- "her",
- "here",
- "him",
- "how",
- "i",
- "if",
- "in",
- "into",
- "is",
- "isnt",
- "it",
- "itll",
- "just",
- "last",
- "least",
- "like",
- "most",
- "my",
- "new",
- "no",
- "not",
- "now",
- "of",
- "on",
- "or",
- "should",
- "sinc",
- "so",
- "some",
- "th",
- "than",
- "this",
- "that",
- "the",
- "their",
- "then",
- "those",
- "to",
- "told",
- "too",
- "true",
- "try",
- "until",
- "url",
- "us",
- "were",
- "when",
- "whether",
- "while",
- "with",
- "within",
- "yes",
- "you",
- "youll",
- ])
+ CORPUS_SKIP_WORDS = ::Set.new(%w[
+ a
+ again
+ all
+ along
+ are
+ also
+ an
+ and
+ as
+ at
+ but
+ by
+ came
+ can
+ cant
+ couldnt
+ did
+ didn
+ didnt
+ do
+ doesnt
+ dont
+ ever
+ first
+ from
+ have
+ her
+ here
+ him
+ how
+ i
+ if
+ in
+ into
+ is
+ isnt
+ it
+ itll
+ just
+ last
+ least
+ like
+ most
+ my
+ new
+ no
+ not
+ now
+ of
+ on
+ or
+ should
+ sinc
+ so
+ some
+ th
+ than
+ this
+ that
+ the
+ their
+ then
+ those
+ to
+ told
+ too
+ true
+ try
+ until
+ url
+ us
+ were
+ when
+ whether
+ while
+ with
+ within
+ yes
+ you
+ youll
+ ])
end
diff --git a/lib/classifier/knn.rb b/lib/classifier/knn.rb
new file mode 100644
index 0000000..0c06dfa
--- /dev/null
+++ b/lib/classifier/knn.rb
@@ -0,0 +1,352 @@
+# rbs_inline: enabled
+
+# Author:: Lucas Carlson (mailto:lucas@rufy.com)
+# Copyright:: Copyright (c) 2024 Lucas Carlson
+# License:: LGPL
+
+require 'json'
+require 'mutex_m'
+require 'classifier/lsi'
+
+module Classifier
+ # Instance-based classification: stores examples and classifies by similarity.
+ #
+ # Example:
+ # knn = Classifier::KNN.new(k: 3)
+ # knn.add("spam" => ["Buy now!", "Limited offer!"])
+ # knn.add("ham" => ["Meeting tomorrow", "Project update"])
+ # knn.classify("Special discount!") # => "spam"
+ #
+ class KNN
+ include Mutex_m
+ include Streaming
+
+ # @rbs @k: Integer
+ # @rbs @weighted: bool
+ # @rbs @lsi: LSI
+ # @rbs @dirty: bool
+ # @rbs @storage: Storage::Base?
+
+ attr_reader :k
+ attr_accessor :weighted, :storage
+
+ # Creates a new kNN classifier.
+ # @rbs (?k: Integer, ?weighted: bool) -> void
+ def initialize(k: 5, weighted: false) # rubocop:disable Naming/MethodParameterName
+ super()
+ validate_k!(k)
+ @k = k
+ @weighted = weighted
+ @lsi = LSI.new(auto_rebuild: true)
+ @dirty = false
+ @storage = nil
+ end
+
+ # Adds labeled examples. Keys are categories, values are items or arrays.
+ # Also aliased as `train` for API consistency with Bayes and LogisticRegression.
+ #
+ # knn.add(spam: "Buy now!", ham: "Meeting tomorrow")
+ # knn.train(spam: "Buy now!", ham: "Meeting tomorrow") # equivalent
+ #
+ # @rbs (**untyped items) -> void
+ def add(**items)
+ synchronize { @dirty = true }
+ @lsi.add(**items)
+ end
+
+ alias train add
+
+ # Classifies text using k nearest neighbors with majority voting.
+ # Returns the category as a String for API consistency with Bayes and LogisticRegression.
+ # @rbs (String) -> String?
+ def classify(text)
+ result = classify_with_neighbors(text)
+ result[:category]&.to_s
+ end
+
+ # Classifies and returns {category:, neighbors:, votes:, confidence:}.
+ # @rbs (String) -> Hash[Symbol, untyped]
+ def classify_with_neighbors(text)
+ synchronize do
+ return empty_result if @lsi.items.empty?
+
+ neighbors = find_neighbors(text)
+ return empty_result if neighbors.empty?
+
+ votes = tally_votes(neighbors)
+ winner = votes.max_by { |_, v| v }&.first
+ return empty_result unless winner
+
+ total_votes = votes.values.sum
+ confidence = total_votes.positive? ? votes[winner] / total_votes.to_f : 0.0
+
+ { category: winner, neighbors: neighbors, votes: votes, confidence: confidence }
+ end
+ end
+
+ # @rbs (String) -> Array[String | Symbol]
+ def categories_for(item)
+ @lsi.categories_for(item)
+ end
+
+ # @rbs (String) -> void
+ def remove_item(item)
+ synchronize { @dirty = true }
+ @lsi.remove_item(item)
+ end
+
+ # @rbs () -> Array[untyped]
+ def items
+ @lsi.items
+ end
+
+ # Returns all unique categories as strings.
+ # @rbs () -> Array[String]
+ def categories
+ synchronize do
+ @lsi.items.flat_map { |item| @lsi.categories_for(item) }.uniq.map(&:to_s)
+ end
+ end
+
+ # @rbs (Integer) -> void
+ def k=(value)
+ validate_k!(value)
+ @k = value
+ end
+
+ # Provides dynamic training methods for categories.
+ # For example:
+ # knn.train_spam "Buy now!"
+ # knn.train_ham "Meeting tomorrow"
+ def method_missing(name, *args)
+ category_match = name.to_s.match(/\Atrain_(\w+)\z/)
+ return super unless category_match
+
+ category = category_match[1].to_sym
+ args.each { |text| add(category => text) }
+ end
+
+ # @rbs (Symbol, ?bool) -> bool
+ def respond_to_missing?(name, include_private = false)
+ !!(name.to_s =~ /\Atrain_(\w+)\z/) || super
+ end
+
+ # @rbs (?untyped) -> untyped
+ def as_json(_options = nil)
+ {
+ version: 1,
+ type: 'knn',
+ k: @k,
+ weighted: @weighted,
+ lsi: @lsi.as_json
+ }
+ end
+
+ # @rbs (?untyped) -> String
+ def to_json(_options = nil)
+ as_json.to_json
+ end
+
+ # Loads a classifier from a JSON string or Hash.
+ # @rbs (String | Hash[String, untyped]) -> KNN
+ def self.from_json(json)
+ data = json.is_a?(String) ? JSON.parse(json) : json
+ raise ArgumentError, "Invalid classifier type: #{data['type']}" unless data['type'] == 'knn'
+
+ lsi_data = data['lsi'].dup
+ lsi_data['type'] = 'lsi'
+
+ instance = new(k: data['k'], weighted: data['weighted'])
+ instance.instance_variable_set(:@lsi, LSI.from_json(lsi_data))
+ instance.instance_variable_set(:@dirty, false)
+ instance
+ end
+
+ # Saves the classifier to the configured storage.
+ # @rbs () -> void
+ def save
+ raise ArgumentError, 'No storage configured. Use save_to_file(path) or set storage=' unless storage
+
+ storage.write(to_json)
+ @dirty = false
+ end
+
+ # Saves the classifier to a file.
+ # @rbs (String) -> Integer
+ def save_to_file(path)
+ result = File.write(path, to_json)
+ @dirty = false
+ result
+ end
+
+ # Reloads the classifier from configured storage.
+ # @rbs () -> self
+ def reload
+ raise ArgumentError, 'No storage configured' unless storage
+ raise UnsavedChangesError, 'Unsaved changes would be lost. Call save first or use reload!' if @dirty
+
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ restore_from_json(data)
+ @dirty = false
+ self
+ end
+
+ # Force reloads, discarding unsaved changes.
+ # @rbs () -> self
+ def reload!
+ raise ArgumentError, 'No storage configured' unless storage
+
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ restore_from_json(data)
+ @dirty = false
+ self
+ end
+
+ # @rbs () -> bool
+ def dirty?
+ @dirty
+ end
+
+ # Loads a classifier from configured storage.
+ # @rbs (storage: Storage::Base) -> KNN
+ def self.load(storage:)
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ instance = from_json(data)
+ instance.storage = storage
+ instance
+ end
+
+ # Loads a classifier from a file.
+ # @rbs (String) -> KNN
+ def self.load_from_file(path)
+ from_json(File.read(path))
+ end
+
+ # @rbs () -> Array[untyped]
+ def marshal_dump
+ [@k, @weighted, @lsi, @dirty]
+ end
+
+ # @rbs (Array[untyped]) -> void
+ def marshal_load(data)
+ mu_initialize
+ @k, @weighted, @lsi, @dirty = data
+ @storage = nil
+ end
+
+ # Loads a classifier from a checkpoint.
+ #
+ # @rbs (storage: Storage::Base, checkpoint_id: String) -> KNN
+ def self.load_checkpoint(storage:, checkpoint_id:)
+ raise ArgumentError, 'Storage must be File storage for checkpoints' unless storage.is_a?(Storage::File)
+
+ dir = File.dirname(storage.path)
+ base = File.basename(storage.path, '.*')
+ ext = File.extname(storage.path)
+ checkpoint_path = File.join(dir, "#{base}_checkpoint_#{checkpoint_id}#{ext}")
+
+ checkpoint_storage = Storage::File.new(path: checkpoint_path)
+ instance = load(storage: checkpoint_storage)
+ instance.storage = storage
+ instance
+ end
+
+ # Trains the classifier from an IO stream.
+ # Each line in the stream is treated as a separate document.
+ #
+ # @example Train from a file
+ # knn.train_from_stream(:spam, File.open('spam_corpus.txt'))
+ #
+ # @example With progress tracking
+ # knn.train_from_stream(:spam, io, batch_size: 500) do |progress|
+ # puts "#{progress.completed} documents processed"
+ # end
+ #
+ # @rbs (?(String | Symbol | nil), ?IO?, ?batch_size: Integer, **IO) { (Streaming::Progress) -> void } -> void
+ def train_from_stream(category = nil, io = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &)
+ # @type var categories: untype
+ @lsi.train_from_stream(category, io, batch_size: batch_size, **categories, &)
+ synchronize { @dirty = true }
+ end
+
+ # Adds items in batches.
+ #
+ # @example Positional style
+ # knn.train_batch(:spam, documents, batch_size: 100)
+ #
+ # @example Keyword style
+ # knn.train_batch(spam: documents, ham: other_docs)
+ #
+ # @rbs (?(String | Symbol)?, ?Array[String]?, ?batch_size: Integer, **Array[String]) { (Streaming::Progress) -> void } -> void
+ def train_batch(category = nil, documents = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &block)
+ # @type var categories: Hash[Symbol, Array[String]]
+ @lsi.train_batch(category, documents, batch_size: batch_size, **categories, &block) # steep:ignore
+ synchronize { @dirty = true }
+ end
+
+ # @rbs!
+ # alias add_batch train_batch
+ alias add_batch train_batch
+
+ private
+
+ # @rbs (String) -> Array[Hash[Symbol, untyped]]
+ def find_neighbors(text)
+ proximity = @lsi.proximity_array_for_content(text)
+ neighbors = proximity.reject { |item, _| item == text }.first(@k)
+
+ neighbors.map do |item, similarity|
+ {
+ item: item,
+ category: @lsi.categories_for(item).first,
+ similarity: similarity
+ }
+ end
+ end
+
+ # @rbs (Array[Hash[Symbol, untyped]]) -> Hash[String | Symbol, Float]
+ def tally_votes(neighbors)
+ votes = Hash.new(0.0)
+
+ neighbors.each do |neighbor|
+ category = neighbor[:category] or next
+ weight = @weighted ? neighbor[:similarity] : 1.0
+ votes[category] += weight
+ end
+
+ votes
+ end
+
+ # @rbs () -> Hash[Symbol, untyped]
+ def empty_result
+ { category: nil, neighbors: [], votes: {}, confidence: 0.0 }
+ end
+
+ # @rbs (Integer) -> void
+ def validate_k!(val)
+ raise ArgumentError, "k must be a positive integer, got #{val}" unless val.is_a?(Integer) && val.positive?
+ end
+
+ # @rbs (String) -> void
+ def restore_from_json(json)
+ data = JSON.parse(json)
+ raise ArgumentError, "Invalid classifier type: #{data['type']}" unless data['type'] == 'knn'
+
+ synchronize do
+ @k = data['k']
+ @weighted = data['weighted']
+
+ lsi_data = data['lsi'].dup
+ lsi_data['type'] = 'lsi'
+ @lsi = LSI.from_json(lsi_data)
+ @dirty = false
+ end
+ end
+ end
+end
diff --git a/lib/classifier/logistic_regression.rb b/lib/classifier/logistic_regression.rb
new file mode 100644
index 0000000..0d1819e
--- /dev/null
+++ b/lib/classifier/logistic_regression.rb
@@ -0,0 +1,614 @@
+# rbs_inline: enabled
+
+# Author:: Lucas Carlson (mailto:lucas@rufy.com)
+# Copyright:: Copyright (c) 2024 Lucas Carlson
+# License:: LGPL
+
+require 'json'
+require 'mutex_m'
+
+module Classifier
+ # Logistic Regression (MaxEnt) classifier using Stochastic Gradient Descent.
+ # Often provides better accuracy than Naive Bayes while remaining fast and interpretable.
+ #
+ # Example:
+ # classifier = Classifier::LogisticRegression.new(:spam, :ham)
+ # classifier.train(spam: ["Buy now!", "Free money!!!"])
+ # classifier.train(ham: ["Meeting tomorrow", "Project update"])
+ # classifier.classify("Claim your prize!") # => "Spam"
+ # classifier.probabilities("Claim your prize!") # => {"Spam" => 0.92, "Ham" => 0.08}
+ #
+ class LogisticRegression # rubocop:disable Metrics/ClassLength
+ include Mutex_m
+ include Streaming
+
+ # @rbs @categories: Array[Symbol]
+ # @rbs @weights: Hash[Symbol, Hash[Symbol, Float]]
+ # @rbs @bias: Hash[Symbol, Float]
+ # @rbs @vocabulary: Hash[Symbol, bool]
+ # @rbs @training_data: Array[{category: Symbol, features: Hash[Symbol, Integer]}]
+ # @rbs @learning_rate: Float
+ # @rbs @regularization: Float
+ # @rbs @max_iterations: Integer
+ # @rbs @tolerance: Float
+ # @rbs @fitted: bool
+ # @rbs @dirty: bool
+ # @rbs @storage: Storage::Base?
+ # @rbs @min_word_length: Integer
+
+ attr_accessor :storage
+
+ DEFAULT_LEARNING_RATE = 0.1
+ DEFAULT_REGULARIZATION = 0.01
+ DEFAULT_MAX_ITERATIONS = 100
+ DEFAULT_TOLERANCE = 1e-4
+
+ # Creates a new Logistic Regression classifier with the specified categories.
+ #
+ # classifier = Classifier::LogisticRegression.new(:spam, :ham)
+ # classifier = Classifier::LogisticRegression.new('Positive', 'Negative', 'Neutral')
+ # classifier = Classifier::LogisticRegression.new(['Positive', 'Negative', 'Neutral'])
+ #
+ # Options:
+ # - learning_rate: Step size for gradient descent (default: 0.1)
+ # - regularization: L2 regularization strength (default: 0.01)
+ # - max_iterations: Maximum training iterations (default: 100)
+ # - tolerance: Convergence threshold (default: 1e-4)
+ # - min_word_length: Minimum word length filter in tokenization
+ #
+ # @rbs (*String | Symbol | Array[String | Symbol], ?learning_rate: Float, ?regularization: Float,
+ # ?max_iterations: Integer, ?tolerance: Float, ?min_word_length: Integer) -> void
+ # rubocop:disable Metrics/ParameterLists
+ def initialize(*categories, learning_rate: DEFAULT_LEARNING_RATE,
+ regularization: DEFAULT_REGULARIZATION,
+ max_iterations: DEFAULT_MAX_ITERATIONS,
+ tolerance: DEFAULT_TOLERANCE,
+ min_word_length: Classifier.config.min_word_length)
+ super()
+ categories = categories.flatten
+ @categories = categories.map { |c| c.to_s.prepare_category_name }
+ @weights = @categories.to_h { |c| [c, {}] }
+ @bias = @categories.to_h { |c| [c, 0.0] }
+ @vocabulary = {}
+ @training_data = []
+ @learning_rate = learning_rate
+ @regularization = regularization
+ @max_iterations = max_iterations
+ @tolerance = tolerance
+ @fitted = false
+ @dirty = false
+ @storage = nil
+ @min_word_length = min_word_length
+ end
+ # rubocop:enable Metrics/ParameterLists
+
+ # Trains the classifier with text for a category.
+ #
+ # classifier.train(spam: "Buy now!", ham: ["Hello", "Meeting tomorrow"])
+ # classifier.train(:spam, "legacy positional API")
+ #
+ # @rbs (?(String | Symbol)?, ?String?, **(String | Array[String])) -> void
+ def train(category = nil, text = nil, **categories)
+ return train_single(category, text) if category && text
+
+ categories.each do |cat, texts|
+ (texts.is_a?(Array) ? texts : [texts]).each { |t| train_single(cat, t) }
+ end
+ end
+
+ # Fits the model to all accumulated training data.
+ # Called automatically during classify/probabilities if not already fitted.
+ #
+ # @rbs () -> self
+ def fit
+ synchronize do
+ return self if @training_data.empty?
+ raise ArgumentError, 'At least two categories required for fitting' if @categories.size < 2
+
+ optimize_weights
+ @fitted = true
+ @dirty = false
+ end
+ self
+ end
+
+ # Returns the best matching category for the provided text.
+ #
+ # classifier.classify("Buy now!") # => "Spam"
+ #
+ # @rbs (String) -> String
+ def classify(text)
+ probs = probabilities(text)
+ best = probs.max_by { |_, v| v }
+ raise StandardError, 'No classifications available' unless best
+
+ best.first
+ end
+
+ # Returns probability distribution across all categories.
+ # Probabilities are well-calibrated (unlike Naive Bayes).
+ # Raises NotFittedError if model has not been fitted.
+ #
+ # classifier.probabilities("Buy now!")
+ # # => {"Spam" => 0.92, "Ham" => 0.08}
+ #
+ # @rbs (String) -> Hash[String, Float]
+ def probabilities(text)
+ raise NotFittedError, 'Model not fitted. Call fit() after training.' unless @fitted
+
+ features = text.word_hash(@min_word_length)
+ synchronize do
+ softmax(compute_scores(features))
+ end
+ end
+
+ # Returns log-odds scores for each category (before softmax).
+ # Raises NotFittedError if model has not been fitted.
+ #
+ # @rbs (String) -> Hash[String, Float]
+ def classifications(text)
+ raise NotFittedError, 'Model not fitted. Call fit() after training.' unless @fitted
+
+ features = text.word_hash(@min_word_length)
+ synchronize do
+ compute_scores(features).transform_keys(&:to_s)
+ end
+ end
+
+ # Returns feature weights for a category, sorted by importance.
+ # Positive weights indicate the feature supports the category.
+ #
+ # classifier.weights(:spam)
+ # # => {:free => 2.3, :buy => 1.8, :money => 1.5, ...}
+ #
+ # @rbs (String | Symbol, ?limit: Integer?) -> Hash[Symbol, Float]
+ def weights(category, limit: nil)
+ fit unless @fitted
+
+ cat = category.to_s.prepare_category_name
+ raise StandardError, "No such category: #{cat}" unless @weights.key?(cat)
+
+ sorted = @weights[cat].sort_by { |_, v| -v.abs }
+ sorted = sorted.first(limit) if limit
+ sorted.to_h
+ end
+
+ # Returns the list of categories.
+ #
+ # @rbs () -> Array[String]
+ def categories
+ synchronize { @categories.map(&:to_s) }
+ end
+
+ # Adds a new category to the classifier.
+ # Allows dynamic category creation for CLI and incremental training.
+ #
+ # @rbs (String | Symbol) -> void
+ def add_category(category)
+ cat = category.to_s.prepare_category_name
+ synchronize do
+ return if @categories.include?(cat)
+
+ @categories << cat
+ @weights[cat] = {}
+ @bias[cat] = 0.0
+ @fitted = false
+ @dirty = true
+ end
+ end
+
+ # Returns true if the model has been fitted.
+ #
+ # @rbs () -> bool
+ def fitted?
+ @fitted
+ end
+
+ # Returns true if there are unsaved changes.
+ #
+ # @rbs () -> bool
+ def dirty?
+ @dirty
+ end
+
+ # Provides training methods for the categories.
+ # classifier.train_spam "Buy now!"
+ def method_missing(name, *args)
+ category_match = name.to_s.match(/train_(\w+)/)
+ return super unless category_match
+
+ category = category_match[1].to_s.prepare_category_name
+ raise StandardError, "No such category: #{category}" unless @categories.include?(category)
+
+ args.each { |text| train(category, text) }
+ end
+
+ # @rbs (Symbol, ?bool) -> bool
+ def respond_to_missing?(name, include_private = false)
+ !!(name.to_s =~ /train_(\w+)/) || super
+ end
+
+ # Returns a hash representation of the classifier state.
+ # Does NOT auto-fit; saves current state including unfitted models.
+ #
+ # @rbs (?untyped) -> Hash[Symbol, untyped]
+ def as_json(_options = nil)
+ {
+ version: 1,
+ type: 'logistic_regression',
+ categories: @categories.map(&:to_s),
+ weights: @weights.transform_keys(&:to_s).transform_values { |v| v.transform_keys(&:to_s) },
+ bias: @bias.transform_keys(&:to_s),
+ vocabulary: @vocabulary.keys.map(&:to_s),
+ training_data: @training_data.map { |d| { category: d[:category].to_s, features: d[:features].transform_keys(&:to_s) } },
+ learning_rate: @learning_rate,
+ regularization: @regularization,
+ max_iterations: @max_iterations,
+ tolerance: @tolerance,
+ fitted: @fitted,
+ min_word_length: @min_word_length
+ }
+ end
+
+ # Serializes the classifier state to a JSON string.
+ #
+ # @rbs (?untyped) -> String
+ def to_json(_options = nil)
+ JSON.generate(as_json)
+ end
+
+ # Loads a classifier from a JSON string or Hash.
+ #
+ # @rbs (String | Hash[String, untyped]) -> LogisticRegression
+ def self.from_json(json)
+ data = json.is_a?(String) ? JSON.parse(json) : json
+ raise ArgumentError, "Invalid classifier type: #{data['type']}" unless data['type'] == 'logistic_regression'
+
+ categories = data['categories'].map(&:to_sym)
+ instance = allocate
+ instance.send(:restore_state, data, categories)
+ instance
+ end
+
+ # Saves the classifier to the configured storage.
+ #
+ # @rbs () -> void
+ def save
+ raise ArgumentError, 'No storage configured' unless storage
+
+ storage.write(to_json)
+ @dirty = false
+ end
+
+ # Saves the classifier state to a file.
+ #
+ # @rbs (String) -> Integer
+ def save_to_file(path)
+ result = File.write(path, to_json)
+ @dirty = false
+ result
+ end
+
+ # Loads a classifier from the configured storage.
+ #
+ # @rbs (storage: Storage::Base) -> LogisticRegression
+ def self.load(storage:)
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ instance = from_json(data)
+ instance.storage = storage
+ instance
+ end
+
+ # Loads a classifier from a file.
+ #
+ # @rbs (String) -> LogisticRegression
+ def self.load_from_file(path)
+ from_json(File.read(path))
+ end
+
+ # Reloads the classifier from storage, raising if there are unsaved changes.
+ #
+ # @rbs () -> self
+ def reload
+ raise ArgumentError, 'No storage configured' unless storage
+ raise UnsavedChangesError, 'Unsaved changes would be lost. Call save first or use reload!' if @dirty
+
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ restore_from_json(data)
+ @dirty = false
+ self
+ end
+
+ # Force reloads the classifier from storage, discarding any unsaved changes.
+ #
+ # @rbs () -> self
+ def reload!
+ raise ArgumentError, 'No storage configured' unless storage
+
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ restore_from_json(data)
+ @dirty = false
+ self
+ end
+
+ # Custom marshal serialization to exclude mutex state.
+ #
+ # @rbs () -> Array[untyped]
+ def marshal_dump
+ fit unless @fitted
+ [@categories, @weights, @bias, @vocabulary, @learning_rate, @regularization,
+ @max_iterations, @tolerance, @fitted, @min_word_length]
+ end
+
+ # Custom marshal deserialization to recreate mutex.
+ #
+ # @rbs (Array[untyped]) -> void
+ def marshal_load(data)
+ mu_initialize
+ @categories, @weights, @bias, @vocabulary, @learning_rate, @regularization,
+ @max_iterations, @tolerance, @fitted, @min_word_length = data
+ @training_data = []
+ @dirty = false
+ @storage = nil
+ end
+
+ # Loads a classifier from a checkpoint.
+ #
+ # @rbs (storage: Storage::Base, checkpoint_id: String) -> LogisticRegression
+ def self.load_checkpoint(storage:, checkpoint_id:)
+ raise ArgumentError, 'Storage must be File storage for checkpoints' unless storage.is_a?(Storage::File)
+
+ dir = File.dirname(storage.path)
+ base = File.basename(storage.path, '.*')
+ ext = File.extname(storage.path)
+ checkpoint_path = File.join(dir, "#{base}_checkpoint_#{checkpoint_id}#{ext}")
+
+ checkpoint_storage = Storage::File.new(path: checkpoint_path)
+ instance = load(storage: checkpoint_storage)
+ instance.storage = storage
+ instance
+ end
+
+ # Trains the classifier from an IO stream.
+ # Each line in the stream is treated as a separate document.
+ # Note: The model is NOT automatically fitted after streaming.
+ # Call #fit to train the model after adding all data.
+ #
+ # @example Train from a file
+ # classifier.train_from_stream(:spam, File.open('spam_corpus.txt'))
+ # classifier.fit # Required to train the model
+ #
+ # @example With progress tracking
+ # classifier.train_from_stream(:spam, io, batch_size: 500) do |progress|
+ # puts "#{progress.completed} documents processed"
+ # end
+ # classifier.fit
+ #
+ # @rbs (?(String | Symbol | nil), ?IO?, ?batch_size: Integer, **IO) { (Streaming::Progress) -> void } -> void
+ def train_from_stream(category = nil, io = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &)
+ raise ArgumentError, 'Provide either (category, io) or keyword category: io pairs' if category.nil? && io.nil? && categories.empty?
+ raise ArgumentError, 'Provide both category and io, or use keyword arguments' if [category, io].one?(&:nil?)
+
+ pairs = category && io ? { category => io } : categories
+ pairs.each do |cat, stream|
+ stream_train_category(cat, stream, batch_size:, &)
+ end
+ end
+
+ # Trains the classifier with an array of documents in batches.
+ # Note: The model is NOT automatically fitted after batch training.
+ # Call #fit to train the model after adding all data.
+ #
+ # @example Positional style
+ # classifier.train_batch(:spam, documents, batch_size: 100)
+ # classifier.fit
+ #
+ # @example Keyword style
+ # classifier.train_batch(spam: documents, ham: other_docs)
+ # classifier.fit
+ #
+ # @rbs (?(String | Symbol)?, ?Array[String]?, ?batch_size: Integer, **Array[String]) { (Streaming::Progress) -> void } -> void
+ def train_batch(category = nil, documents = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &block)
+ if category && documents
+ train_batch_for_category(category, documents, batch_size: batch_size, &block)
+ else
+ categories.each do |cat, docs|
+ train_batch_for_category(cat, Array(docs), batch_size: batch_size, &block)
+ end
+ end
+ end
+
+ private
+
+ # Trains from an IO stream with a single category.
+ # @rbs (String | Symbol, IO, batch_size: Integer) { (Streaming::Progress) -> void } -> void
+ def stream_train_category(category, io, batch_size:)
+ category = category.to_s.prepare_category_name
+ raise ArgumentError, "No such category: #{category}" unless @categories.include?(category)
+ raise ArgumentError, 'Stream must respond to #each_line' unless io.respond_to?(:each_line)
+
+ reader = Streaming::LineReader.new(io, batch_size: batch_size)
+ total = reader.estimate_line_count
+ progress = Streaming::Progress.new(total: total)
+
+ reader.each_batch do |batch|
+ synchronize do
+ batch.each do |text|
+ features = text.word_hash(@min_word_length)
+ features.each_key { |word| @vocabulary[word] = true }
+ @training_data << { category: category, features: features }
+ end
+ @fitted = false
+ @dirty = true
+ end
+ progress.completed += batch.size
+ progress.current_batch += 1
+ yield progress if block_given?
+ end
+ end
+
+ # Trains a batch of documents for a single category.
+ # @rbs (String | Symbol, Array[String], ?batch_size: Integer) { (Streaming::Progress) -> void } -> void
+ def train_batch_for_category(category, documents, batch_size: Streaming::DEFAULT_BATCH_SIZE)
+ category = category.to_s.prepare_category_name
+ raise StandardError, "No such category: #{category}" unless @categories.include?(category)
+
+ progress = Streaming::Progress.new(total: documents.size)
+
+ documents.each_slice(batch_size) do |batch|
+ synchronize do
+ batch.each do |text|
+ features = text.word_hash(@min_word_length)
+ features.each_key { |word| @vocabulary[word] = true }
+ @training_data << { category: category, features: features }
+ end
+ @fitted = false
+ @dirty = true
+ end
+ progress.completed += batch.size
+ progress.current_batch += 1
+ yield progress if block_given?
+ end
+ end
+
+ # Core training logic for a single category and text.
+ # @rbs (String | Symbol, String) -> void
+ def train_single(category, text)
+ category = category.to_s.prepare_category_name
+ raise StandardError, "No such category: #{category}" unless @categories.include?(category)
+
+ features = text.word_hash(@min_word_length)
+ synchronize do
+ features.each_key { |word| @vocabulary[word] = true }
+ @training_data << { category: category, features: features }
+ @fitted = false
+ @dirty = true
+ end
+ end
+
+ # Optimizes weights using mini-batch SGD with L2 regularization.
+ # @rbs () -> void
+ def optimize_weights
+ return if @training_data.empty?
+
+ initialize_weights
+ prev_loss = Float::INFINITY
+
+ @max_iterations.times do
+ total_loss = run_training_epoch
+ break if (prev_loss - total_loss).abs < @tolerance
+
+ prev_loss = total_loss
+ end
+
+ @training_data = []
+ end
+
+ # @rbs () -> void
+ def initialize_weights
+ @vocabulary.each_key do |word|
+ @categories.each { |cat| @weights[cat][word] ||= 0.0 }
+ end
+ end
+
+ # @rbs () -> Float
+ def run_training_epoch
+ total_loss = 0.0
+
+ @training_data.shuffle.each do |sample|
+ probs = softmax(compute_scores(sample[:features]))
+ update_weights(sample[:features], sample[:category], probs)
+ total_loss -= Math.log([probs[sample[:category].to_s], 1e-15].max)
+ end
+
+ total_loss + l2_penalty
+ end
+
+ # @rbs (Hash[Symbol, Integer], Symbol, Hash[String, Float]) -> void
+ def update_weights(features, true_category, probs)
+ @categories.each do |cat|
+ error = probs[cat.to_s] - (cat == true_category ? 1.0 : 0.0)
+ @bias[cat] -= @learning_rate * error
+
+ features.each do |word, count|
+ gradient = (error * count) + (@regularization * (@weights[cat][word] || 0.0))
+ @weights[cat][word] ||= 0.0
+ @weights[cat][word] -= @learning_rate * gradient
+ end
+ end
+ end
+
+ # @rbs () -> Float
+ def l2_penalty
+ penalty = 0.0
+ @weights.each_value do |cat_weights|
+ cat_weights.each_value { |w| penalty += 0.5 * @regularization * w * w }
+ end
+ penalty
+ end
+
+ # Computes raw scores (logits) for each category.
+ # @rbs (Hash[Symbol, Integer]) -> Hash[Symbol, Float]
+ def compute_scores(features)
+ @categories.to_h do |cat|
+ score = @bias[cat]
+ features.each { |word, count| score += (@weights[cat][word] || 0.0) * count }
+ [cat, score]
+ end
+ end
+
+ # Applies softmax to convert scores to probabilities.
+ # @rbs (Hash[Symbol, Float]) -> Hash[String, Float]
+ def softmax(scores)
+ max_score = scores.values.max || 0.0
+ exp_scores = scores.transform_values { |s| Math.exp(s - max_score) }
+ sum = exp_scores.values.sum.to_f
+ exp_scores.transform_keys(&:to_s).transform_values { |e| (e / sum).to_f }
+ end
+
+ # Restores classifier state from JSON string.
+ # @rbs (String) -> void
+ def restore_from_json(json)
+ data = JSON.parse(json)
+ categories = data['categories'].map(&:to_sym)
+ restore_state(data, categories)
+ end
+
+ # Restores classifier state from parsed JSON data.
+ # @rbs (Hash[String, untyped], Array[Symbol]) -> void
+ def restore_state(data, categories)
+ mu_initialize
+ @categories = categories
+ restore_weights_and_bias(data)
+ restore_hyperparameters(data)
+ @fitted = data.fetch('fitted', true)
+ @dirty = false
+ @storage = nil
+ @min_word_length = data['min_word_length'] || Classifier.config.min_word_length
+ end
+
+ def restore_weights_and_bias(data)
+ @weights = {}
+ @bias = {}
+ data['weights'].each { |cat, words| @weights[cat.to_sym] = words.transform_keys(&:to_sym).transform_values(&:to_f) }
+ data['bias'].each { |cat, value| @bias[cat.to_sym] = value.to_f }
+ @vocabulary = data['vocabulary'].to_h { |v| [v.to_sym, true] }
+ @training_data = (data['training_data'] || []).map do |d|
+ { category: d['category'].to_sym, features: d['features'].transform_keys(&:to_sym).transform_values(&:to_i) }
+ end
+ end
+
+ def restore_hyperparameters(data)
+ @learning_rate = data['learning_rate']
+ @regularization = data['regularization']
+ @max_iterations = data['max_iterations']
+ @tolerance = data['tolerance']
+ end
+ end
+end
diff --git a/lib/classifier/lsi.rb b/lib/classifier/lsi.rb
index 3277e9b..3476cd9 100644
--- a/lib/classifier/lsi.rb
+++ b/lib/classifier/lsi.rb
@@ -1,56 +1,214 @@
+# rbs_inline: enabled
+
# Author:: David Fayram (mailto:dfayram@lensmen.net)
# Copyright:: Copyright (c) 2005 David Fayram II
# License:: LGPL
+module Classifier
+ class LSI
+ # Backend options: :native, :ruby
+ # @rbs @backend: Symbol
+ @backend = :ruby
+
+ class << self
+ # @rbs @backend: Symbol
+ attr_accessor :backend
+
+ # Check if using native C extension
+ # @rbs () -> bool
+ def native_available?
+ backend == :native
+ end
+
+ # Get the Vector class for the current backend
+ # @rbs () -> Class
+ def vector_class
+ backend == :native ? Classifier::Linalg::Vector : ::Vector
+ end
+
+ # Get the Matrix class for the current backend
+ # @rbs () -> Class
+ def matrix_class
+ backend == :native ? Classifier::Linalg::Matrix : ::Matrix
+ end
+ end
+ end
+end
+
+# Backend detection: native extension > pure Ruby
+# Set NATIVE_VECTOR=true to force pure Ruby implementation
+
begin
- raise LoadError if ENV['NATIVE_VECTOR'] == "true" # to test the native vector class, try `rake test NATIVE_VECTOR=true`
-
- require 'gsl' # requires http://rb-gsl.rubyforge.org/
- require 'classifier/extensions/vector_serialize'
- $GSL = true
-
+ raise LoadError if ENV['NATIVE_VECTOR'] == 'true'
+
+ require 'classifier/classifier_ext'
+ Classifier::LSI.backend = :native
rescue LoadError
- warn "Notice: for 10x faster LSI support, please install http://rb-gsl.rubyforge.org/"
- require 'classifier/extensions/vector'
+ # Fall back to pure Ruby implementation
+ unless ENV['SUPPRESS_LSI_WARNING'] == 'true'
+ warn 'Notice: for 5-10x faster LSI, install the classifier gem with native extensions. ' \
+ 'Set SUPPRESS_LSI_WARNING=true to hide this.'
+ end
+ Classifier::LSI.backend = :ruby
+ require 'classifier/extensions/vector'
end
-
+
+require 'json'
+require 'mutex_m'
require 'classifier/lsi/word_list'
require 'classifier/lsi/content_node'
require 'classifier/lsi/summary'
+require 'classifier/lsi/incremental_svd'
module Classifier
-
# This class implements a Latent Semantic Indexer, which can search, classify and cluster
# data based on underlying semantic relations. For more information on the algorithms used,
# please consult Wikipedia[http://en.wikipedia.org/wiki/Latent_Semantic_Indexing].
class LSI
-
- attr_reader :word_list
- attr_accessor :auto_rebuild
-
+ include Mutex_m
+ include Streaming
+
+ # @rbs @auto_rebuild: bool
+ # @rbs @word_list: WordList
+ # @rbs @items: Hash[untyped, ContentNode]
+ # @rbs @version: Integer
+ # @rbs @built_at_version: Integer
+ # @rbs @singular_values: Array[Float]?
+ # @rbs @dirty: bool
+ # @rbs @storage: Storage::Base?
+ # @rbs @incremental_mode: bool
+ # @rbs @u_matrix: Matrix?
+ # @rbs @max_rank: Integer
+ # @rbs @initial_vocab_size: Integer?
+ # @rbs @min_word_length: Integer
+
+ attr_reader :word_list, :singular_values
+ attr_accessor :auto_rebuild, :storage
+
+ # Default maximum rank for incremental SVD
+ DEFAULT_MAX_RANK = 100
+
# Create a fresh index.
# If you want to call #build_index manually, use
- # Classifier::LSI.new :auto_rebuild => false
+ # Classifier::LSI.new auto_rebuild: false
+ #
+ # For incremental SVD mode (adds documents without full rebuild):
+ # Classifier::LSI.new incremental: true, max_rank: 100
#
+ # @rbs (?Hash[Symbol, untyped]) -> void
def initialize(options = {})
+ super()
@auto_rebuild = true unless options[:auto_rebuild] == false
- @word_list, @items = WordList.new, {}
- @version, @built_at_version = 0, -1
+ @word_list = WordList.new
+ @items = {}
+ @version = 0
+ @built_at_version = -1
+ @dirty = false
+ @storage = nil
+
+ # Incremental SVD settings
+ @incremental_mode = options[:incremental] == true
+ @max_rank = options[:max_rank] || DEFAULT_MAX_RANK
+ @u_matrix = nil
+ @initial_vocab_size = nil
+ @min_word_length = options[:min_word_length] || Classifier.config.min_word_length
end
-
+
# Returns true if the index needs to be rebuilt. The index needs
# to be built after all informaton is added, but before you start
# using it for search, classification and cluster detection.
+ #
+ # @rbs () -> bool
def needs_rebuild?
- (@items.keys.size > 1) && (@version != @built_at_version)
+ synchronize { (@items.keys.size > 1) && (@version != @built_at_version) }
+ end
+
+ # @rbs () -> Array[Hash[Symbol, untyped]]?
+ def singular_value_spectrum
+ return nil unless @singular_values
+
+ total = @singular_values.sum
+ return nil if total.zero?
+
+ cumulative = 0.0
+ @singular_values.map.with_index do |value, i|
+ cumulative += value
+ {
+ dimension: i,
+ value: value,
+ percentage: value / total,
+ cumulative_percentage: cumulative / total
+ }
+ end
+ end
+
+ # Returns true if incremental mode is enabled and active.
+ # Incremental mode becomes active after the first build_index call.
+ #
+ # @rbs () -> bool
+ def incremental_enabled?
+ @incremental_mode && !@u_matrix.nil?
+ end
+
+ # Returns the current rank of the incremental SVD (number of singular values kept).
+ # Returns nil if incremental mode is not active.
+ #
+ # @rbs () -> Integer?
+ def current_rank
+ @singular_values&.count(&:positive?)
end
-
- # Adds an item to the index. item is assumed to be a string, but
+
+ # Disables incremental mode. Subsequent adds will trigger full rebuilds.
+ #
+ # @rbs () -> void
+ def disable_incremental_mode!
+ @incremental_mode = false
+ @u_matrix = nil
+ @initial_vocab_size = nil
+ end
+
+ # Enables incremental mode with optional max_rank setting.
+ # The next build_index call will store the U matrix for incremental updates.
+ #
+ # @rbs (?max_rank: Integer) -> void
+ def enable_incremental_mode!(max_rank: DEFAULT_MAX_RANK)
+ @incremental_mode = true
+ @max_rank = max_rank
+ end
+
+ # Adds items to the index using hash-style syntax.
+ # The hash keys are categories, and values are items (or arrays of items).
+ #
+ # For example:
+ # lsi = Classifier::LSI.new
+ # lsi.add("Dog" => "Dogs are loyal pets")
+ # lsi.add("Cat" => "Cats are independent")
+ # lsi.add(Bird: "Birds can fly") # Symbol keys work too
+ #
+ # Multiple items with the same category:
+ # lsi.add("Dog" => ["Dogs are loyal", "Puppies are cute"])
+ #
+ # Batch operations with multiple categories:
+ # lsi.add(
+ # "Dog" => ["Dogs are loyal", "Puppies are cute"],
+ # "Cat" => ["Cats are independent", "Kittens are playful"]
+ # )
+ #
+ # @rbs (**untyped items) -> void
+ def add(**items)
+ items.each do |category, value|
+ Array(value).each { |doc| add_item(doc, category.to_s) }
+ end
+ end
+
+ # Adds an item to the index. item is assumed to be a string, but
# any item may be indexed so long as it responds to #to_s or if
- # you provide an optional block explaining how the indexer can
+ # you provide an optional block explaining how the indexer can
# fetch fresh string data. This optional block is passed the item,
# so the item may only be a reference to a URL or file name.
- #
+ #
+ # @deprecated Use {#add} instead for clearer hash-style syntax.
+ #
# For example:
# lsi = Classifier::LSI.new
# lsi.add_item "This is just plain text"
@@ -58,254 +216,724 @@ module Classifier
# ar = ActiveRecordObject.find( :all )
# lsi.add_item ar, *ar.categories { |x| ar.content }
#
- def add_item( item, *categories, &block )
- clean_word_hash = block ? block.call(item).clean_word_hash : item.to_s.clean_word_hash
- @items[item] = ContentNode.new(clean_word_hash, *categories)
- @version += 1
+ # @rbs (String, *String | Symbol) ?{ (String) -> String } -> void
+ def add_item(item, *categories, &block)
+ clean_word_hash =
+ if block
+ block.call(item).clean_word_hash(@min_word_length)
+ else
+ item.to_s.clean_word_hash(@min_word_length)
+ end
+
+ node = nil
+
+ synchronize do
+ node = ContentNode.new(clean_word_hash, *categories)
+ @items[item] = node
+ @version += 1
+ @dirty = true
+ end
+
+ # Use incremental update if enabled and we have a U matrix
+ return perform_incremental_update(node, clean_word_hash) if @incremental_mode && @u_matrix
+
build_index if @auto_rebuild
end
- # A less flexible shorthand for add_item that assumes
+ # A less flexible shorthand for add_item that assumes
# you are passing in a string with no categorries. item
- # will be duck typed via to_s .
+ # will be duck typed via to_s .
#
- def <<( item )
- add_item item
+ # @rbs (String) -> void
+ def <<(item)
+ add_item(item)
end
-
+
# Returns the categories for a given indexed items. You are free to add and remove
# items from this as you see fit. It does not invalide an index to change its categories.
+ #
+ # @rbs (String) -> Array[String | Symbol]
def categories_for(item)
- return [] unless @items[item]
- return @items[item].categories
+ synchronize do
+ return [] unless @items[item]
+
+ @items[item].categories
+ end
end
- # Removes an item from the database, if it is indexed.
+ # Removes an item from the database, if it is indexed.
#
- def remove_item( item )
- if @items.keys.contain? item
- @items.remove item
+ # @rbs (String) -> void
+ def remove_item(item)
+ removed = synchronize do
+ next false unless @items.key?(item)
+
+ @items.delete(item)
@version += 1
+ @dirty = true
+ true
end
+ build_index if removed && @auto_rebuild
end
-
- # Returns an array of items that are indexed.
+
+ # Returns an array of items that are indexed.
+ # @rbs () -> Array[untyped]
def items
- @items.keys
- end
-
- # Returns the categories for a given indexed items. You are free to add and remove
- # items from this as you see fit. It does not invalide an index to change its categories.
- def categories_for(item)
- return [] unless @items[item]
- return @items[item].categories
+ synchronize { @items.keys }
end
# This function rebuilds the index if needs_rebuild? returns true.
# For very large document spaces, this indexing operation may take some
- # time to complete, so it may be wise to place the operation in another
- # thread.
+ # time to complete, so it may be wise to place the operation in another
+ # thread.
#
# As a rule, indexing will be fairly swift on modern machines until
- # you have well over 500 documents indexed, or have an incredibly diverse
- # vocabulary for your documents.
+ # you have well over 500 documents indexed, or have an incredibly diverse
+ # vocabulary for your documents.
#
# The optional parameter "cutoff" is a tuning parameter. When the index is
- # built, a certain number of s-values are discarded from the system. The
+ # built, a certain number of s-values are discarded from the system. The
# cutoff parameter tells the indexer how many of these values to keep.
# A value of 1 for cutoff means that no semantic analysis will take place,
# turning the LSI class into a simple vector search engine.
- def build_index( cutoff=0.75 )
- return unless needs_rebuild?
- make_word_list
-
- doc_list = @items.values
- tda = doc_list.collect { |node| node.raw_vector_with( @word_list ) }
-
- if $GSL
- tdm = GSL::Matrix.alloc(*tda).trans
- ntdm = build_reduced_matrix(tdm, cutoff)
-
- ntdm.size[1].times do |col|
- vec = GSL::Vector.alloc( ntdm.column(col) ).row
- doc_list[col].lsi_vector = vec
- doc_list[col].lsi_norm = vec.normalize
- end
- else
- tdm = Matrix.rows(tda).trans
- ntdm = build_reduced_matrix(tdm, cutoff)
-
- ntdm.row_size.times do |col|
- doc_list[col].lsi_vector = ntdm.column(col) if doc_list[col]
- doc_list[col].lsi_norm = ntdm.column(col).normalize if doc_list[col]
- end
- end
-
- @built_at_version = @version
- end
-
+ #
+ # @rbs (?Float, ?force: bool) -> void
+ def build_index(cutoff = 0.75, force: false)
+ validate_cutoff!(cutoff)
+
+ synchronize do
+ return unless force || needs_rebuild_unlocked?
+
+ make_word_list
+
+ doc_list = @items.values
+ tda = doc_list.collect { |node| node.raw_vector_with(@word_list) }
+
+ if self.class.native_available?
+ # Convert vectors to arrays for matrix construction
+ tda_arrays = tda.map { |v| v.respond_to?(:to_a) ? v.to_a : v }
+ tdm = self.class.matrix_class.alloc(*tda_arrays).trans
+ ntdm, u_mat = build_reduced_matrix_with_u(tdm, cutoff)
+ assign_native_ext_lsi_vectors(ntdm, doc_list)
+ else
+ tdm = Matrix.rows(tda).trans
+ ntdm, u_mat = build_reduced_matrix_with_u(tdm, cutoff)
+ assign_ruby_lsi_vectors(ntdm, doc_list)
+ end
+
+ # Store U matrix for incremental mode
+ if @incremental_mode
+ @u_matrix = u_mat
+ @initial_vocab_size = @word_list.size
+ end
+
+ @built_at_version = @version
+ end
+ end
+
# This method returns max_chunks entries, ordered by their average semantic rating.
# Essentially, the average distance of each entry from all other entries is calculated,
# the highest are returned.
#
# This can be used to build a summary service, or to provide more information about
# your dataset's general content. For example, if you were to use categorize on the
- # results of this data, you could gather information on what your dataset is generally
+ # results of this data, you could gather information on what your dataset is generally
# about.
- def highest_relative_content( max_chunks=10 )
- return [] if needs_rebuild?
-
- avg_density = Hash.new
- @items.each_key { |x| avg_density[x] = proximity_array_for_content(x).inject(0.0) { |x,y| x + y[1]} }
-
- avg_density.keys.sort_by { |x| avg_density[x] }.reverse[0..max_chunks-1].map
+ #
+ # @rbs (?Integer) -> Array[String]
+ def highest_relative_content(max_chunks = 10)
+ synchronize do
+ return [] if needs_rebuild_unlocked?
+
+ avg_density = {}
+ @items.each_key { |x| avg_density[x] = proximity_array_for_content_unlocked(x).sum { |pair| pair[1] } }
+
+ avg_density.keys.sort_by { |x| avg_density[x] }.reverse[0..(max_chunks - 1)].map
+ end
end
- # This function is the primitive that find_related and classify
+ # This function is the primitive that find_related and classify
# build upon. It returns an array of 2-element arrays. The first element
# of this array is a document, and the second is its "score", defining
# how "close" it is to other indexed items.
- #
+ #
# These values are somewhat arbitrary, having to do with the vector space
# created by your content, so the magnitude is interpretable but not always
- # meaningful between indexes.
+ # meaningful between indexes.
#
# The parameter doc is the content to compare. If that content is not
- # indexed, you can pass an optional block to define how to create the
- # text data. See add_item for examples of how this works.
- def proximity_array_for_content( doc, &block )
- return [] if needs_rebuild?
-
- content_node = node_for_content( doc, &block )
- result =
- @items.keys.collect do |item|
- if $GSL
- val = content_node.search_vector * @items[item].search_vector.col
- else
- val = (Matrix[content_node.search_vector] * @items[item].search_vector)[0]
- end
- [item, val]
- end
- result.sort_by { |x| x[1] }.reverse
- end
-
+ # indexed, you can pass an optional block to define how to create the
+ # text data. See add_item for examples of how this works.
+ #
+ # @rbs (String) ?{ (String) -> String } -> Array[[String, Float]]
+ def proximity_array_for_content(doc, &block)
+ synchronize { proximity_array_for_content_unlocked(doc, &block) }
+ end
+
# Similar to proximity_array_for_content, this function takes similar
# arguments and returns a similar array. However, it uses the normalized
- # calculated vectors instead of their full versions. This is useful when
+ # calculated vectors instead of their full versions. This is useful when
# you're trying to perform operations on content that is much smaller than
# the text you're working with. search uses this primitive.
- def proximity_norms_for_content( doc, &block )
- return [] if needs_rebuild?
-
- content_node = node_for_content( doc, &block )
- result =
- @items.keys.collect do |item|
- if $GSL
- val = content_node.search_norm * @items[item].search_norm.col
- else
- val = (Matrix[content_node.search_norm] * @items[item].search_norm)[0]
- end
- [item, val]
- end
- result.sort_by { |x| x[1] }.reverse
- end
-
+ #
+ # @rbs (String) ?{ (String) -> String } -> Array[[String, Float]]
+ def proximity_norms_for_content(doc, &block)
+ synchronize { proximity_norms_for_content_unlocked(doc, &block) }
+ end
+
# This function allows for text-based search of your index. Unlike other functions
# like find_related and classify, search only takes short strings. It will also ignore
- # factors like repeated words. It is best for short, google-like search terms.
- # A search will first priortize lexical relationships, then semantic ones.
+ # factors like repeated words. It is best for short, google-like search terms.
+ # A search will first priortize lexical relationships, then semantic ones.
#
# While this may seem backwards compared to the other functions that LSI supports,
# it is actually the same algorithm, just applied on a smaller document.
- def search( string, max_nearest=3 )
- return [] if needs_rebuild?
- carry = proximity_norms_for_content( string )
- result = carry.collect { |x| x[0] }
- return result[0..max_nearest-1]
+ #
+ # @rbs (String, ?Integer) -> Array[String]
+ def search(string, max_nearest = 3)
+ synchronize do
+ return [] if needs_rebuild_unlocked?
+
+ carry = proximity_norms_for_content_unlocked(string)
+ result = carry.collect { |x| x[0] }
+ result[0..(max_nearest - 1)]
+ end
end
-
+
# This function takes content and finds other documents
# that are semantically "close", returning an array of documents sorted
# from most to least relavant.
- # max_nearest specifies the number of documents to return. A value of
- # 0 means that it returns all the indexed documents, sorted by relavence.
+ # max_nearest specifies the number of documents to return. A value of
+ # 0 means that it returns all the indexed documents, sorted by relavence.
#
- # This is particularly useful for identifing clusters in your document space.
+ # This is particularly useful for identifing clusters in your document space.
# For example you may want to identify several "What's Related" items for weblog
# articles, or find paragraphs that relate to each other in an essay.
- def find_related( doc, max_nearest=3, &block )
- carry =
- proximity_array_for_content( doc, &block ).reject { |pair| pair[0] == doc }
- result = carry.collect { |x| x[0] }
- return result[0..max_nearest-1]
- end
-
- # This function uses a voting system to categorize documents, based on
- # the categories of other documents. It uses the same logic as the
+ #
+ # @rbs (String, ?Integer) ?{ (String) -> String } -> Array[String]
+ def find_related(doc, max_nearest = 3, &block)
+ synchronize do
+ carry =
+ proximity_array_for_content_unlocked(doc, &block).reject { |pair| pair[0] == doc }
+ result = carry.collect { |x| x[0] }
+ result[0..(max_nearest - 1)]
+ end
+ end
+
+ # This function uses a voting system to categorize documents, based on
+ # the categories of other documents. It uses the same logic as the
# find_related function to find related documents, then returns the
- # most obvious category from this list.
+ # most obvious category from this list.
+ #
+ # @rbs (String, ?Float) ?{ (String) -> String } -> String | Symbol
+ def classify(doc, cutoff = 0.30, &block)
+ validate_cutoff!(cutoff)
+
+ synchronize do
+ votes = vote_unlocked(doc, cutoff, &block)
+
+ ranking = votes.keys.sort_by { |x| votes[x] }
+ ranking[-1]
+ end
+ end
+
+ # @rbs (String, ?Float) ?{ (String) -> String } -> Hash[String | Symbol, Float]
+ def vote(doc, cutoff = 0.30, &block)
+ validate_cutoff!(cutoff)
+
+ synchronize { vote_unlocked(doc, cutoff, &block) }
+ end
+
+ # Returns the same category as classify() but also returns
+ # a confidence value derived from the vote share that the
+ # winning category got.
+ #
+ # e.g.
+ # category,confidence = classify_with_confidence(doc)
+ # if confidence < 0.3
+ # category = nil
+ # end
+ #
+ # See classify() for argument docs
+ # @rbs (String, ?Float) ?{ (String) -> String } -> [String | Symbol | nil, Float?]
+ def classify_with_confidence(doc, cutoff = 0.30, &block)
+ validate_cutoff!(cutoff)
+
+ synchronize do
+ votes = vote_unlocked(doc, cutoff, &block)
+ votes_sum = votes.values.sum
+ return [nil, nil] if votes_sum.zero?
+
+ ranking = votes.keys.sort_by { |x| votes[x] }
+ winner = ranking[-1]
+ vote_share = votes[winner] / votes_sum.to_f
+ [winner, vote_share]
+ end
+ end
+
+ # Prototype, only works on indexed documents.
+ # I have no clue if this is going to work, but in theory
+ # it's supposed to.
+ # @rbs (String, ?Integer) -> Array[Symbol]
+ def highest_ranked_stems(doc, count = 3)
+ synchronize do
+ raise 'Requested stem ranking on non-indexed content!' unless @items[doc]
+
+ arr = node_for_content_unlocked(doc).lsi_vector.to_a
+ top_n = arr.sort.reverse[0..(count - 1)]
+ top_n.collect { |x| @word_list.word_for_index(arr.index(x)) }
+ end
+ end
+
+ # Custom marshal serialization to exclude mutex state
+ # @rbs () -> Array[untyped]
+ def marshal_dump
+ [@auto_rebuild, @word_list, @items, @version, @built_at_version, @dirty, @min_word_length]
+ end
+
+ # Custom marshal deserialization to recreate mutex
+ # @rbs (Array[untyped]) -> void
+ def marshal_load(data)
+ mu_initialize
+ @auto_rebuild, @word_list, @items, @version, @built_at_version, @dirty,
+ @min_word_length = data
+ @storage = nil
+ end
+
+ # Returns a hash representation of the LSI index.
+ # Only source data (word_hash, categories) is included, not computed vectors.
+ # This can be converted to JSON or used directly.
+ #
+ # @rbs () -> untyped
+ def as_json(*)
+ items_data = @items.transform_values do |node|
+ {
+ word_hash: node.word_hash.transform_keys(&:to_s),
+ categories: node.categories.map(&:to_s)
+ }
+ end
+
+ {
+ version: 1,
+ type: 'lsi',
+ auto_rebuild: @auto_rebuild,
+ items: items_data
+ }
+ end
+
+ # Serializes the LSI index to a JSON string.
+ # Only source data (word_hash, categories) is serialized, not computed vectors.
+ # On load, the index will be rebuilt automatically.
+ #
+ # @rbs () -> String
+ def to_json(*)
+ as_json.to_json
+ end
+
+ # Loads an LSI index from a JSON string or Hash created by #to_json or #as_json.
+ # The index will be rebuilt after loading.
+ #
+ # @rbs (String | Hash[String, untyped]) -> LSI
+ def self.from_json(json)
+ data = json.is_a?(String) ? JSON.parse(json) : json
+ raise ArgumentError, "Invalid classifier type: #{data['type']}" unless data['type'] == 'lsi'
+
+ # Create instance with auto_rebuild disabled during loading
+ instance = new(auto_rebuild: false)
+
+ # Restore items (categories stay as strings, matching original storage)
+ data['items'].each do |item_key, item_data|
+ word_hash = item_data['word_hash'].transform_keys(&:to_sym)
+ categories = item_data['categories']
+ instance.instance_variable_get(:@items)[item_key] = ContentNode.new(word_hash, *categories)
+ instance.instance_variable_set(:@version, instance.instance_variable_get(:@version) + 1)
+ end
+
+ # Restore auto_rebuild setting and rebuild index
+ instance.auto_rebuild = data['auto_rebuild']
+ instance.build_index
+ instance
+ end
+
+ # Saves the LSI index to the configured storage.
+ # Raises ArgumentError if no storage is configured.
+ #
+ # @rbs () -> void
+ def save
+ raise ArgumentError, 'No storage configured. Use save_to_file(path) or set storage=' unless storage
+
+ storage.write(to_json)
+ @dirty = false
+ end
+
+ # Saves the LSI index to a file (legacy API).
+ #
+ # @rbs (String) -> Integer
+ def save_to_file(path)
+ result = File.write(path, to_json)
+ @dirty = false
+ result
+ end
+
+ # Reloads the LSI index from the configured storage.
+ # Raises UnsavedChangesError if there are unsaved changes.
+ # Use reload! to force reload and discard changes.
+ #
+ # @rbs () -> self
+ def reload
+ raise ArgumentError, 'No storage configured' unless storage
+ raise UnsavedChangesError, 'Unsaved changes would be lost. Call save first or use reload!' if @dirty
+
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ restore_from_json(data)
+ @dirty = false
+ self
+ end
+
+ # Force reloads the LSI index from storage, discarding any unsaved changes.
+ #
+ # @rbs () -> self
+ def reload!
+ raise ArgumentError, 'No storage configured' unless storage
+
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ restore_from_json(data)
+ @dirty = false
+ self
+ end
+
+ # Returns true if there are unsaved changes.
+ #
+ # @rbs () -> bool
+ def dirty?
+ @dirty
+ end
+
+ # Loads an LSI index from the configured storage.
+ # The storage is set on the returned instance.
+ #
+ # @rbs (storage: Storage::Base) -> LSI
+ def self.load(storage:)
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ instance = from_json(data)
+ instance.storage = storage
+ instance
+ end
+
+ # Loads an LSI index from a file (legacy API).
+ #
+ # @rbs (String) -> LSI
+ def self.load_from_file(path)
+ from_json(File.read(path))
+ end
+
+ # Loads an LSI index from a checkpoint.
+ #
+ # @rbs (storage: Storage::Base, checkpoint_id: String) -> LSI
+ def self.load_checkpoint(storage:, checkpoint_id:)
+ raise ArgumentError, 'Storage must be File storage for checkpoints' unless storage.is_a?(Storage::File)
+
+ dir = File.dirname(storage.path)
+ base = File.basename(storage.path, '.*')
+ ext = File.extname(storage.path)
+ checkpoint_path = File.join(dir, "#{base}_checkpoint_#{checkpoint_id}#{ext}")
+
+ checkpoint_storage = Storage::File.new(path: checkpoint_path)
+ instance = load(storage: checkpoint_storage)
+ instance.storage = storage
+ instance
+ end
+
+ # Trains the LSI index from an IO stream.
+ # Each line in the stream is treated as a separate document.
+ # Documents are added without rebuilding, then the index is rebuilt at the end.
+ #
+ # @example Train from a file
+ # lsi.train_from_stream(:category, File.open('corpus.txt'))
+ #
+ # @example With progress tracking
+ # lsi.train_from_stream(:category, io, batch_size: 500) do |progress|
+ # puts "#{progress.completed} documents processed"
+ # end
+ #
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+ # @rbs (?(String | Symbol | nil), ?IO?, ?batch_size: Integer, **IO) { (Streaming::Progress) -> void } -> void
+ def train_from_stream(category = nil, io = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &)
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+ raise ArgumentError, 'Provide either (category, io) or keyword category: io pairs' if category.nil? && io.nil? && categories.empty?
+ raise ArgumentError, 'Provide both category and io, or use keyword arguments' if [category, io].one?(&:nil?)
+
+ pairs = category && io ? { category => io } : categories
+ pairs.each_value do |io|
+ raise ArgumentError, 'Stream must respond to #each_line' unless io.respond_to?(:each_line)
+ end
+ begin
+ original_auto_rebuild = @auto_rebuild
+ @auto_rebuild = false
+ pairs.each do |cat, stream|
+ stream_train_category(cat, stream, batch_size:, &)
+ end
+ ensure
+ @auto_rebuild = original_auto_rebuild
+ build_index if original_auto_rebuild
+ end
+ end
+
+ # Adds items to the index in batches from an array.
+ # Documents are added without rebuilding, then the index is rebuilt at the end.
+ #
+ # @example Batch add with progress
+ # lsi.add_batch(Dog: documents, batch_size: 100) do |progress|
+ # puts "#{progress.percent}% complete"
+ # end
#
- # cutoff signifies the number of documents to consider when clasifying
- # text. A cutoff of 1 means that every document in the index votes on
- # what category the document is in. This may not always make sense.
+ # @rbs (?batch_size: Integer, **Array[String]) { (Streaming::Progress) -> void } -> void
+ def add_batch(batch_size: Streaming::DEFAULT_BATCH_SIZE, **items)
+ original_auto_rebuild = @auto_rebuild
+ @auto_rebuild = false
+
+ begin
+ total_docs = items.values.sum { |v| Array(v).size }
+ progress = Streaming::Progress.new(total: total_docs)
+
+ items.each do |category, documents|
+ Array(documents).each_slice(batch_size) do |batch|
+ batch.each { |doc| add_item(doc, category.to_s) }
+ progress.completed += batch.size
+ progress.current_batch += 1
+ yield progress if block_given?
+ end
+ end
+ ensure
+ @auto_rebuild = original_auto_rebuild
+ build_index if original_auto_rebuild
+ end
+ end
+
+ # Alias train_batch to add_batch for API consistency with other classifiers.
+ # Note: LSI uses categories differently (items have categories, not the training call).
#
- def classify( doc, cutoff=0.30, &block )
+ # @rbs (?(String | Symbol)?, ?Array[String]?, ?batch_size: Integer, **Array[String]) { (Streaming::Progress) -> void } -> void
+ def train_batch(category = nil, documents = nil, batch_size: Streaming::DEFAULT_BATCH_SIZE, **categories, &block)
+ if category && documents
+ add_batch(batch_size: batch_size, **{ category.to_sym => documents }, &block)
+ else
+ add_batch(batch_size: batch_size, **categories, &block)
+ end
+ end
+
+ private
+
+ # Trains from an IO stream with a single category.
+ # @rbs (String | Symbol, IO, batch_size: Integer) { (Streaming::Progress) -> void } -> void
+ def stream_train_category(category, io, batch_size:)
+ reader = Streaming::LineReader.new(io, batch_size: batch_size)
+ total = reader.estimate_line_count
+ progress = Streaming::Progress.new(total: total)
+
+ reader.each_batch do |batch|
+ batch.each { |text| add_item(text, category) }
+ progress.completed += batch.size
+ progress.current_batch += 1
+ yield progress if block_given?
+ end
+ end
+
+ # Restores LSI state from a JSON string (used by reload)
+ # @rbs (String) -> void
+ def restore_from_json(json)
+ data = JSON.parse(json)
+ raise ArgumentError, "Invalid classifier type: #{data['type']}" unless data['type'] == 'lsi'
+
+ synchronize do
+ # Recreate the items
+ @items = {}
+ data['items'].each do |item_key, item_data|
+ word_hash = item_data['word_hash'].transform_keys(&:to_sym)
+ categories = item_data['categories']
+ @items[item_key] = ContentNode.new(word_hash, *categories)
+ end
+
+ # Restore settings
+ @auto_rebuild = data['auto_rebuild']
+ @version += 1
+ @built_at_version = -1
+ @word_list = WordList.new
+ @dirty = false
+ end
+
+ # Rebuild the index
+ build_index
+ end
+
+ # @rbs (Float) -> void
+ def validate_cutoff!(cutoff)
+ return if cutoff.positive? && cutoff < 1
+
+ raise ArgumentError, "cutoff must be between 0 and 1 (exclusive), got #{cutoff}"
+ end
+
+ # Assigns LSI vectors using native C extension
+ # @rbs (untyped, Array[ContentNode]) -> void
+ def assign_native_ext_lsi_vectors(ntdm, doc_list)
+ ntdm.size[1].times do |col|
+ vec = self.class.vector_class.alloc(ntdm.column(col).to_a).row
+ doc_list[col].lsi_vector = vec
+ doc_list[col].lsi_norm = vec.normalize
+ end
+ end
+
+ # Assigns LSI vectors using pure Ruby Matrix
+ # @rbs (untyped, Array[ContentNode]) -> void
+ def assign_ruby_lsi_vectors(ntdm, doc_list)
+ ntdm.column_size.times do |col|
+ next unless doc_list[col]
+
+ column = ntdm.column(col)
+ next unless column
+
+ doc_list[col].lsi_vector = column
+ doc_list[col].lsi_norm = column.normalize
+ end
+ end
+
+ # Unlocked version of needs_rebuild? for internal use when lock is already held
+ # @rbs () -> bool
+ def needs_rebuild_unlocked?
+ (@items.keys.size > 1) && (@version != @built_at_version)
+ end
+
+ # Unlocked version of proximity_array_for_content for internal use
+ # @rbs (String) ?{ (String) -> String } -> Array[[String, Float]]
+ def proximity_array_for_content_unlocked(doc, &)
+ return [] if needs_rebuild_unlocked?
+ return @items.keys.map { |item| [item, 1.0] } if @items.size == 1
+
+ content_node = node_for_content_unlocked(doc, &)
+ result =
+ @items.keys.collect do |item|
+ val = if self.class.native_available?
+ content_node.search_vector * @items[item].search_vector.col
+ else
+ (Matrix[content_node.search_vector] * @items[item].search_vector)[0]
+ end
+ [item, val]
+ end
+ result.sort_by { |x| x[1] }.reverse
+ end
+
+ # Unlocked version of proximity_norms_for_content for internal use
+ # @rbs (String) ?{ (String) -> String } -> Array[[String, Float]]
+ def proximity_norms_for_content_unlocked(doc, &)
+ return [] if needs_rebuild_unlocked?
+
+ content_node = node_for_content_unlocked(doc, &)
+ result =
+ @items.keys.collect do |item|
+ val = if self.class.native_available?
+ content_node.search_norm * @items[item].search_norm.col
+ else
+ (Matrix[content_node.search_norm] * @items[item].search_norm)[0]
+ end
+ [item, val]
+ end
+ result.sort_by { |x| x[1] }.reverse
+ end
+
+ # Unlocked version of vote for internal use
+ # @rbs (String, ?Float) ?{ (String) -> String } -> Hash[String | Symbol, Float]
+ def vote_unlocked(doc, cutoff = 0.30, &)
icutoff = (@items.size * cutoff).round
- carry = proximity_array_for_content( doc, &block )
- carry = carry[0..icutoff-1]
+ carry = proximity_array_for_content_unlocked(doc, &)
+ carry = carry[0..(icutoff - 1)]
votes = {}
carry.each do |pair|
categories = @items[pair[0]].categories
- categories.each do |category|
+ categories.each do |category|
votes[category] ||= 0.0
- votes[category] += pair[1]
+ votes[category] += pair[1]
end
end
-
- ranking = votes.keys.sort_by { |x| votes[x] }
- return ranking[-1]
+ votes
end
-
- # Prototype, only works on indexed documents.
- # I have no clue if this is going to work, but in theory
- # it's supposed to.
- def highest_ranked_stems( doc, count=3 )
- raise "Requested stem ranking on non-indexed content!" unless @items[doc]
- arr = node_for_content(doc).lsi_vector.to_a
- top_n = arr.sort.reverse[0..count-1]
- return top_n.collect { |x| @word_list.word_for_index(arr.index(x))}
+
+ # Unlocked version of node_for_content for internal use.
+ # @rbs (String) ?{ (String) -> String } -> ContentNode
+ def node_for_content_unlocked(item, &block)
+ return @items[item] if @items[item]
+
+ clean_word_hash = block ? block.call(item).clean_word_hash : item.to_s.clean_word_hash
+ cn = ContentNode.new(clean_word_hash, &block)
+ cn.raw_vector_with(@word_list) unless needs_rebuild_unlocked?
+ assign_lsi_vector_incremental(cn) if incremental_enabled?
+ cn
end
- private
- def build_reduced_matrix( matrix, cutoff=0.75 )
- # TODO: Check that M>=N on these dimensions! Transpose helps assure this
+ # @rbs (untyped, ?Float) -> untyped
+ def build_reduced_matrix(matrix, cutoff = 0.75)
+ result, _u = build_reduced_matrix_with_u(matrix, cutoff)
+ result
+ end
+
+ # Builds reduced matrix and returns both the result and the U matrix.
+ # U matrix is needed for incremental SVD updates.
+ # @rbs (untyped, ?Float) -> [untyped, Matrix]
+ def build_reduced_matrix_with_u(matrix, cutoff = 0.75)
u, v, s = matrix.SV_decomp
- # TODO: Better than 75% term, please. :\
- s_cutoff = s.sort.reverse[(s.size * cutoff).round - 1]
+ all_singular_values = s.sort.reverse
+ s_cutoff_index = [(s.size * cutoff).round - 1, 0].max
+ s_cutoff = all_singular_values[s_cutoff_index]
+
+ kept_indices = []
+ kept_singular_values = []
s.size.times do |ord|
- s[ord] = 0.0 if s[ord] < s_cutoff
+ if s[ord] >= s_cutoff
+ kept_indices << ord
+ kept_singular_values << s[ord]
+ else
+ s[ord] = 0.0
+ end
end
- # Reconstruct the term document matrix, only with reduced rank
- u * ($GSL ? GSL::Matrix : ::Matrix).diag( s ) * v.trans
+
+ @singular_values = kept_singular_values.sort.reverse
+ result = u * self.class.matrix_class.diag(s) * v.trans
+ result = result.trans if result.row_size != matrix.row_size
+ u_reduced = extract_reduced_u(u, kept_indices, s)
+
+ [result, u_reduced]
end
-
- def node_for_content(item, &block)
- if @items[item]
- return @items[item]
- else
- clean_word_hash = block ? block.call(item).clean_word_hash : item.to_s.clean_word_hash
- cn = ContentNode.new(clean_word_hash, &block) # make the node and extract the data
+ # Extracts columns from U corresponding to kept singular values.
+ # Columns are sorted by descending singular value to match @singular_values order.
+ # rubocop:disable Naming/MethodParameterName
+ # @rbs (untyped, Array[Integer], Array[Float]) -> Matrix
+ def extract_reduced_u(u, kept_indices, singular_values)
+ return Matrix.empty(u.row_size, 0) if kept_indices.empty?
- unless needs_rebuild?
- cn.raw_vector_with( @word_list ) # make the lsi raw and norm vectors
+ sorted_indices = kept_indices.sort_by { |i| -singular_values[i] }
+
+ if u.respond_to?(:to_ruby_matrix)
+ u = u.to_ruby_matrix
+ elsif !u.is_a?(::Matrix)
+ rows = u.row_size.times.map do |i|
+ sorted_indices.map { |j| u[i, j] }
end
+ return Matrix.rows(rows)
end
-
- return cn
+
+ cols = sorted_indices.map { |i| u.column(i).to_a }
+ Matrix.columns(cols)
end
-
+ # rubocop:enable Naming/MethodParameterName
+
+ # @rbs () -> void
def make_word_list
@word_list = WordList.new
@items.each_value do |node|
@@ -313,6 +941,128 @@ module Classifier
end
end
+ # Performs incremental SVD update for a new document.
+ # @rbs (ContentNode, Hash[Symbol, Integer]) -> void
+ def perform_incremental_update(node, word_hash)
+ needs_full_rebuild = false
+ old_rank = nil
+
+ synchronize do
+ if vocabulary_growth_exceeds_threshold?(word_hash)
+ disable_incremental_mode!
+ needs_full_rebuild = true
+ next
+ end
+
+ old_rank = @u_matrix.column_size
+ extend_vocabulary_for_incremental(word_hash)
+ raw_vec = node.raw_vector_with(@word_list)
+ raw_vector = Vector[*raw_vec.to_a]
+
+ @u_matrix, @singular_values = IncrementalSVD.update(
+ @u_matrix, @singular_values, raw_vector, max_rank: @max_rank
+ )
+
+ new_rank = @u_matrix.column_size
+ if new_rank > old_rank
+ reproject_all_documents
+ else
+ assign_lsi_vector_incremental(node)
+ end
+
+ @built_at_version = @version
+ end
+
+ build_index if needs_full_rebuild
+ end
+
+ # Checks if vocabulary growth would exceed threshold (20%)
+ # @rbs (Hash[Symbol, Integer]) -> bool
+ def vocabulary_growth_exceeds_threshold?(word_hash)
+ return false unless @initial_vocab_size&.positive?
+
+ new_words = word_hash.keys.count { |w| @word_list[w].nil? }
+ growth_ratio = new_words.to_f / @initial_vocab_size
+ growth_ratio > 0.2
+ end
+
+ # Extends vocabulary and U matrix for new words.
+ # @rbs (Hash[Symbol, Integer]) -> void
+ def extend_vocabulary_for_incremental(word_hash)
+ new_words = word_hash.keys.select { |w| @word_list[w].nil? }
+ return if new_words.empty?
+
+ new_words.each { |word| @word_list.add_word(word) }
+ extend_u_matrix(new_words.size)
+ end
+
+ # Extends U matrix with zero rows for new vocabulary terms.
+ # @rbs (Integer) -> void
+ def extend_u_matrix(num_new_rows)
+ return if num_new_rows.zero? || @u_matrix.nil?
+
+ if self.class.native_available? && @u_matrix.is_a?(self.class.matrix_class)
+ new_rows = self.class.matrix_class.zeros(num_new_rows, @u_matrix.column_size)
+ @u_matrix = self.class.matrix_class.vstack(@u_matrix, new_rows)
+ else
+ new_rows = Matrix.zero(num_new_rows, @u_matrix.column_size)
+ @u_matrix = Matrix.vstack(@u_matrix, new_rows)
+ end
+ end
+
+ # Re-projects all documents onto the current U matrix
+ # Called when rank grows to ensure consistent LSI vector sizes
+ # Uses native batch_project for performance when available
+ # @rbs () -> void
+ def reproject_all_documents
+ return unless @u_matrix
+ return reproject_all_documents_native if self.class.native_available? && @u_matrix.respond_to?(:batch_project)
+
+ reproject_all_documents_ruby
+ end
+
+ # Native batch re-projection using C extension.
+ # @rbs () -> void
+ def reproject_all_documents_native
+ nodes = @items.values
+ raw_vectors = nodes.map do |node|
+ raw = node.raw_vector_with(@word_list)
+ raw.is_a?(self.class.vector_class) ? raw : self.class.vector_class.alloc(raw.to_a)
+ end
+
+ lsi_vectors = @u_matrix.batch_project(raw_vectors)
+
+ nodes.each_with_index do |node, i|
+ lsi_vec = lsi_vectors[i].row
+ node.lsi_vector = lsi_vec
+ node.lsi_norm = lsi_vec.normalize
+ end
+ end
+
+ # Pure Ruby re-projection (fallback)
+ # @rbs () -> void
+ def reproject_all_documents_ruby
+ @items.each_value do |node|
+ assign_lsi_vector_incremental(node)
+ end
+ end
+
+ # Assigns LSI vector to a node using projection: lsi_vec = U^T * raw_vec.
+ # @rbs (ContentNode) -> void
+ def assign_lsi_vector_incremental(node)
+ return unless @u_matrix
+
+ raw_vec = node.raw_vector_with(@word_list)
+ raw_vector = Vector[*raw_vec.to_a]
+ lsi_arr = (@u_matrix.transpose * raw_vector).to_a
+
+ lsi_vec = if self.class.native_available?
+ self.class.vector_class.alloc(lsi_arr).row
+ else
+ Vector[*lsi_arr]
+ end
+ node.lsi_vector = lsi_vec
+ node.lsi_norm = lsi_vec.normalize
+ end
end
end
-
diff --git a/lib/classifier/lsi/content_node.rb b/lib/classifier/lsi/content_node.rb
index f313331..37a9448 100644
--- a/lib/classifier/lsi/content_node.rb
+++ b/lib/classifier/lsi/content_node.rb
@@ -1,72 +1,96 @@
+# rbs_inline: enabled
+
# Author:: David Fayram (mailto:dfayram@lensmen.net)
# Copyright:: Copyright (c) 2005 David Fayram II
# License:: LGPL
module Classifier
-
-# This is an internal data structure class for the LSI node. Save for
-# raw_vector_with, it should be fairly straightforward to understand.
-# You should never have to use it directly.
+ # This is an internal data structure class for the LSI node. Save for
+ # raw_vector_with, it should be fairly straightforward to understand.
+ # You should never have to use it directly.
class ContentNode
- attr_accessor :raw_vector, :raw_norm,
- :lsi_vector, :lsi_norm,
- :categories
-
+ # @rbs @word_hash: Hash[Symbol, Integer]
+
+ # @rbs @raw_vector: untyped
+ # @rbs @raw_norm: untyped
+ # @rbs @lsi_vector: untyped
+ # @rbs @lsi_norm: untyped
+ attr_accessor :raw_vector, :raw_norm, :lsi_vector, :lsi_norm
+
+ # @rbs @categories: Array[String | Symbol]
+ attr_accessor :categories
+
attr_reader :word_hash
+
# If text_proc is not specified, the source will be duck-typed
# via source.to_s
- def initialize( word_hash, *categories )
+ #
+ # @rbs (Hash[Symbol, Integer], *String | Symbol) -> void
+ def initialize(word_frequencies, *categories)
@categories = categories || []
- @word_hash = word_hash
+ @word_hash = word_frequencies
end
-
+
# Use this to fetch the appropriate search vector.
+ #
+ # @rbs () -> untyped
def search_vector
@lsi_vector || @raw_vector
end
-
+
# Use this to fetch the appropriate search vector in normalized form.
+ #
+ # @rbs () -> untyped
def search_norm
@lsi_norm || @raw_norm
end
-
+
# Creates the raw vector out of word_hash using word_list as the
# key for mapping the vector space.
- def raw_vector_with( word_list )
- if $GSL
- vec = GSL::Vector.alloc(word_list.size)
- else
- vec = Array.new(word_list.size, 0)
- end
+ #
+ # @rbs (WordList) -> untyped
+ def raw_vector_with(word_list)
+ vec = if Classifier::LSI.native_available?
+ Classifier::LSI.vector_class.alloc(word_list.size)
+ else
+ Array.new(word_list.size, 0)
+ end
@word_hash.each_key do |word|
vec[word_list[word]] = @word_hash[word] if word_list[word]
end
-
+
# Perform the scaling transform
- total_words = vec.sum
-
+ total_words = Classifier::LSI.native_available? ? vec.sum : vec.sum_with_identity
+ vec_array = Classifier::LSI.native_available? ? vec.to_a : vec
+ total_unique_words = vec_array.count { |word| word != 0 }
+
# Perform first-order association transform if this vector has more
- # than one word in it.
- if total_words > 1.0
+ # than one word in it.
+ if total_words > 1.0 && total_unique_words > 1
weighted_total = 0.0
+
vec.each do |term|
- if ( term > 0 )
- weighted_total += (( term / total_words ) * Math.log( term / total_words ))
- end
- end
- vec = vec.collect { |val| Math.log( val + 1 ) / -weighted_total }
+ next unless term.positive?
+ next if total_words.zero?
+
+ term_over_total = term / total_words
+ val = term_over_total * Math.log(term_over_total)
+ weighted_total += val unless val.nan?
+ end
+
+ sign = weighted_total.negative? ? 1.0 : -1.0
+ divisor = sign * [weighted_total.abs, Vector::EPSILON].max
+ vec = vec.collect { |val| Math.log(val + 1) / divisor }
end
-
- if $GSL
- @raw_norm = vec.normalize
- @raw_vector = vec
+
+ if Classifier::LSI.native_available?
+ @raw_norm = vec.normalize
+ @raw_vector = vec
else
- @raw_norm = Vector[*vec].normalize
- @raw_vector = Vector[*vec]
+ @raw_norm = Vector[*vec].normalize
+ @raw_vector = Vector[*vec]
end
- end
-
- end
-
+ end
+ end
end
diff --git a/lib/classifier/lsi/incremental_svd.rb b/lib/classifier/lsi/incremental_svd.rb
new file mode 100644
index 0000000..beabfa3
--- /dev/null
+++ b/lib/classifier/lsi/incremental_svd.rb
@@ -0,0 +1,166 @@
+# rbs_inline: enabled
+
+# rubocop:disable Naming/MethodParameterName, Metrics/ParameterLists
+
+require 'matrix'
+
+module Classifier
+ class LSI
+ # Brand's Incremental SVD Algorithm for LSI
+ #
+ # Implements the algorithm from Brand (2006) "Fast low-rank modifications
+ # of the thin singular value decomposition" for adding documents to LSI
+ # without full SVD recomputation.
+ #
+ # Given existing thin SVD: A ≈ U * S * V^T (with k components)
+ # When adding a new column c:
+ #
+ # 1. Project: m = U^T * c (project onto existing column space)
+ # 2. Residual: p = c - U * m (component orthogonal to U)
+ # 3. Orthonormalize: If ||p|| > ε: p̂ = p / ||p||
+ # 4. Form K matrix:
+ # - If ||p|| > ε: K = [diag(s), m; 0, ||p||] (rank grows by 1)
+ # - If ||p|| ≈ 0: K = diag(s) + m * e_last^T (no new direction)
+ # 5. Small SVD: Compute SVD of K (only (k+1) × (k+1) matrix!)
+ # 6. Update:
+ # - U_new = [U, p̂] * U'
+ # - S_new = S'
+ #
+ module IncrementalSVD
+ EPSILON = 1e-10
+
+ class << self
+ # Updates SVD with a new document vector using Brand's algorithm.
+ #
+ # @param u [Matrix] current left singular vectors (m × k)
+ # @param s [Array<Float>] current singular values (k values)
+ # @param c [Vector] new document vector (m × 1)
+ # @param max_rank [Integer] maximum rank to maintain
+ # @param epsilon [Float] threshold for zero detection
+ # @return [Array<Matrix, Array<Float>>] updated [u, s]
+ #
+ # @rbs (Matrix, Array[Float], Vector, max_rank: Integer, ?epsilon: Float) -> [Matrix, Array[Float]]
+ def update(u, s, c, max_rank:, epsilon: EPSILON)
+ m_vec = project(u, c)
+ u_times_m = u * m_vec
+ p_vec = c - (u_times_m.is_a?(Vector) ? u_times_m : Vector[*u_times_m.to_a.flatten])
+ p_norm = magnitude(p_vec)
+
+ if p_norm > epsilon
+ update_with_new_direction(u, s, m_vec, p_vec, p_norm, max_rank, epsilon)
+ else
+ update_in_span(u, s, m_vec, max_rank, epsilon)
+ end
+ end
+
+ # Projects a document vector onto the semantic space defined by U.
+ # Returns the LSI representation: lsi_vec = U^T * raw_vec
+ #
+ # @param u [Matrix] left singular vectors (m × k)
+ # @param raw_vec [Vector] document vector in term space (m × 1)
+ # @return [Vector] document in semantic space (k × 1)
+ #
+ # @rbs (Matrix, Vector) -> Vector
+ def project(u, raw_vec)
+ u.transpose * raw_vec
+ end
+
+ private
+
+ # Update when new document has a component orthogonal to existing U.
+ # @rbs (Matrix, Array[Float], Vector, Vector, Float, Integer, Float) -> [Matrix, Array[Float]]
+ def update_with_new_direction(u, s, m_vec, p_vec, p_norm, max_rank, epsilon)
+ p_hat = p_vec * (1.0 / p_norm)
+ k_matrix = build_k_matrix_with_growth(s, m_vec, p_norm)
+ u_prime, s_prime = small_svd(k_matrix, epsilon)
+ u_extended = extend_matrix_with_column(u, p_hat)
+ u_new = u_extended * u_prime
+
+ u_new, s_prime = truncate(u_new, s_prime, max_rank) if s_prime.size > max_rank
+
+ [u_new, s_prime]
+ end
+
+ # Update when new document is entirely within span of existing U.
+ # @rbs (Matrix, Array[Float], Vector, Integer, Float) -> [Matrix, Array[Float]]
+ def update_in_span(u, s, m_vec, max_rank, epsilon)
+ k_matrix = build_k_matrix_in_span(s, m_vec)
+ u_prime, s_prime = small_svd(k_matrix, epsilon)
+ u_new = u * u_prime
+
+ u_new, s_prime = truncate(u_new, s_prime, max_rank) if s_prime.size > max_rank
+
+ [u_new, s_prime]
+ end
+
+ # Builds the K matrix when rank grows by 1.
+ # @rbs (Array[Float], untyped, Float) -> untyped
+ def build_k_matrix_with_growth(s, m_vec, p_norm)
+ k = s.size
+ rows = k.times.map do |i|
+ row = Array.new(k + 1, 0.0) #: Array[Float]
+ row[i] = s[i].to_f
+ row[k] = m_vec[i].to_f
+ row
+ end
+ rows << Array.new(k + 1, 0.0).tap { |r| r[k] = p_norm }
+ Matrix.rows(rows)
+ end
+
+ # Builds the K matrix when vector is in span (no rank growth).
+ # @rbs (Array[Float], Vector) -> Matrix
+ def build_k_matrix_in_span(s, _m_vec)
+ k = s.size
+ rows = k.times.map do |i|
+ row = Array.new(k, 0.0)
+ row[i] = s[i]
+ row
+ end
+ Matrix.rows(rows)
+ end
+
+ # Computes SVD of small matrix and extracts singular values.
+ # @rbs (Matrix, Float) -> [Matrix, Array[Float]]
+ def small_svd(matrix, epsilon)
+ u, _v, s_array = matrix.SV_decomp
+
+ s_sorted = s_array.select { |sv| sv.abs > epsilon }.sort.reverse
+ indices = s_array.each_with_index
+ .select { |sv, _| sv.abs > epsilon }
+ .sort_by { |sv, _| -sv }
+ .map { |_, i| i }
+
+ u_cols = indices.map { |i| u.column(i).to_a }
+ u_reordered = u_cols.empty? ? Matrix.empty(matrix.row_size, 0) : Matrix.columns(u_cols)
+
+ [u_reordered, s_sorted]
+ end
+
+ # Extends matrix with a new column
+ # @rbs (Matrix, Vector) -> Matrix
+ def extend_matrix_with_column(matrix, col_vec)
+ rows = matrix.row_size.times.map do |i|
+ matrix.row(i).to_a + [col_vec[i]]
+ end
+ Matrix.rows(rows)
+ end
+
+ # Truncates to max_rank
+ # @rbs (untyped, Array[Float], Integer) -> [untyped, Array[Float]]
+ def truncate(u, s, max_rank)
+ s_truncated = s[0...max_rank] || [] #: Array[Float]
+ cols = (0...max_rank).map { |i| u.column(i).to_a }
+ u_truncated = Matrix.columns(cols)
+ [u_truncated, s_truncated]
+ end
+
+ # Computes magnitude of a vector
+ # @rbs (untyped) -> Float
+ def magnitude(vec)
+ Math.sqrt(vec.to_a.sum { |x| x.to_f * x.to_f })
+ end
+ end
+ end
+ end
+end
+# rubocop:enable Naming/MethodParameterName, Metrics/ParameterLists
diff --git a/lib/classifier/lsi/summary.rb b/lib/classifier/lsi/summary.rb
index 6adf7ac..3328057 100644
--- a/lib/classifier/lsi/summary.rb
+++ b/lib/classifier/lsi/summary.rb
@@ -3,29 +3,49 @@
# License:: LGPL
class String
- def summary( count=10, separator=" [...] " )
- perform_lsi split_sentences, count, separator
- end
-
- def paragraph_summary( count=1, separator=" [...] " )
- perform_lsi split_paragraphs, count, separator
- end
-
- def split_sentences
- split /(\.|\!|\?)/ # TODO: make this less primitive
- end
-
- def split_paragraphs
- split /(\n\n|\r\r|\r\n\r\n)/ # TODO: make this less primitive
- end
-
- private
-
- def perform_lsi(chunks, count, separator)
- lsi = Classifier::LSI.new :auto_rebuild => false
- chunks.each { |chunk| lsi << chunk unless chunk.strip.empty? || chunk.strip.split.size == 1 }
- lsi.build_index
- summaries = lsi.highest_relative_content count
- return summaries.reject { |chunk| !summaries.include? chunk }.map { |x| x.strip }.join(separator)
- end
-end \ No newline at end of file
+ ABBREVIATIONS = %w[Mr Mrs Ms Dr Prof Jr Sr Inc Ltd Corp Co vs etc al eg ie].freeze
+
+ def summary(count = 10, separator = ' [...] ')
+ perform_lsi split_sentences, count, separator
+ end
+
+ def paragraph_summary(count = 1, separator = ' [...] ')
+ perform_lsi split_paragraphs, count, separator
+ end
+
+ def split_sentences
+ return pragmatic_segment if defined?(PragmaticSegmenter)
+
+ split_sentences_regex
+ end
+
+ def split_paragraphs
+ split(/\r?\n\r?\n+/)
+ end
+
+ private
+
+ def pragmatic_segment
+ PragmaticSegmenter::Segmenter.new(text: self).segment
+ end
+
+ def split_sentences_regex
+ abbrev_pattern = ABBREVIATIONS.map { |a| "#{a}\\." }.join('|')
+ text = gsub(/\b(#{abbrev_pattern})/i) { |m| m.gsub('.', '<<<DOT>>>') }
+ text = text.gsub(/(\d)\.(\d)/, '\1<<<DOT>>>\2')
+ sentences = text.split(/(?<=[.!?])(?:\s+|(?=[A-Z]))/)
+ sentences.map { |s| s.gsub('<<<DOT>>>', '.') }
+ end
+
+ def perform_lsi(chunks, count, separator)
+ lsi = Classifier::LSI.new auto_rebuild: false
+ chunks.each do |chunk|
+ stripped = chunk.strip
+ next if stripped.empty? || stripped.split.size == 1
+
+ lsi << chunk
+ end
+ lsi.build_index
+ lsi.highest_relative_content(count).map(&:strip).join(separator)
+ end
+end
diff --git a/lib/classifier/lsi/word_list.rb b/lib/classifier/lsi/word_list.rb
index dba3bde..a8bca9e 100644
--- a/lib/classifier/lsi/word_list.rb
+++ b/lib/classifier/lsi/word_list.rb
@@ -1,36 +1,46 @@
+# rbs_inline: enabled
+
# Author:: David Fayram (mailto:dfayram@lensmen.net)
# Copyright:: Copyright (c) 2005 David Fayram II
# License:: LGPL
-module Classifier
+module Classifier
# This class keeps a word => index mapping. It is used to map stemmed words
# to dimensions of a vector.
-
class WordList
+ # @rbs @location_table: Hash[Symbol, Integer]
+
+ # @rbs () -> void
def initialize
- @location_table = Hash.new
+ @location_table = {}
end
-
+
# Adds a word (if it is new) and assigns it a unique dimension.
+ #
+ # @rbs (Symbol) -> Integer?
def add_word(word)
term = word
@location_table[term] = @location_table.size unless @location_table[term]
end
-
+
# Returns the dimension of the word or nil if the word is not in the space.
+ #
+ # @rbs (Symbol) -> Integer?
def [](lookup)
term = lookup
@location_table[term]
end
-
+
+ # @rbs (Integer) -> Symbol?
def word_for_index(ind)
@location_table.invert[ind]
end
-
+
# Returns the number of words mapped.
+ #
+ # @rbs () -> Integer
def size
@location_table.size
end
-
end
end
diff --git a/lib/classifier/storage.rb b/lib/classifier/storage.rb
new file mode 100644
index 0000000..da4f36e
--- /dev/null
+++ b/lib/classifier/storage.rb
@@ -0,0 +1,9 @@
+# rbs_inline: enabled
+
+# Author:: Lucas Carlson (mailto:lucas@rufy.com)
+# Copyright:: Copyright (c) 2005 Lucas Carlson
+# License:: LGPL
+
+require_relative 'storage/base'
+require_relative 'storage/memory'
+require_relative 'storage/file'
diff --git a/lib/classifier/storage/base.rb b/lib/classifier/storage/base.rb
new file mode 100644
index 0000000..91decbb
--- /dev/null
+++ b/lib/classifier/storage/base.rb
@@ -0,0 +1,50 @@
+# rbs_inline: enabled
+
+# Author:: Lucas Carlson (mailto:lucas@rufy.com)
+# Copyright:: Copyright (c) 2005 Lucas Carlson
+# License:: LGPL
+
+module Classifier
+ module Storage
+ # Abstract base class for storage backends.
+ # Implement this protocol to create custom storage (Redis, PostgreSQL, etc.)
+ #
+ # Example:
+ # class RedisStorage < Classifier::Storage::Base
+ # def initialize(redis:, key:)
+ # @redis, @key = redis, key
+ # end
+ #
+ # def write(data) = @redis.set(@key, data)
+ # def read = @redis.get(@key)
+ # def delete = @redis.del(@key)
+ # def exists? = @redis.exists?(@key)
+ # end
+ #
+ class Base
+ # Save classifier data
+ # @rbs (String) -> void
+ def write(data)
+ raise NotImplementedError, "#{self.class}#write must be implemented"
+ end
+
+ # Load classifier data
+ # @rbs () -> String?
+ def read
+ raise NotImplementedError, "#{self.class}#read must be implemented"
+ end
+
+ # Delete classifier data
+ # @rbs () -> void
+ def delete
+ raise NotImplementedError, "#{self.class}#delete must be implemented"
+ end
+
+ # Check if data exists
+ # @rbs () -> bool
+ def exists?
+ raise NotImplementedError, "#{self.class}#exists? must be implemented"
+ end
+ end
+ end
+end
diff --git a/lib/classifier/storage/file.rb b/lib/classifier/storage/file.rb
new file mode 100644
index 0000000..8c265db
--- /dev/null
+++ b/lib/classifier/storage/file.rb
@@ -0,0 +1,51 @@
+# rbs_inline: enabled
+
+# Author:: Lucas Carlson (mailto:lucas@rufy.com)
+# Copyright:: Copyright (c) 2005 Lucas Carlson
+# License:: LGPL
+
+require_relative 'base'
+
+module Classifier
+ module Storage
+ # File-based storage backend.
+ #
+ # Example:
+ # bayes = Classifier::Bayes.new('Spam', 'Ham')
+ # bayes.storage = Classifier::Storage::File.new(path: "/var/models/spam.json")
+ # bayes.train_spam("Buy now!")
+ # bayes.save
+ #
+ class File < Base
+ # @rbs @path: String
+
+ attr_reader :path
+
+ # @rbs (path: String) -> void
+ def initialize(path:)
+ super()
+ @path = path
+ end
+
+ # @rbs (String) -> Integer
+ def write(data)
+ ::File.write(@path, data)
+ end
+
+ # @rbs () -> String?
+ def read
+ exists? ? ::File.read(@path) : nil
+ end
+
+ # @rbs () -> void
+ def delete
+ ::File.delete(@path) if exists?
+ end
+
+ # @rbs () -> bool
+ def exists?
+ ::File.exist?(@path)
+ end
+ end
+ end
+end
diff --git a/lib/classifier/storage/memory.rb b/lib/classifier/storage/memory.rb
new file mode 100644
index 0000000..f7cc770
--- /dev/null
+++ b/lib/classifier/storage/memory.rb
@@ -0,0 +1,49 @@
+# rbs_inline: enabled
+
+# Author:: Lucas Carlson (mailto:lucas@rufy.com)
+# Copyright:: Copyright (c) 2005 Lucas Carlson
+# License:: LGPL
+
+require_relative 'base'
+
+module Classifier
+ module Storage
+ # In-memory storage for testing and ephemeral use.
+ #
+ # Example:
+ # bayes = Classifier::Bayes.new('Spam', 'Ham')
+ # bayes.storage = Classifier::Storage::Memory.new
+ # bayes.train_spam("Buy now!")
+ # bayes.save
+ #
+ class Memory < Base
+ # @rbs @data: String?
+
+ # @rbs () -> void
+ def initialize
+ super
+ @data = nil
+ end
+
+ # @rbs (String) -> String
+ def write(data)
+ @data = data
+ end
+
+ # @rbs () -> String?
+ def read
+ @data
+ end
+
+ # @rbs () -> void
+ def delete
+ @data = nil
+ end
+
+ # @rbs () -> bool
+ def exists?
+ !@data.nil?
+ end
+ end
+ end
+end
diff --git a/lib/classifier/streaming.rb b/lib/classifier/streaming.rb
new file mode 100644
index 0000000..7238267
--- /dev/null
+++ b/lib/classifier/streaming.rb
@@ -0,0 +1,122 @@
+# rbs_inline: enabled
+
+require_relative 'streaming/progress'
+require_relative 'streaming/line_reader'
+
+module Classifier
+ # Streaming module provides memory-efficient training capabilities for classifiers.
+ # Include this module in a classifier to add streaming and batch training methods.
+ #
+ # @example Including in a classifier
+ # class MyClassifier
+ # include Classifier::Streaming
+ # end
+ #
+ # @example Streaming training
+ # classifier.train_from_stream(:category, File.open('corpus.txt'))
+ #
+ # @example Batch training with progress
+ # classifier.train_batch(:category, documents, batch_size: 100) do |progress|
+ # puts "#{progress.percent}% complete"
+ # end
+ module Streaming
+ # Default batch size for streaming operations
+ DEFAULT_BATCH_SIZE = 100
+
+ # Trains the classifier from an IO stream.
+ # Each line in the stream is treated as a separate document.
+ #
+ # @rbs (?(Symbol | String | nil), ?IO?, ?batch_size: Integer, **IO) { (Progress) -> void } -> void
+ def train_from_stream(category = nil, io = nil, batch_size: DEFAULT_BATCH_SIZE, **categories, &block)
+ raise NotImplementedError, "#{self.class} must implement train_from_stream"
+ end
+
+ # Trains the classifier with an array of documents in batches.
+ # Supports both positional and keyword argument styles.
+ #
+ # @example Positional style
+ # classifier.train_batch(:spam, documents, batch_size: 100)
+ #
+ # @example Keyword style
+ # classifier.train_batch(spam: documents, ham: other_docs, batch_size: 100)
+ #
+ # @rbs (?(Symbol | String)?, ?Array[String]?, ?batch_size: Integer, **Array[String]) { (Progress) -> void } -> void
+ def train_batch(category = nil, documents = nil, batch_size: DEFAULT_BATCH_SIZE, **categories, &block)
+ raise NotImplementedError, "#{self.class} must implement train_batch"
+ end
+
+ # Saves a checkpoint of the current training state.
+ # Requires a storage backend to be configured.
+ #
+ # @rbs (String) -> void
+ def save_checkpoint(checkpoint_id)
+ raise ArgumentError, 'No storage configured' unless respond_to?(:storage) && storage
+
+ original_storage = storage
+
+ begin
+ self.storage = checkpoint_storage_for(checkpoint_id)
+ save
+ ensure
+ self.storage = original_storage
+ end
+ end
+
+ # Lists available checkpoints.
+ # Requires a storage backend to be configured.
+ #
+ # @rbs () -> Array[String]
+ def list_checkpoints
+ raise ArgumentError, 'No storage configured' unless respond_to?(:storage) && storage
+
+ case storage
+ when Storage::File
+ file_storage = storage #: Storage::File
+ dir = File.dirname(file_storage.path)
+ base = File.basename(file_storage.path, '.*')
+ ext = File.extname(file_storage.path)
+
+ pattern = File.join(dir, "#{base}_checkpoint_*#{ext}")
+ Dir.glob(pattern).map do |path|
+ File.basename(path, ext).sub(/^#{Regexp.escape(base)}_checkpoint_/, '')
+ end.sort
+ else
+ []
+ end
+ end
+
+ # Deletes a checkpoint.
+ #
+ # @rbs (String) -> void
+ def delete_checkpoint(checkpoint_id)
+ raise ArgumentError, 'No storage configured' unless respond_to?(:storage) && storage
+
+ checkpoint_storage = checkpoint_storage_for(checkpoint_id)
+ checkpoint_storage.delete if checkpoint_storage.exists?
+ end
+
+ private
+
+ # @rbs (String) -> String
+ def checkpoint_path_for(checkpoint_id)
+ raise ArgumentError, 'Storage must be File storage for checkpoints' unless storage.is_a?(Storage::File)
+
+ file_storage = storage #: Storage::File
+ dir = File.dirname(file_storage.path)
+ base = File.basename(file_storage.path, '.*')
+ ext = File.extname(file_storage.path)
+
+ File.join(dir, "#{base}_checkpoint_#{checkpoint_id}#{ext}")
+ end
+
+ # @rbs (String) -> Storage::Base
+ def checkpoint_storage_for(checkpoint_id)
+ case storage
+ when Storage::File
+ Storage::File.new(path: checkpoint_path_for(checkpoint_id))
+ else
+ raise ArgumentError, "Checkpoints not supported for #{storage.class}"
+ end
+ end
+ end
+end
diff --git a/lib/classifier/streaming/line_reader.rb b/lib/classifier/streaming/line_reader.rb
new file mode 100644
index 0000000..e0b92f3
--- /dev/null
+++ b/lib/classifier/streaming/line_reader.rb
@@ -0,0 +1,99 @@
+# rbs_inline: enabled
+
+module Classifier
+ module Streaming
+ # Memory-efficient line reader for large files and IO streams.
+ # Reads lines one at a time and can yield in configurable batches.
+ #
+ # @example Reading line by line
+ # reader = LineReader.new(File.open('large_corpus.txt'))
+ # reader.each { |line| process(line) }
+ #
+ # @example Reading in batches
+ # reader = LineReader.new(io, batch_size: 100)
+ # reader.each_batch { |batch| process_batch(batch) }
+ class LineReader
+ include Enumerable #[String]
+
+ # @rbs @io: IO
+ # @rbs @batch_size: Integer
+
+ attr_reader :batch_size
+
+ # Creates a new LineReader.
+ #
+ # @rbs (IO, ?batch_size: Integer) -> void
+ def initialize(io, batch_size: 100)
+ @io = io
+ @batch_size = batch_size
+ end
+
+ # Iterates over each line in the IO stream.
+ # Lines are chomped (trailing newlines removed).
+ #
+ # @rbs () { (String) -> void } -> void
+ # @rbs () -> Enumerator[String, void]
+ def each
+ return enum_for(:each) unless block_given?
+
+ @io.each_line do |line|
+ yield line.chomp
+ end
+ end
+
+ # Iterates over batches of lines.
+ # Each batch is an array of chomped lines.
+ #
+ # @rbs () { (Array[String]) -> void } -> void
+ # @rbs () -> Enumerator[Array[String], void]
+ def each_batch
+ return enum_for(:each_batch) unless block_given?
+
+ batch = [] #: Array[String]
+ each do |line|
+ batch << line
+ if batch.size >= @batch_size
+ yield batch
+ batch = []
+ end
+ end
+ yield batch unless batch.empty?
+ end
+
+ # Estimates the total number of lines in the IO stream.
+ # This is a rough estimate based on file size and average line length.
+ # Returns nil for non-seekable streams.
+ #
+ # @rbs (?sample_size: Integer) -> Integer?
+ def estimate_line_count(sample_size: 100)
+ return nil unless @io.respond_to?(:size) && @io.respond_to?(:rewind)
+
+ begin
+ original_pos = @io.pos
+ @io.rewind
+
+ sample_bytes = 0
+ sample_lines = 0
+
+ sample_size.times do
+ line = @io.gets
+ break unless line
+
+ sample_bytes += line.bytesize
+ sample_lines += 1
+ end
+
+ @io.seek(original_pos)
+
+ return nil if sample_lines.zero?
+
+ avg_line_size = sample_bytes.to_f / sample_lines
+ io_size = @io.__send__(:size) #: Integer
+ (io_size / avg_line_size).round
+ rescue IOError, Errno::ESPIPE
+ nil
+ end
+ end
+ end
+ end
+end
diff --git a/lib/classifier/streaming/progress.rb b/lib/classifier/streaming/progress.rb
new file mode 100644
index 0000000..d747674
--- /dev/null
+++ b/lib/classifier/streaming/progress.rb
@@ -0,0 +1,96 @@
+# rbs_inline: enabled
+
+module Classifier
+ module Streaming
+ # Progress tracking object yielded to blocks during batch/stream operations.
+ # Provides information about training progress including completion percentage,
+ # elapsed time, processing rate, and estimated time remaining.
+ #
+ # @example Basic usage with train_batch
+ # classifier.train_batch(:spam, documents, batch_size: 100) do |progress|
+ # puts "#{progress.completed}/#{progress.total} (#{progress.percent}%)"
+ # puts "Rate: #{progress.rate.round(1)} docs/sec"
+ # puts "ETA: #{progress.eta&.round}s" if progress.eta
+ # end
+ class Progress
+ # @rbs @completed: Integer
+ # @rbs @total: Integer?
+ # @rbs @start_time: Time
+ # @rbs @current_batch: Integer
+
+ attr_reader :start_time, :total
+ attr_accessor :completed, :current_batch
+
+ # @rbs (?total: Integer?, ?completed: Integer) -> void
+ def initialize(total: nil, completed: 0)
+ @completed = completed
+ @total = total
+ @start_time = Time.now
+ @current_batch = 0
+ end
+
+ # Returns the completion percentage (0-100).
+ # Returns nil if total is unknown.
+ #
+ # @rbs () -> Float?
+ def percent
+ return nil unless @total&.positive?
+
+ (@completed.to_f / @total * 100).round(2)
+ end
+
+ # Returns the elapsed time in seconds since the operation started.
+ #
+ # @rbs () -> Float
+ def elapsed
+ Time.now - @start_time
+ end
+
+ # Returns the processing rate in items per second.
+ # Returns 0 if no time has elapsed.
+ #
+ # @rbs () -> Float
+ def rate
+ e = elapsed
+ return 0.0 if e.zero?
+
+ @completed / e
+ end
+
+ # Returns the estimated time remaining in seconds.
+ # Returns nil if total is unknown or rate is zero.
+ #
+ # @rbs () -> Float?
+ def eta
+ return nil unless @total
+ return nil if rate.zero?
+ return 0.0 if @completed >= @total
+
+ (@total - @completed) / rate
+ end
+
+ # Returns true if the operation is complete.
+ #
+ # @rbs () -> bool
+ def complete?
+ return false unless @total
+
+ @completed >= @total
+ end
+
+ # Returns a hash representation of the progress state.
+ #
+ # @rbs () -> Hash[Symbol, untyped]
+ def to_h
+ {
+ completed: @completed,
+ total: @total,
+ percent: percent,
+ elapsed: elapsed.round(2),
+ rate: rate.round(2),
+ eta: eta&.round(2)
+ }
+ end
+ end
+ end
+end
diff --git a/lib/classifier/tfidf.rb b/lib/classifier/tfidf.rb
new file mode 100644
index 0000000..20a65e1
--- /dev/null
+++ b/lib/classifier/tfidf.rb
@@ -0,0 +1,416 @@
+# rbs_inline: enabled
+
+# Author:: Lucas Carlson (mailto:lucas@rufy.com)
+# Copyright:: Copyright (c) 2024 Lucas Carlson
+# License:: LGPL
+
+require 'json'
+
+module Classifier
+ # TF-IDF vectorizer: transforms text to weighted feature vectors.
+ # Downweights common words, upweights discriminative terms.
+ #
+ # Example:
+ # tfidf = Classifier::TFIDF.new
+ # tfidf.fit(["Dogs are great pets", "Cats are independent"])
+ # tfidf.transform("Dogs are loyal") # => {:dog=>0.7071..., :loyal=>0.7071...}
+ #
+ class TFIDF
+ include Streaming
+
+ # @rbs @min_df: Integer | Float
+ # @rbs @max_df: Integer | Float
+ # @rbs @ngram_range: Array[Integer]
+ # @rbs @sublinear_tf: bool
+ # @rbs @vocabulary: Hash[Symbol, Integer]
+ # @rbs @idf: Hash[Symbol, Float]
+ # @rbs @num_documents: Integer
+ # @rbs @fitted: bool
+ # @rbs @dirty: bool
+ # @rbs @storage: Storage::Base?
+ # @rbs @min_word_length: Integer
+
+ attr_reader :vocabulary, :idf, :num_documents
+ attr_accessor :storage
+
+ # Creates a new TF-IDF vectorizer.
+ # - min_df/max_df: filter terms by document frequency (Integer for count, Float for proportion)
+ # - ngram_range: [1,1] for unigrams, [1,2] for unigrams+bigrams
+ # - sublinear_tf: use 1 + log(tf) instead of raw term frequency
+ # - min_word_length: minimum word length filter in tokenization
+ #
+ # @rbs (?min_df: Integer | Float, ?max_df: Integer | Float,
+ # ?ngram_range: Array[Integer], ?sublinear_tf: bool, ?min_word_length: Integer) -> void
+ def initialize(min_df: 1, max_df: 1.0, ngram_range: [1, 1], sublinear_tf: false,
+ min_word_length: Classifier.config.min_word_length)
+ validate_df!(min_df, 'min_df')
+ validate_df!(max_df, 'max_df')
+ validate_ngram_range!(ngram_range)
+
+ @min_df = min_df
+ @max_df = max_df
+ @ngram_range = ngram_range
+ @sublinear_tf = sublinear_tf
+ @vocabulary = {}
+ @idf = {}
+ @num_documents = 0
+ @fitted = false
+ @dirty = false
+ @storage = nil
+ @min_word_length = min_word_length
+ end
+
+ # Learns vocabulary and IDF weights from the corpus.
+ # @rbs (Array[String]) -> self
+ def fit(documents)
+ raise ArgumentError, 'documents must be an array' unless documents.is_a?(Array)
+ raise ArgumentError, 'documents cannot be empty' if documents.empty?
+
+ @num_documents = documents.size
+ document_frequencies = Hash.new(0)
+
+ documents.each do |doc|
+ terms = extract_terms(doc)
+ terms.each_key { |term| document_frequencies[term] += 1 }
+ end
+
+ @vocabulary = {}
+ @idf = {}
+ vocab_index = 0
+
+ document_frequencies.each do |term, df|
+ next unless within_df_bounds?(df, @num_documents)
+
+ @vocabulary[term] = vocab_index
+ vocab_index += 1
+
+ # IDF: log((N + 1) / (df + 1)) + 1
+ @idf[term] = Math.log((@num_documents + 1).to_f / (df + 1)) + 1
+ end
+
+ @fitted = true
+ @dirty = true
+ self
+ end
+
+ # Transforms a document into a normalized TF-IDF vector.
+ # @rbs (String) -> Hash[Symbol, Float]
+ def transform(document)
+ raise NotFittedError, 'TFIDF has not been fitted. Call fit first.' unless @fitted
+
+ terms = extract_terms(document)
+ result = {} #: Hash[Symbol, Float]
+
+ terms.each do |term, tf|
+ next unless @vocabulary.key?(term)
+
+ tf_value = @sublinear_tf && tf.positive? ? 1 + Math.log(tf) : tf.to_f
+ result[term] = (tf_value * @idf[term]).to_f
+ end
+
+ normalize_vector(result)
+ end
+
+ # Fits and transforms in one step.
+ # @rbs (Array[String]) -> Array[Hash[Symbol, Float]]
+ def fit_transform(documents)
+ fit(documents)
+ documents.map { |doc| transform(doc) }
+ end
+
+ # Returns vocabulary terms in index order.
+ # @rbs () -> Array[Symbol]
+ def feature_names
+ @vocabulary.keys.sort_by { |term| @vocabulary[term] }
+ end
+
+ # @rbs () -> bool
+ def fitted?
+ @fitted
+ end
+
+ # Returns true if there are unsaved changes.
+ # @rbs () -> bool
+ def dirty?
+ @dirty
+ end
+
+ # Saves the vectorizer to the configured storage.
+ # @rbs () -> void
+ def save
+ raise ArgumentError, 'No storage configured' unless storage
+
+ storage.write(to_json)
+ @dirty = false
+ end
+
+ # Saves the vectorizer state to a file.
+ # @rbs (String) -> Integer
+ def save_to_file(path)
+ result = File.write(path, to_json)
+ @dirty = false
+ result
+ end
+
+ # Loads a vectorizer from the configured storage.
+ # @rbs (storage: Storage::Base) -> TFIDF
+ def self.load(storage:)
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ instance = from_json(data)
+ instance.storage = storage
+ instance
+ end
+
+ # Loads a vectorizer from a file.
+ # @rbs (String) -> TFIDF
+ def self.load_from_file(path)
+ from_json(File.read(path))
+ end
+
+ # Reloads the vectorizer from storage, raising if there are unsaved changes.
+ # @rbs () -> self
+ def reload
+ raise ArgumentError, 'No storage configured' unless storage
+ raise UnsavedChangesError, 'Unsaved changes would be lost. Call save first or use reload!' if @dirty
+
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ restore_from_json(data)
+ @dirty = false
+ self
+ end
+
+ # Force reloads the vectorizer from storage, discarding any unsaved changes.
+ # @rbs () -> self
+ def reload!
+ raise ArgumentError, 'No storage configured' unless storage
+
+ data = storage.read
+ raise StorageError, 'No saved state found' unless data
+
+ restore_from_json(data)
+ @dirty = false
+ self
+ end
+
+ # @rbs (?untyped) -> Hash[Symbol, untyped]
+ def as_json(_options = nil)
+ {
+ version: 1,
+ type: 'tfidf',
+ min_df: @min_df,
+ max_df: @max_df,
+ ngram_range: @ngram_range,
+ sublinear_tf: @sublinear_tf,
+ vocabulary: @vocabulary,
+ idf: @idf,
+ num_documents: @num_documents,
+ fitted: @fitted,
+ min_word_length: @min_word_length
+ }
+ end
+
+ # @rbs (?untyped) -> String
+ def to_json(_options = nil)
+ JSON.generate(as_json)
+ end
+
+ # Loads a vectorizer from JSON.
+ # @rbs (String | Hash[String, untyped]) -> TFIDF
+ def self.from_json(json)
+ data = json.is_a?(String) ? JSON.parse(json) : json
+ raise ArgumentError, "Invalid vectorizer type: #{data['type']}" unless data['type'] == 'tfidf'
+
+ instance = new(
+ min_df: data['min_df'],
+ max_df: data['max_df'],
+ ngram_range: data['ngram_range'],
+ sublinear_tf: data['sublinear_tf'],
+ min_word_length: data['min_word_length'] || Classifier.config.min_word_length
+ )
+
+ instance.instance_variable_set(:@vocabulary, symbolize_keys(data['vocabulary']))
+ instance.instance_variable_set(:@idf, symbolize_keys(data['idf']))
+ instance.instance_variable_set(:@num_documents, data['num_documents'])
+ instance.instance_variable_set(:@fitted, data['fitted'])
+ instance.instance_variable_set(:@dirty, false)
+ instance.instance_variable_set(:@storage, nil)
+
+ instance
+ end
+
+ # @rbs () -> Array[untyped]
+ def marshal_dump
+ [@min_df, @max_df, @ngram_range, @sublinear_tf, @vocabulary, @idf, @num_documents, @fitted,
+ @min_word_length]
+ end
+
+ # @rbs (Array[untyped]) -> void
+ def marshal_load(data)
+ @min_df, @max_df, @ngram_range, @sublinear_tf, @vocabulary, @idf, @num_documents, @fitted,
+ @min_word_length = data
+ @dirty = false
+ @storage = nil
+ end
+
+ # Loads a vectorizer from a checkpoint.
+ #
+ # @rbs (storage: Storage::Base, checkpoint_id: String) -> TFIDF
+ def self.load_checkpoint(storage:, checkpoint_id:)
+ raise ArgumentError, 'Storage must be File storage for checkpoints' unless storage.is_a?(Storage::File)
+
+ dir = File.dirname(storage.path)
+ base = File.basename(storage.path, '.*')
+ ext = File.extname(storage.path)
+ checkpoint_path = File.join(dir, "#{base}_checkpoint_#{checkpoint_id}#{ext}")
+
+ checkpoint_storage = Storage::File.new(path: checkpoint_path)
+ instance = load(storage: checkpoint_storage)
+ instance.storage = storage
+ instance
+ end
+
+ # Fits the vectorizer from an IO stream.
+ # Collects all documents from the stream, then fits the model.
+ # Note: All documents must be collected in memory for IDF calculation.
+ #
+ # @example Fit from a file
+ # tfidf.fit_from_stream(File.open('corpus.txt'))
+ #
+ # @example With progress tracking
+ # tfidf.fit_from_stream(io, batch_size: 500) do |progress|
+ # puts "#{progress.completed} documents loaded"
+ # end
+ #
+ # @rbs (IO, ?batch_size: Integer) { (Streaming::Progress) -> void } -> self
+ def fit_from_stream(io, batch_size: Streaming::DEFAULT_BATCH_SIZE)
+ reader = Streaming::LineReader.new(io, batch_size: batch_size)
+ total = reader.estimate_line_count
+ progress = Streaming::Progress.new(total: total)
+
+ documents = [] #: Array[String]
+
+ reader.each_batch do |batch|
+ documents.concat(batch)
+ progress.completed += batch.size
+ progress.current_batch += 1
+ yield progress if block_given?
+ end
+
+ fit(documents) unless documents.empty?
+ self
+ end
+
+ # TFIDF doesn't support train_from_stream (use fit_from_stream instead).
+ # This method raises NotImplementedError with guidance.
+ #
+ # @rbs (*untyped, **untyped) -> void
+ def train_from_stream(*) # steep:ignore
+ raise NotImplementedError, 'TFIDF uses fit_from_stream instead of train_from_stream'
+ end
+
+ # TFIDF doesn't support train_batch (use fit instead).
+ # This method raises NotImplementedError with guidance.
+ #
+ # @rbs (*untyped, **untyped) -> void
+ def train_batch(*) # steep:ignore
+ raise NotImplementedError, 'TFIDF uses fit instead of train_batch'
+ end
+
+ private
+
+ # Restores vectorizer state from JSON string.
+ # @rbs (String) -> void
+ def restore_from_json(json)
+ data = JSON.parse(json)
+
+ @min_df = data['min_df']
+ @max_df = data['max_df']
+ @ngram_range = data['ngram_range']
+ @sublinear_tf = data['sublinear_tf']
+ @vocabulary = self.class.send(:symbolize_keys, data['vocabulary'])
+ @idf = self.class.send(:symbolize_keys, data['idf'])
+ @num_documents = data['num_documents']
+ @fitted = data['fitted']
+ end
+
+ # @rbs (String) -> Hash[Symbol, Integer]
+ def extract_terms(document)
+ result = Hash.new(0)
+
+ if @ngram_range[0] <= 1
+ word_hash = document.clean_word_hash(@min_word_length)
+ word_hash.each { |term, count| result[term] += count }
+ end
+
+ return result if @ngram_range[1] <= 1
+
+ tokens = tokenize_for_ngrams(document)
+ (2..@ngram_range[1]).each do |n|
+ next if n < @ngram_range[0]
+
+ generate_ngrams(tokens, n).each { |ngram| result[ngram] += 1 }
+ end
+
+ result
+ end
+
+ # @rbs (String) -> Array[String]
+ def tokenize_for_ngrams(document)
+ document
+ .gsub(/[^\w\s]/, '')
+ .split
+ .map(&:downcase)
+ .reject { |w| w.length <= 2 || String::CORPUS_SKIP_WORDS.include?(w) }
+ .map(&:stem)
+ end
+
+ # @rbs (Array[String], Integer) -> Array[Symbol]
+ def generate_ngrams(tokens, n) # rubocop:disable Naming/MethodParameterName
+ return [] if tokens.size < n
+
+ tokens.each_cons(n).map { |gram| gram.join('_').intern }
+ end
+
+ # @rbs (Integer, Integer) -> bool
+ def within_df_bounds?(doc_freq, num_docs)
+ doc_freq.between?(
+ @min_df.is_a?(Float) ? (@min_df * num_docs).ceil : @min_df,
+ @max_df.is_a?(Float) ? (@max_df * num_docs).floor : @max_df
+ )
+ end
+
+ # @rbs (Hash[Symbol, Float]) -> Hash[Symbol, Float]
+ def normalize_vector(vector)
+ return vector if vector.empty?
+
+ magnitude = Math.sqrt(vector.values.sum { |v| v * v })
+ return vector if magnitude.zero?
+
+ vector.transform_values { |v| v / magnitude }
+ end
+
+ # @rbs (Integer | Float, String) -> void
+ def validate_df!(value, name)
+ raise ArgumentError, "#{name} must be an Integer or Float" unless value.is_a?(Float) || value.is_a?(Integer)
+ raise ArgumentError, "#{name} must be between 0.0 and 1.0" if value.is_a?(Float) && !value.between?(0.0, 1.0)
+ raise ArgumentError, "#{name} must be non-negative" if value.is_a?(Integer) && value.negative?
+ end
+
+ # @rbs (Array[Integer]) -> void
+ def validate_ngram_range!(range)
+ raise ArgumentError, 'ngram_range must be an array of two integers' unless range.is_a?(Array) && range.size == 2
+ raise ArgumentError, 'ngram_range values must be positive integers' unless range.all?(Integer) && range.all?(&:positive?)
+ raise ArgumentError, 'ngram_range[0] must be <= ngram_range[1]' if range[0] > range[1]
+ end
+
+ # @rbs (Hash[String, untyped]) -> Hash[Symbol, untyped]
+ def self.symbolize_keys(hash)
+ hash.transform_keys(&:to_sym)
+ end
+ private_class_method :symbolize_keys
+ end
+end
diff --git a/lib/classifier/version.rb b/lib/classifier/version.rb
new file mode 100644
index 0000000..861b19f
--- /dev/null
+++ b/lib/classifier/version.rb
@@ -0,0 +1,3 @@
+module Classifier
+ VERSION = '2.6.0'.freeze
+end
diff --git a/metadata.yml b/metadata.yml
deleted file mode 100644
index aba63e7..0000000
--- a/metadata.yml
+++ /dev/null
@@ -1,79 +0,0 @@
---- !ruby/object:Gem::Specification
-name: classifier
-version: !ruby/object:Gem::Version
- version: 1.3.4
-platform: ruby
-authors:
-- Lucas Carlson
-autorequire: classifier
-bindir: bin
-cert_chain: []
-date: 2013-12-31 00:00:00.000000000 Z
-dependencies:
-- !ruby/object:Gem::Dependency
- name: fast-stemmer
- requirement: !ruby/object:Gem::Requirement
- requirements:
- - - '>='
- - !ruby/object:Gem::Version
- version: 1.0.0
- type: :runtime
- prerelease: false
- version_requirements: !ruby/object:Gem::Requirement
- requirements:
- - - '>='
- - !ruby/object:Gem::Version
- version: 1.0.0
-description: |2
- A general classifier module to allow Bayesian and other types of classifications.
-email: lucas@rufy.com
-executables: []
-extensions: []
-extra_rdoc_files: []
-files:
-- lib/classifier.rb
-- lib/classifier/bayes.rb
-- lib/classifier/extensions/string.rb
-- lib/classifier/extensions/vector.rb
-- lib/classifier/extensions/vector_serialize.rb
-- lib/classifier/extensions/word_hash.rb
-- lib/classifier/lsi.rb
-- lib/classifier/lsi/content_node.rb
-- lib/classifier/lsi/summary.rb
-- lib/classifier/lsi/word_list.rb
-- bin/bayes.rb
-- bin/summarize.rb
-- test/bayes/bayesian_test.rb
-- test/extensions/word_hash_test.rb
-- test/lsi/lsi_test.rb
-- test/test_helper.rb
-- Gemfile
-- Gemfile.lock
-- LICENSE
-- README.markdown
-- Rakefile
-homepage: http://classifier.rufy.com/
-licenses: []
-metadata: {}
-post_install_message:
-rdoc_options: []
-require_paths:
-- lib
-required_ruby_version: !ruby/object:Gem::Requirement
- requirements:
- - - '>='
- - !ruby/object:Gem::Version
- version: '0'
-required_rubygems_version: !ruby/object:Gem::Requirement
- requirements:
- - - '>='
- - !ruby/object:Gem::Version
- version: '0'
-requirements:
-- A porter-stemmer module to split word stems.
-rubyforge_project:
-rubygems_version: 2.0.3
-signing_key:
-specification_version: 4
-summary: A general classifier module to allow Bayesian and other types of classifications.
-test_files: []
diff --git a/sig/classifier.rbs b/sig/classifier.rbs
new file mode 100644
index 0000000..a8946ce
--- /dev/null
+++ b/sig/classifier.rbs
@@ -0,0 +1,3 @@
+module Classifier
+ VERSION: String
+end
diff --git a/sig/vendor/fast_stemmer.rbs b/sig/vendor/fast_stemmer.rbs
new file mode 100644
index 0000000..4f0cb9c
--- /dev/null
+++ b/sig/vendor/fast_stemmer.rbs
@@ -0,0 +1,9 @@
+# Type stubs for fast-stemmer gem and classifier extensions
+class String
+ def stem: () -> String
+ def prepare_category_name: () -> Symbol
+end
+
+class Symbol
+ def prepare_category_name: () -> Symbol
+end
diff --git a/sig/vendor/gsl.rbs b/sig/vendor/gsl.rbs
new file mode 100644
index 0000000..c67eddd
--- /dev/null
+++ b/sig/vendor/gsl.rbs
@@ -0,0 +1,27 @@
+# Type stubs for optional GSL gem
+module GSL
+ class Vector
+ def self.alloc: (untyped) -> Vector
+ def to_a: () -> Array[Float]
+ def normalize: () -> Vector
+ def sum: () -> Float
+ def each: () { (Float) -> void } -> void
+ def []: (Integer) -> Float
+ def []=: (Integer, Float) -> Float
+ def size: () -> Integer
+ def row: () -> Vector
+ def col: () -> Vector
+ def *: (untyped) -> untyped
+ def collect: () { (Float) -> Float } -> Vector
+ end
+
+ class Matrix
+ def self.alloc: (*untyped) -> Matrix
+ def self.diag: (untyped) -> Matrix
+ def trans: () -> Matrix
+ def *: (untyped) -> Matrix
+ def size: () -> [Integer, Integer]
+ def column: (Integer) -> Vector
+ def SV_decomp: () -> [Matrix, Matrix, Vector]
+ end
+end
diff --git a/sig/vendor/json.rbs b/sig/vendor/json.rbs
new file mode 100644
index 0000000..44202a4
--- /dev/null
+++ b/sig/vendor/json.rbs
@@ -0,0 +1,5 @@
+module JSON
+ def self.parse: (String source, ?symbolize_names: bool) -> untyped
+ def self.generate: (untyped obj) -> String
+ def self.pretty_generate: (untyped obj) -> String
+end
diff --git a/sig/vendor/matrix.rbs b/sig/vendor/matrix.rbs
new file mode 100644
index 0000000..0ec2dab
--- /dev/null
+++ b/sig/vendor/matrix.rbs
@@ -0,0 +1,37 @@
+# Type stubs for matrix gem
+# Using untyped elements since our usage is primarily with Floats/Numerics
+class Vector
+ EPSILON: Float
+
+ def self.[]: (*untyped) -> Vector
+ def size: () -> Integer
+ def []: (Integer) -> untyped
+ def magnitude: () -> Float
+ def normalize: () -> Vector
+ def each: () { (untyped) -> void } -> void
+ def collect: () { (untyped) -> untyped } -> Vector
+ def to_a: () -> Array[untyped]
+ def *: (untyped) -> untyped
+ def -: (Vector) -> Vector
+ def is_a?: (untyped) -> bool
+end
+
+class Matrix
+ def self.rows: (Array[Array[untyped]]) -> Matrix
+ def self.[]: (*Array[untyped]) -> Matrix
+ def self.diag: (untyped) -> Matrix
+ def self.columns: (Array[Array[untyped]]) -> Matrix
+ def self.empty: (Integer, Integer) -> Matrix
+ def self.zero: (Integer, Integer) -> Matrix
+ def self.vstack: (Matrix, Matrix) -> Matrix
+ def trans: () -> Matrix
+ def transpose: () -> Matrix
+ def *: (untyped) -> untyped
+ def row_size: () -> Integer
+ def column_size: () -> Integer
+ def row: (Integer) -> Vector
+ def column: (Integer) -> Vector
+ def SV_decomp: () -> [Matrix, Matrix, untyped]
+ def is_a?: (untyped) -> bool
+ def respond_to?: (Symbol) -> bool
+end
diff --git a/sig/vendor/mutex_m.rbs b/sig/vendor/mutex_m.rbs
new file mode 100644
index 0000000..e3aa713
--- /dev/null
+++ b/sig/vendor/mutex_m.rbs
@@ -0,0 +1,16 @@
+# Type stubs for mutex_m gem
+module Mutex_m
+ def mu_initialize: () -> void
+ def mu_lock: () -> void
+ def mu_unlock: () -> void
+ def mu_synchronize: [T] () { () -> T } -> T
+ def mu_try_lock: () -> bool
+ def mu_locked?: () -> bool
+
+ # Aliases
+ alias lock mu_lock
+ alias unlock mu_unlock
+ alias synchronize mu_synchronize
+ alias try_lock mu_try_lock
+ alias locked? mu_locked?
+end
diff --git a/sig/vendor/optparse.rbs b/sig/vendor/optparse.rbs
new file mode 100644
index 0000000..b6a0dae
--- /dev/null
+++ b/sig/vendor/optparse.rbs
@@ -0,0 +1,19 @@
+# Minimal type definitions for optparse stdlib
+
+class OptionParser
+ class InvalidOption < StandardError
+ end
+
+ class MissingArgument < StandardError
+ end
+
+ class InvalidArgument < StandardError
+ end
+
+ def initialize: () { (OptionParser) -> void } -> void
+ def banner=: (String) -> String
+ def separator: (String) -> void
+ def on: (*untyped) ?{ (*untyped) -> untyped } -> void
+ def to_s: () -> String
+ def parse!: (Array[String]) -> Array[String]
+end
diff --git a/sig/vendor/streaming.rbs b/sig/vendor/streaming.rbs
new file mode 100644
index 0000000..ee42c16
--- /dev/null
+++ b/sig/vendor/streaming.rbs
@@ -0,0 +1,14 @@
+# Type stubs for Streaming module
+# Defines the interface that including classes must implement
+
+module Classifier
+ # Interface for classes that include Streaming
+ interface _StreamingHost
+ def storage: () -> Storage::Base?
+ def storage=: (Storage::Base?) -> void
+ def save: () -> void
+ end
+
+ module Streaming : _StreamingHost
+ end
+end
diff --git a/skills/classifier/SKILL.md b/skills/classifier/SKILL.md
new file mode 100644
index 0000000..496d96d
--- /dev/null
+++ b/skills/classifier/SKILL.md
@@ -0,0 +1,67 @@
+# Text Classification with Classifier
+
+Use when: User asks to classify text, detect spam, analyze sentiment, detect emotions, or use pre-trained ML models.
+
+## Pre-trained Models
+
+Run `classifier models` to see all available models. Common ones:
+
+| Model | Command | Use Case |
+|-------|---------|----------|
+| `sms-spam-filter` | `classifier -r sms-spam-filter "text"` | Spam detection |
+| `imdb-sentiment` | `classifier -r imdb-sentiment "text"` | Sentiment analysis |
+| `emotion-detection` | `classifier -r emotion-detection "text"` | Emotion classification |
+
+## Quick Classification
+
+```bash
+# Classify with a pre-trained model
+classifier -r <model-name> "text to classify"
+
+# Example: detect spam
+classifier -r sms-spam-filter "You won a free iPhone! Click here now!"
+
+# Example: sentiment analysis
+classifier -r imdb-sentiment "This movie was absolutely terrible"
+
+# Example: emotion detection
+classifier -r emotion-detection "I am so happy today"
+```
+
+## Custom Training
+
+```bash
+# Train from text
+classifier train positive "Great product, love it"
+classifier train negative "Terrible quality, waste of money"
+
+# Train from files
+classifier train positive reviews/good/*.txt
+classifier train negative reviews/bad/*.txt
+
+# Classify after training
+classifier "This product exceeded my expectations"
+```
+
+## Model Management
+
+```bash
+# List all available models
+classifier models
+
+# Show model details
+classifier info <model-name>
+
+# Save trained model
+classifier save my-model.json
+
+# Load saved model
+classifier load my-model.json
+```
+
+## Best Practices
+
+1. For quick classification tasks, use pre-trained models first
+2. For custom domains, train with representative examples from each category
+3. Use `classifier models` to discover available pre-trained models
+4. Balance training data across categories for best results
diff --git a/test/bayes/bayesian_test.rb b/test/bayes/bayesian_test.rb
index 900ca19..f59d1f6 100644
--- a/test/bayes/bayesian_test.rb
+++ b/test/bayes/bayesian_test.rb
@@ -1,33 +1,637 @@
-require File.dirname(__FILE__) + '/../test_helper'
-class BayesianTest < Test::Unit::TestCase
- def setup
- @classifier = Classifier::Bayes.new 'Interesting', 'Uninteresting'
- end
-
- def test_good_training
- assert_nothing_raised { @classifier.train_interesting "love" }
- end
-
- def test_bad_training
- assert_raise(StandardError) { @classifier.train_no_category "words" }
- end
-
- def test_bad_method
- assert_raise(NoMethodError) { @classifier.forget_everything_you_know "" }
- end
-
- def test_categories
- assert_equal ['Interesting', 'Uninteresting'].sort, @classifier.categories.sort
- end
-
- def test_add_category
- @classifier.add_category 'Test'
- assert_equal ['Test', 'Interesting', 'Uninteresting'].sort, @classifier.categories.sort
- end
-
- def test_classification
- @classifier.train_interesting "here are some good words. I hope you love them"
- @classifier.train_uninteresting "here are some bad words, I hate you"
- assert_equal 'Uninteresting', @classifier.classify("I hate bad words and you")
- end
-end \ No newline at end of file
+require_relative '../test_helper'
+
+class BayesianTest < Minitest::Test
+ def setup
+ @classifier = Classifier::Bayes.new 'Interesting', 'Uninteresting'
+ end
+
+ def test_bad_training
+ assert_raises(StandardError) { @classifier.train_no_category 'words' }
+ end
+
+ def test_bad_method
+ assert_raises(NoMethodError) { @classifier.forget_everything_you_know '' }
+ end
+
+ def test_categories
+ assert_equal %w[Interesting Uninteresting].sort, @classifier.categories.sort
+ end
+
+ def test_array_initialization
+ classifier = Classifier::Bayes.new(%w[Spam Ham])
+
+ assert_equal %w[Ham Spam], classifier.categories.sort
+
+ classifier.train_spam 'bad nasty spam email'
+ classifier.train_ham 'good legitimate email'
+
+ assert_equal 'Spam', classifier.classify('this is spam')
+ end
+
+ def test_initialization_with_min_word_length
+ classifier = Classifier::Bayes.new(%w[Spam Ham], min_word_length: 5)
+
+ classifier.train_spam 'bad nasty spam email'
+ classifier.train_ham 'good legitimate email'
+
+ assert_equal 'Spam', classifier.classify('nasty text')
+ assert_equal 'Ham', classifier.classify('legitimate text')
+ assert_equal 'Spam', classifier.classify('good text')
+ end
+
+ def test_add_category
+ @classifier.add_category 'Test'
+
+ assert_equal %w[Test Interesting Uninteresting].sort, @classifier.categories.sort
+ end
+
+ def test_classification
+ @classifier.train_interesting 'here are some good words. I hope you love them'
+ @classifier.train_uninteresting 'here are some bad words, I hate you'
+
+ assert_equal 'Uninteresting', @classifier.classify('I hate bad words and you')
+ end
+
+ def test_safari_animals
+ bayes = Classifier::Bayes.new 'Lion', 'Elephant'
+ bayes.train_lion 'lion'
+ bayes.train_lion 'zebra'
+ bayes.train_elephant 'elephant'
+ bayes.train_elephant 'trunk'
+ bayes.train_elephant 'tusk'
+
+ assert_equal 'Lion', bayes.classify('zebra')
+ assert_equal 'Elephant', bayes.classify('trunk')
+ assert_equal 'Elephant', bayes.classify('tusk')
+ assert_equal 'Lion', bayes.classify('lion')
+ assert_equal 'Elephant', bayes.classify('elephant')
+ end
+
+ def test_remove_category
+ @classifier.train_interesting 'This is interesting content'
+ @classifier.train_uninteresting 'This is uninteresting content'
+
+ assert_equal %w[Interesting Uninteresting].sort, @classifier.categories.sort
+
+ @classifier.remove_category 'Uninteresting'
+
+ assert_equal ['Interesting'], @classifier.categories
+ end
+
+ def test_remove_category_affects_classification
+ @classifier.train_interesting 'This is interesting content'
+ @classifier.train_uninteresting 'This is uninteresting content'
+
+ assert_equal 'Uninteresting', @classifier.classify('This is uninteresting')
+
+ @classifier.remove_category 'Uninteresting'
+
+ assert_equal 'Interesting', @classifier.classify('This is uninteresting')
+ end
+
+ def test_remove_all_categories
+ @classifier.remove_category 'Interesting'
+ @classifier.remove_category 'Uninteresting'
+
+ assert_empty @classifier.categories
+ end
+
+ def test_remove_and_add_category
+ @classifier.remove_category 'Uninteresting'
+ @classifier.add_category 'Neutral'
+
+ assert_equal %w[Interesting Neutral].sort, @classifier.categories.sort
+ end
+
+ def test_remove_category_preserves_other_category_data
+ @classifier.train_interesting 'This is interesting content'
+ @classifier.train_uninteresting 'This is uninteresting content'
+
+ interesting_classification = @classifier.classify('This is interesting')
+ @classifier.remove_category 'Uninteresting'
+
+ assert_equal interesting_classification, @classifier.classify('This is interesting')
+ end
+
+ def test_remove_category_check_counts
+ initial_total_words = @classifier.instance_variable_get(:@total_words)
+ category_word_count = @classifier.instance_variable_get(:@category_word_count)['Interesting']
+
+ @classifier.remove_category('Interesting')
+
+ assert_nil @classifier.instance_variable_get(:@categories)['Interesting']
+ assert_equal 0, @classifier.instance_variable_get(:@category_counts)['Interesting']
+ assert_equal 0, @classifier.instance_variable_get(:@category_word_count)['Interesting']
+
+ new_total_words = @classifier.instance_variable_get(:@total_words)
+
+ assert_equal initial_total_words - category_word_count, new_total_words
+ end
+
+ def test_remove_category_updates_total_words_before_deletion
+ initial_total_words = @classifier.instance_variable_get(:@total_words)
+ category_word_count = @classifier.instance_variable_get(:@category_word_count)['Interesting']
+
+ @classifier.remove_category('Interesting')
+
+ new_total_words = @classifier.instance_variable_get(:@total_words)
+
+ assert_equal initial_total_words - category_word_count, new_total_words
+ end
+
+ def test_remove_nonexistent_category
+ assert_raises(StandardError, 'No such category: Nonexistent Category') do
+ @classifier.remove_category('Nonexistent Category')
+ end
+ end
+
+ # Untrain tests
+
+ def test_untrain_basic
+ @classifier.train_interesting 'good words'
+ initial_total = @classifier.instance_variable_get(:@total_words)
+
+ @classifier.untrain_interesting 'good words'
+
+ new_total = @classifier.instance_variable_get(:@total_words)
+
+ assert_operator new_total, :<, initial_total, 'Total words should decrease after untrain'
+ end
+
+ def test_untrain_with_train_method
+ @classifier.train :interesting, 'hello world'
+ @classifier.untrain :interesting, 'hello world'
+
+ category_words = @classifier.instance_variable_get(:@categories)[:Interesting]
+
+ assert_empty category_words, 'Category should have no words after untraining same text'
+ end
+
+ def test_untrain_dynamic_method
+ @classifier.train_interesting 'dynamic method test'
+ @classifier.untrain_interesting 'dynamic method test'
+
+ category_words = @classifier.instance_variable_get(:@categories)[:Interesting]
+
+ assert_empty category_words, 'Dynamic untrain should remove trained words'
+ end
+
+ def test_untrain_affects_classification
+ # Train both categories with distinct words
+ @classifier.train_interesting 'cats cats cats pets pets great'
+ @classifier.train_uninteresting 'dogs dogs dogs animals bad'
+
+ assert_equal 'Interesting', @classifier.classify('cats pets')
+
+ # Untrain some of the interesting words, but keep category viable
+ @classifier.untrain_interesting 'cats cats pets'
+
+ # Now train more uninteresting with cats
+ @classifier.train_uninteresting 'cats cats cats'
+
+ # Classification should now favor uninteresting for 'cats'
+ assert_equal 'Uninteresting', @classifier.classify('cats')
+ end
+
+ def test_untrain_decrements_category_count
+ @classifier.train_interesting 'first document'
+ @classifier.train_interesting 'second document'
+
+ initial_count = @classifier.instance_variable_get(:@category_counts)[:Interesting]
+
+ assert_equal 2, initial_count
+
+ @classifier.untrain_interesting 'first document'
+
+ new_count = @classifier.instance_variable_get(:@category_counts)[:Interesting]
+
+ assert_equal 1, new_count
+ end
+
+ def test_untrain_removes_word_when_count_zero
+ @classifier.train_interesting 'unique'
+
+ category_words = @classifier.instance_variable_get(:@categories)[:Interesting]
+
+ assert category_words.key?(:uniqu), 'Word should exist after training'
+
+ @classifier.untrain_interesting 'unique'
+
+ refute category_words.key?(:uniqu), 'Word should be removed when count reaches zero'
+ end
+
+ def test_untrain_partial_word_removal
+ @classifier.train_interesting 'apple apple apple'
+ @classifier.untrain_interesting 'apple'
+
+ category_words = @classifier.instance_variable_get(:@categories)[:Interesting]
+
+ assert_equal 2, category_words[:appl], 'Should have 2 remaining after untraining 1'
+ end
+
+ def test_untrain_more_than_trained
+ @classifier.train_interesting 'word'
+ @classifier.untrain_interesting 'word word word word word'
+
+ category_words = @classifier.instance_variable_get(:@categories)[:Interesting]
+
+ refute category_words.key?(:word), 'Word should be deleted, not go negative'
+
+ total_words = @classifier.instance_variable_get(:@total_words)
+
+ assert_operator total_words, :>=, 0, 'Total words should not go negative'
+ end
+
+ def test_untrain_nonexistent_words
+ @classifier.train_interesting 'existing words'
+ initial_total = @classifier.instance_variable_get(:@total_words)
+
+ @classifier.untrain_interesting 'completely different text'
+
+ new_total = @classifier.instance_variable_get(:@total_words)
+
+ assert_operator new_total, :<=, initial_total, 'Should handle non-existent words gracefully'
+ end
+
+ def test_untrain_invalid_category
+ assert_raises(StandardError) { @classifier.untrain_nonexistent 'words' }
+ end
+
+ def test_untrain_decrements_category_word_count
+ @classifier.train_interesting 'hello world testing'
+ initial_word_count = @classifier.instance_variable_get(:@category_word_count)[:Interesting]
+
+ @classifier.untrain_interesting 'hello'
+
+ new_word_count = @classifier.instance_variable_get(:@category_word_count)[:Interesting]
+
+ assert_operator new_word_count, :<, initial_word_count, 'Category word count should decrease'
+ end
+
+ # Keyword argument API tests
+
+ def test_train_with_keyword_argument
+ @classifier.train(interesting: 'good words here')
+ @classifier.train(uninteresting: 'bad words there')
+
+ assert_equal 'Interesting', @classifier.classify('good words')
+ assert_equal 'Uninteresting', @classifier.classify('bad words')
+ end
+
+ def test_train_with_array_value
+ @classifier.train(interesting: ['good words', 'great content', 'love this'])
+ @classifier.train(uninteresting: 'bad stuff')
+
+ assert_equal 'Interesting', @classifier.classify('good great love')
+ end
+
+ def test_train_with_multiple_categories
+ @classifier.train(
+ interesting: ['good words', 'great content'],
+ uninteresting: ['bad words', 'boring stuff']
+ )
+
+ assert_equal 'Interesting', @classifier.classify('good great')
+ assert_equal 'Uninteresting', @classifier.classify('bad boring')
+ end
+
+ def test_untrain_with_keyword_argument
+ @classifier.train(interesting: 'hello world')
+ @classifier.untrain(interesting: 'hello world')
+
+ category_words = @classifier.instance_variable_get(:@categories)[:Interesting]
+
+ assert_empty category_words
+ end
+
+ def test_untrain_with_array_value
+ @classifier.train(interesting: %w[apple banana cherry])
+ @classifier.untrain(interesting: %w[apple banana])
+
+ result = @classifier.classify('cherry')
+
+ assert_equal 'Interesting', result
+ end
+
+ def test_keyword_and_positional_produce_same_result
+ classifier1 = Classifier::Bayes.new 'Spam', 'Ham'
+ classifier1.train :spam, 'buy now'
+ classifier1.train :ham, 'hello friend'
+
+ classifier2 = Classifier::Bayes.new 'Spam', 'Ham'
+ classifier2.train(spam: 'buy now')
+ classifier2.train(ham: 'hello friend')
+
+ assert_equal classifier1.classify('buy'), classifier2.classify('buy')
+ assert_equal classifier1.classify('hello'), classifier2.classify('hello')
+ end
+
+ def test_keyword_and_dynamic_method_produce_same_result
+ classifier1 = Classifier::Bayes.new 'Spam', 'Ham'
+ classifier1.train_spam 'buy now'
+ classifier1.train_ham 'hello friend'
+
+ classifier2 = Classifier::Bayes.new 'Spam', 'Ham'
+ classifier2.train(spam: 'buy now', ham: 'hello friend')
+
+ assert_equal classifier1.classifications('buy'), classifier2.classifications('buy')
+ end
+
+ # Edge case tests
+
+ def test_empty_string_training
+ @classifier.train_interesting ''
+ category_words = @classifier.instance_variable_get(:@categories)[:Interesting]
+
+ assert_empty category_words, 'Empty string should not add any words'
+ end
+
+ def test_empty_string_classification
+ @classifier.train_interesting 'good words here'
+ @classifier.train_uninteresting 'bad words here'
+
+ result = @classifier.classify('')
+
+ assert_includes %w[Interesting Uninteresting], result, 'Should return a category even for empty string'
+ end
+
+ def test_unicode_text_training
+ @classifier.train_interesting '日本語 chinese 中文 korean 한국어'
+ @classifier.train_uninteresting 'plain english text only'
+
+ # Unicode characters are treated as words if long enough
+ category_words = @classifier.instance_variable_get(:@categories)[:Interesting]
+
+ assert_predicate category_words.size, :positive?, 'Should store unicode words'
+ end
+
+ def test_emoji_training
+ @classifier.train_interesting '😀 happy 🎉 celebration 🚀 rocket'
+ @classifier.train_uninteresting 'sad 😢 crying 💔 heartbreak'
+
+ result = @classifier.classify('happy celebration')
+
+ assert_equal 'Interesting', result, 'Should handle emoji in text'
+ end
+
+ def test_special_characters_only
+ @classifier.train_interesting '!@#$%^&*()'
+ category_words = @classifier.instance_variable_get(:@categories)[:Interesting]
+ # Special chars become symbols in word_hash, but clean_word_hash filters them
+ assert_kind_of Hash, category_words
+ end
+
+ def test_very_long_text
+ long_text = 'interesting ' * 10_000
+ @classifier.train_interesting long_text
+ @classifier.train_uninteresting 'boring text'
+
+ total_words = @classifier.instance_variable_get(:@total_words)
+
+ assert_predicate total_words, :positive?, 'Should handle very long text'
+
+ result = @classifier.classify('interesting')
+
+ assert_equal 'Interesting', result
+ end
+
+ def test_single_word_classification
+ @classifier.train_interesting 'apple'
+ @classifier.train_uninteresting 'banana'
+
+ assert_equal 'Interesting', @classifier.classify('apple')
+ assert_equal 'Uninteresting', @classifier.classify('banana')
+ end
+
+ def test_whitespace_only
+ @classifier.train_interesting " \t\n "
+ category_words = @classifier.instance_variable_get(:@categories)[:Interesting]
+
+ assert_empty category_words, 'Whitespace-only should not add words'
+ end
+
+ def test_mixed_case_classification
+ @classifier.train_interesting 'UPPERCASE lowercase MiXeD'
+ @classifier.train_uninteresting 'different words here'
+
+ # Words are downcased during training, so uppercase query should match
+ result = @classifier.classify('uppercase lowercase')
+
+ assert_equal 'Interesting', result, 'Should handle mixed case'
+ end
+
+ def test_numbers_in_text
+ @classifier.train_interesting 'test123 456test 789'
+ @classifier.train_uninteresting 'abc def ghi'
+
+ result = @classifier.classify('test123')
+
+ assert_equal 'Interesting', result, 'Should handle numbers in text'
+ end
+
+ # Laplace smoothing tests
+
+ def test_laplace_smoothing_unseen_words
+ # Train with some words, then classify with unseen word
+ # Laplace smoothing should give unseen words a non-zero probability
+ # that scales with vocabulary size
+ @classifier.train_interesting 'apple banana cherry'
+ @classifier.train_uninteresting 'dog elephant fox'
+
+ # "zebra" is unseen - should still get valid scores
+ scores = @classifier.classifications('zebra')
+
+ scores.each_value do |score|
+ assert_predicate score, :finite?, 'Score should be finite with Laplace smoothing'
+ refute_predicate score, :zero?, 'Score should be non-zero with Laplace smoothing'
+ end
+ end
+
+ def test_laplace_smoothing_consistency
+ # With proper Laplace smoothing, the probability of an unseen word
+ # should be α / (total + α * vocab_size)
+ # This should be consistent across categories with same training size
+ classifier = Classifier::Bayes.new 'A', 'B'
+ classifier.train_a 'word1 word2 word3'
+ classifier.train_b 'word4 word5 word6'
+
+ scores = classifier.classifications('unseenword')
+
+ # Both categories have same word count, so unseen word scores should be equal
+ assert_in_delta scores['A'], scores['B'], 0.01,
+ 'Equal-sized categories should give equal scores for unseen words'
+ end
+
+ def test_laplace_smoothing_vocabulary_scaling
+ # The smoothing should account for vocabulary size
+ # Larger vocabulary = smaller probability for each unseen word
+ small_vocab = Classifier::Bayes.new 'Cat', 'Dog'
+ small_vocab.train_cat 'meow purr'
+ small_vocab.train_dog 'bark woof'
+
+ large_vocab = Classifier::Bayes.new 'Cat', 'Dog'
+ large_vocab.train_cat 'meow purr hiss scratch climb jump pounce stalk hunt sleep'
+ large_vocab.train_dog 'bark woof growl fetch run play chase guard protect howl'
+
+ small_scores = small_vocab.classifications('unknown')
+ large_scores = large_vocab.classifications('unknown')
+
+ # With proper smoothing, larger vocabulary should give lower (more negative) scores
+ # for unseen words because probability mass is spread across more terms
+ assert_operator small_scores['Cat'], :>, large_scores['Cat'],
+ 'Larger vocabulary should give lower scores for unseen words'
+ end
+
+ def test_laplace_smoothing_seen_words_also_smoothed
+ # Proper Laplace smoothing applies to ALL words, not just unseen ones
+ # P(word|cat) = (count + α) / (total + α * V), not count / total
+ classifier = Classifier::Bayes.new 'A', 'B'
+ classifier.train_a 'test'
+ classifier.train_b 'other'
+
+ # With proper smoothing, seen word probability should include α adjustment
+ # The word "test" appears once in A with total=1, vocab=2
+ # Proper: (1 + 1) / (1 + 1*2) = 2/3
+ # Current: 1 / 1 = 1.0 (no smoothing applied to seen words)
+
+ scores = classifier.classifications('test')
+
+ # Score for A should reflect smoothed probability, not raw count
+ # log(2/3) ≈ -0.405, not log(1) = 0
+ # The word score plus prior should not equal just the prior
+ prior_only_score = Math.log(0.5) # equal priors
+
+ refute_in_delta scores['A'], prior_only_score, 0.01,
+ 'Seen word score should include smoothing adjustment, not raw probability'
+ end
+
+ def test_laplace_smoothing_denominator_includes_vocabulary
+ # The denominator should be (total + α * vocab_size), not just total
+ # This test verifies that adding more vocabulary affects all probabilities
+ classifier1 = Classifier::Bayes.new 'Spam', 'Ham'
+ classifier1.train_spam 'buy now'
+ classifier1.train_ham 'hello friend'
+
+ classifier2 = Classifier::Bayes.new 'Spam', 'Ham'
+ classifier2.train_spam 'buy now'
+ classifier2.train_ham 'hello friend goodbye see you later take care'
+
+ # Same query word "buy" - should have different probabilities
+ # because vocabulary size differs (affecting denominator)
+ scores1 = classifier1.classifications('buy')
+ scores2 = classifier2.classifications('buy')
+
+ # With proper smoothing, larger vocab in classifier2 means
+ # the probability of "buy" in Spam is lower (spread across more terms)
+ refute_in_delta scores1['Spam'], scores2['Spam'], 0.1,
+ 'Vocabulary size should affect word probabilities in denominator'
+ end
+
+ # Save/Load tests
+
+ def test_as_json
+ @classifier.train_interesting 'good words here'
+ @classifier.train_uninteresting 'bad words there'
+
+ data = @classifier.as_json
+
+ assert_instance_of Hash, data
+ assert_equal 1, data[:version]
+ assert_equal 'bayes', data[:type]
+ assert_includes data[:categories].keys, 'Interesting'
+ assert_includes data[:categories].keys, 'Uninteresting'
+ end
+
+ def test_to_json
+ @classifier.train_interesting 'good words here'
+ @classifier.train_uninteresting 'bad words there'
+
+ json = @classifier.to_json
+ data = JSON.parse(json)
+
+ assert_equal 1, data['version']
+ assert_equal 'bayes', data['type']
+ assert_includes data['categories'].keys, 'Interesting'
+ assert_includes data['categories'].keys, 'Uninteresting'
+ end
+
+ def test_from_json_with_string
+ @classifier.train_interesting 'good words here'
+ @classifier.train_uninteresting 'bad words there'
+
+ json = @classifier.to_json
+ loaded = Classifier::Bayes.from_json(json)
+
+ assert_equal @classifier.categories.sort, loaded.categories.sort
+ assert_equal @classifier.classify('good words'), loaded.classify('good words')
+ assert_equal @classifier.classify('bad words'), loaded.classify('bad words')
+ end
+
+ def test_from_json_with_hash
+ @classifier.train_interesting 'good words here'
+ @classifier.train_uninteresting 'bad words there'
+
+ # Use as_json to get a hash, then convert keys to strings (as would happen from JSON.parse)
+ hash = JSON.parse(@classifier.to_json)
+ loaded = Classifier::Bayes.from_json(hash)
+
+ assert_equal @classifier.categories.sort, loaded.categories.sort
+ assert_equal @classifier.classify('good words'), loaded.classify('good words')
+ assert_equal @classifier.classify('bad words'), loaded.classify('bad words')
+ end
+
+ def test_from_json_invalid_type
+ invalid_json = { version: 1, type: 'invalid' }.to_json
+
+ assert_raises(ArgumentError) { Classifier::Bayes.from_json(invalid_json) }
+ end
+
+ def test_save_and_load
+ @classifier.train_interesting 'good words here'
+ @classifier.train_uninteresting 'bad words there'
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ @classifier.save_to_file(path)
+
+ assert_path_exists path, 'Save should create file'
+
+ loaded = Classifier::Bayes.load_from_file(path)
+
+ assert_equal @classifier.categories.sort, loaded.categories.sort
+ assert_equal @classifier.classify('good'), loaded.classify('good')
+ end
+ end
+
+ def test_save_load_preserves_training_state
+ @classifier.train_interesting 'apple banana cherry'
+ @classifier.train_uninteresting 'dog elephant fox'
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ @classifier.save_to_file(path)
+ loaded = Classifier::Bayes.load_from_file(path)
+
+ # Verify classifications match
+ assert_equal @classifier.classifications('apple'), loaded.classifications('apple')
+ assert_equal @classifier.classifications('dog'), loaded.classifications('dog')
+ end
+ end
+
+ def test_loaded_classifier_can_continue_training
+ @classifier.train_interesting 'initial training'
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ @classifier.save_to_file(path)
+ loaded = Classifier::Bayes.load_from_file(path)
+
+ # Continue training on loaded classifier
+ loaded.train_interesting 'more interesting content'
+ loaded.train_uninteresting 'boring content here'
+
+ assert_equal 'Interesting', loaded.classify('interesting content')
+ assert_equal 'Uninteresting', loaded.classify('boring content')
+ end
+ end
+end
diff --git a/test/bayes/streaming_test.rb b/test/bayes/streaming_test.rb
new file mode 100644
index 0000000..16b0a75
--- /dev/null
+++ b/test/bayes/streaming_test.rb
@@ -0,0 +1,354 @@
+require_relative '../test_helper'
+require 'stringio'
+require 'tempfile'
+
+class BayesStreamingTest < Minitest::Test
+ def setup
+ @classifier = Classifier::Bayes.new('Spam', 'Ham')
+ end
+
+ # train_from_stream tests
+
+ def test_train_from_stream_basic
+ io = StringIO.new("buy now cheap\nfree money\nlimited offer\n")
+ @classifier.train_from_stream(:spam, io)
+
+ # Should have trained 3 documents
+ assert_equal 'Spam', @classifier.classify('buy cheap free')
+ end
+
+ def test_train_from_stream_many_categories
+ classifier = Classifier::Bayes.new('Spam', 'Ham')
+ classifier.train_from_stream(
+ spam: StringIO.new("buy now cheap\nfree money\nlimited offer\n"),
+ ham: StringIO.new("hello friend\nmeeting tomorrow\n")
+ )
+
+ assert_equal 'Spam', classifier.classify('buy free')
+ assert_equal 'Ham', classifier.classify('hello meeting')
+ end
+
+ def test_train_from_stream_invalid_io_type
+ assert_raises(ArgumentError) do
+ @classifier.train_from_stream(spam: Object.new)
+ end
+ end
+
+ def test_train_from_stream_empty_io
+ io = StringIO.new('')
+ @classifier.train_from_stream(:spam, io)
+
+ # No documents trained, classifier should still work
+ assert_includes @classifier.categories, 'Spam'
+ end
+
+ def test_train_from_stream_single_line
+ io = StringIO.new("this is spam content\n")
+ @classifier.train_from_stream(:spam, io)
+
+ # Train some ham to make classification meaningful
+ @classifier.train(:ham, 'this is normal email')
+
+ result = @classifier.classify('spam content')
+
+ assert_equal 'Spam', result
+ end
+
+ def test_train_from_stream_with_batch_size
+ lines = (1..50).map { |i| "document number #{i} with some content" }
+ io = StringIO.new(lines.join("\n"))
+
+ batches_processed = 0
+ @classifier.train_from_stream(:spam, io, batch_size: 10) do |progress|
+ batches_processed = progress.current_batch
+ end
+
+ assert_equal 5, batches_processed
+ end
+
+ def test_train_from_stream_progress_tracking
+ lines = (1..25).map { |i| "line #{i}" }
+ io = StringIO.new(lines.join("\n"))
+
+ completed_values = []
+ @classifier.train_from_stream(:spam, io, batch_size: 10) do |progress|
+ completed_values << progress.completed
+ end
+
+ assert_equal [10, 20, 25], completed_values
+ end
+
+ def test_train_from_stream_with_progress_block
+ io = StringIO.new("doc1\ndoc2\ndoc3\n")
+ progress_received = nil
+
+ @classifier.train_from_stream(:spam, io, batch_size: 2) do |progress|
+ progress_received = progress
+ end
+
+ assert_instance_of Classifier::Streaming::Progress, progress_received
+ assert_equal 3, progress_received.completed
+ end
+
+ def test_train_from_stream_invalid_category
+ io = StringIO.new("some content\n")
+
+ assert_raises(StandardError) do
+ @classifier.train_from_stream(:nonexistent, io)
+ end
+ end
+
+ def test_train_from_stream_marks_dirty
+ io = StringIO.new("content\n")
+
+ refute_predicate @classifier, :dirty?
+
+ @classifier.train_from_stream(:spam, io)
+
+ assert_predicate @classifier, :dirty?
+ end
+
+ def test_train_from_stream_with_file
+ Tempfile.create(['corpus', '.txt']) do |file|
+ file.puts 'spam message one'
+ file.puts 'spam message two'
+ file.puts 'spam message three'
+ file.flush
+ file.rewind
+
+ @classifier.train_from_stream(:spam, file)
+ end
+
+ @classifier.train(:ham, 'normal message here')
+
+ assert_equal 'Spam', @classifier.classify('spam message')
+ end
+
+ # train_batch tests
+
+ def test_train_batch_positional_style
+ documents = ['buy now', 'free money', 'limited offer']
+ @classifier.train_batch(:spam, documents)
+
+ @classifier.train(:ham, 'hello friend')
+
+ assert_equal 'Spam', @classifier.classify('buy free limited')
+ end
+
+ def test_train_batch_keyword_style
+ spam_docs = ['buy now', 'free money']
+ ham_docs = ['hello friend', 'meeting tomorrow']
+
+ @classifier.train_batch(spam: spam_docs, ham: ham_docs)
+
+ assert_equal 'Spam', @classifier.classify('buy free')
+ assert_equal 'Ham', @classifier.classify('hello meeting')
+ end
+
+ def test_train_batch_with_batch_size
+ documents = (1..100).map { |i| "document #{i}" }
+
+ batches = 0
+ @classifier.train_batch(:spam, documents, batch_size: 25) do |_progress|
+ batches += 1
+ end
+
+ assert_equal 4, batches
+ end
+
+ def test_train_batch_progress_tracking
+ documents = (1..30).map { |i| "doc #{i}" }
+
+ completed_values = []
+ @classifier.train_batch(:spam, documents, batch_size: 10) do |progress|
+ completed_values << progress.completed
+ end
+
+ assert_equal [10, 20, 30], completed_values
+ end
+
+ def test_train_batch_progress_percent
+ documents = (1..100).map { |i| "doc #{i}" }
+
+ percent_values = []
+ @classifier.train_batch(:spam, documents, batch_size: 25) do |progress|
+ percent_values << progress.percent
+ end
+
+ assert_equal [25.0, 50.0, 75.0, 100.0], percent_values
+ end
+
+ def test_train_batch_empty_array
+ @classifier.train_batch(:spam, [])
+ # Should not raise, classifier should be unchanged
+ assert_includes @classifier.categories, 'Spam'
+ end
+
+ def test_train_batch_invalid_category
+ assert_raises(StandardError) do
+ @classifier.train_batch(:nonexistent, ['doc'])
+ end
+ end
+
+ def test_train_batch_marks_dirty
+ refute_predicate @classifier, :dirty?
+ @classifier.train_batch(:spam, ['content'])
+
+ assert_predicate @classifier, :dirty?
+ end
+
+ def test_train_batch_multiple_categories
+ @classifier.train_batch(
+ spam: ['buy now', 'free offer'],
+ ham: %w[hello meeting]
+ )
+
+ assert_equal 'Spam', @classifier.classify('buy free')
+ assert_equal 'Ham', @classifier.classify('hello meeting')
+ end
+
+ # Equivalence tests
+
+ def test_train_batch_equivalent_to_train
+ classifier1 = Classifier::Bayes.new('Spam', 'Ham')
+ classifier2 = Classifier::Bayes.new('Spam', 'Ham')
+
+ documents = ['buy now cheap', 'free money fast', 'limited time offer']
+
+ # Train with regular train
+ documents.each { |doc| classifier1.train(:spam, doc) }
+
+ # Train with train_batch
+ classifier2.train_batch(:spam, documents)
+
+ # Both should classify the same
+ test_doc = 'buy cheap free limited'
+
+ assert_equal classifier1.classify(test_doc), classifier2.classify(test_doc)
+
+ # Classifications should be identical
+ assert_equal classifier1.classifications(test_doc), classifier2.classifications(test_doc)
+ end
+
+ def test_train_from_stream_equivalent_to_train
+ classifier1 = Classifier::Bayes.new('Spam', 'Ham')
+ classifier2 = Classifier::Bayes.new('Spam', 'Ham')
+
+ documents = ['buy now cheap', 'free money fast', 'limited time offer']
+
+ # Train with regular train
+ documents.each { |doc| classifier1.train(:spam, doc) }
+
+ # Train with train_from_stream
+ io = StringIO.new(documents.join("\n"))
+ classifier2.train_from_stream(:spam, io)
+
+ # Both should classify the same
+ test_doc = 'buy cheap free limited'
+
+ assert_equal classifier1.classify(test_doc), classifier2.classify(test_doc)
+ end
+
+ # Checkpoint tests
+
+ def test_save_checkpoint
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ @classifier.storage = Classifier::Storage::File.new(path: path)
+
+ @classifier.train(:spam, 'buy now cheap')
+ @classifier.save_checkpoint('50pct')
+
+ checkpoint_path = File.join(dir, 'classifier_checkpoint_50pct.json')
+
+ assert_path_exists checkpoint_path
+ end
+ end
+
+ def test_load_checkpoint
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ storage = Classifier::Storage::File.new(path: path)
+ @classifier.storage = storage
+
+ @classifier.train(:spam, 'buy now cheap')
+ @classifier.save_checkpoint('halfway')
+
+ # Load from checkpoint
+ loaded = Classifier::Bayes.load_checkpoint(storage: storage, checkpoint_id: 'halfway')
+
+ # Should have the same training
+ assert_equal @classifier.classify('buy cheap'), loaded.classify('buy cheap')
+ end
+ end
+
+ def test_list_checkpoints
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ @classifier.storage = Classifier::Storage::File.new(path: path)
+
+ @classifier.train(:spam, 'content')
+ @classifier.save_checkpoint('10pct')
+ @classifier.save_checkpoint('50pct')
+ @classifier.save_checkpoint('90pct')
+
+ checkpoints = @classifier.list_checkpoints
+
+ assert_equal %w[10pct 50pct 90pct], checkpoints.sort
+ end
+ end
+
+ def test_delete_checkpoint
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ @classifier.storage = Classifier::Storage::File.new(path: path)
+
+ @classifier.train(:spam, 'content')
+ @classifier.save_checkpoint('test')
+
+ checkpoint_path = File.join(dir, 'classifier_checkpoint_test.json')
+
+ assert_path_exists checkpoint_path
+
+ @classifier.delete_checkpoint('test')
+
+ refute_path_exists checkpoint_path
+ end
+ end
+
+ def test_save_checkpoint_requires_storage
+ assert_raises(ArgumentError) do
+ @classifier.save_checkpoint('test')
+ end
+ end
+
+ def test_checkpoint_workflow
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ storage = Classifier::Storage::File.new(path: path)
+
+ # First training session
+ classifier1 = Classifier::Bayes.new('Spam', 'Ham')
+ classifier1.storage = storage
+
+ classifier1.train(:spam, 'buy now')
+ classifier1.train(:spam, 'free money')
+ classifier1.save_checkpoint('phase1')
+
+ # Simulate restart - load from checkpoint
+ classifier2 = Classifier::Bayes.load_checkpoint(storage: storage, checkpoint_id: 'phase1')
+
+ # Continue training
+ classifier2.train(:ham, 'hello friend')
+ classifier2.train(:ham, 'meeting tomorrow')
+ classifier2.save_checkpoint('phase2')
+
+ # Load final checkpoint
+ final = Classifier::Bayes.load_checkpoint(storage: storage, checkpoint_id: 'phase2')
+
+ # Should have all training
+ assert_equal 'Spam', final.classify('buy free')
+ assert_equal 'Ham', final.classify('hello meeting')
+ end
+ end
+end
diff --git a/test/cli/cli_test.rb b/test/cli/cli_test.rb
new file mode 100644
index 0000000..d42f95a
--- /dev/null
+++ b/test/cli/cli_test.rb
@@ -0,0 +1,279 @@
+require_relative '../test_helper'
+require 'classifier/cli'
+
+class CLITest < Minitest::Test
+ def setup
+ @tmpdir = Dir.mktmpdir
+ @model_path = File.join(@tmpdir, 'classifier.json')
+ end
+
+ def teardown
+ FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
+ end
+
+ # Helper to run CLI and capture output
+ def run_cli(*args, stdin: nil)
+ cli = Classifier::CLI.new(args, stdin: stdin)
+ cli.run
+ end
+
+ # Helper to create a trained model for testing
+ def create_trained_bayes_model
+ run_cli('train', 'spam', '-f', @model_path, stdin: "buy now\nfree money\nlimited offer")
+ run_cli('train', 'ham', '-f', @model_path, stdin: "hello friend\nmeeting tomorrow\nproject update")
+ end
+
+ #
+ # Version and Help
+ #
+ def test_version_flag
+ result = run_cli('-v')
+
+ assert_match(/\d+\.\d+\.\d+/, result[:output])
+ assert_equal 0, result[:exit_code]
+ end
+
+ def test_help_flag
+ result = run_cli('-h')
+
+ assert_match(/usage:/i, result[:output])
+ assert_match(/train/, result[:output])
+ assert_match(/classify/i, result[:output])
+ assert_equal 0, result[:exit_code]
+ end
+
+ def test_getting_started_when_no_model_and_no_args
+ result = run_cli('-f', @model_path)
+
+ assert_match(/Get started by training/, result[:output])
+ assert_match(/classifier train spam/, result[:output])
+ assert_match(/classifier --help/, result[:output])
+ assert_equal 0, result[:exit_code]
+ assert_empty result[:error]
+ end
+
+ #
+ # Train Command
+ #
+ def test_train_from_stdin
+ result = run_cli('train', 'spam', '-f', @model_path, stdin: "buy now\nfree money")
+
+ assert_equal 0, result[:exit_code]
+ assert_path_exists @model_path
+ end
+
+ def test_train_from_file
+ corpus_file = File.join(@tmpdir, 'spam.txt')
+ File.write(corpus_file, "buy now\nfree money\nlimited offer")
+
+ result = run_cli('train', 'spam', '-f', @model_path, corpus_file)
+
+ assert_equal 0, result[:exit_code]
+ assert_path_exists @model_path
+ end
+
+ def test_train_multiple_files
+ file1 = File.join(@tmpdir, 'spam1.txt')
+ file2 = File.join(@tmpdir, 'spam2.txt')
+ File.write(file1, 'buy now')
+ File.write(file2, 'free money')
+
+ result = run_cli('train', 'spam', '-f', @model_path, file1, file2)
+
+ assert_equal 0, result[:exit_code]
+ end
+
+ def test_train_requires_category
+ result = run_cli('train', '-f', @model_path)
+
+ assert_equal 2, result[:exit_code]
+ assert_match(/category/i, result[:error])
+ end
+
+ def test_train_multiple_categories
+ run_cli('train', 'spam', '-f', @model_path, stdin: 'buy now')
+ result = run_cli('train', 'ham', '-f', @model_path, stdin: 'hello friend')
+
+ assert_equal 0, result[:exit_code]
+
+ # Verify both categories exist
+ result = run_cli('info', '-f', @model_path)
+ info = JSON.parse(result[:output])
+
+ assert_includes info['categories'], 'Spam'
+ assert_includes info['categories'], 'Ham'
+ end
+
+ #
+ # Classify Command (Default Action)
+ #
+ def test_classify_text_argument
+ create_trained_bayes_model
+
+ result = run_cli('buy now free money', '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ assert_equal 'spam', result[:output].strip.downcase
+ end
+
+ def test_classify_from_stdin
+ create_trained_bayes_model
+
+ result = run_cli('-f', @model_path, stdin: 'buy now free money')
+
+ assert_equal 0, result[:exit_code]
+ assert_equal 'spam', result[:output].strip.downcase
+ end
+
+ def test_classify_multiple_lines_from_stdin
+ create_trained_bayes_model
+
+ result = run_cli('-f', @model_path, stdin: "buy now\nmeeting tomorrow")
+ lines = result[:output].strip.split("\n").map(&:downcase)
+
+ assert_equal 2, lines.size
+ assert_equal 'spam', lines[0]
+ assert_equal 'ham', lines[1]
+ end
+
+ def test_classify_with_probabilities
+ create_trained_bayes_model
+
+ result = run_cli('-p', 'buy now free money', '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/spam:\d+\.\d+/, result[:output].downcase)
+ assert_match(/ham:\d+\.\d+/, result[:output].downcase)
+ end
+
+ def test_classify_without_model_fails
+ result = run_cli('some text', '-f', '/nonexistent/model.json')
+
+ assert_equal 1, result[:exit_code]
+ assert_match(/model|not found|exist/i, result[:error])
+ end
+
+ #
+ # Info Command
+ #
+ def test_info_shows_model_details
+ create_trained_bayes_model
+
+ result = run_cli('info', '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ info = JSON.parse(result[:output])
+
+ assert_equal 'bayes', info['type']
+ assert_includes info['categories'], 'Spam'
+ assert_includes info['categories'], 'Ham'
+ assert_operator info['category_stats']['Spam']['unique_words'], :>, 0
+ assert_operator info['category_stats']['Ham']['unique_words'], :>, 0
+ end
+
+ def test_info_without_model_fails
+ result = run_cli('info', '-f', '/nonexistent/model.json')
+
+ assert_equal 1, result[:exit_code]
+ end
+
+ #
+ # Classifier Types
+ #
+ def test_train_with_lsi_type
+ result = run_cli('-m', 'lsi', 'train', 'tech', '-f', @model_path, stdin: 'ruby programming language')
+
+ assert_equal 0, result[:exit_code]
+
+ result = run_cli('info', '-f', @model_path)
+ info = JSON.parse(result[:output])
+
+ assert_equal 'lsi', info['type']
+ end
+
+ def test_train_with_knn_type
+ result = run_cli('-m', 'knn', 'train', 'tech', '-f', @model_path, stdin: 'ruby programming language')
+
+ assert_equal 0, result[:exit_code]
+
+ result = run_cli('info', '-f', @model_path)
+ info = JSON.parse(result[:output])
+
+ assert_equal 'knn', info['type']
+ end
+
+ def test_train_with_lr_type
+ result = run_cli('-m', 'lr', 'train', 'tech', '-f', @model_path, stdin: 'ruby programming language')
+
+ assert_equal 0, result[:exit_code]
+
+ result = run_cli('info', '-f', @model_path)
+ info = JSON.parse(result[:output])
+
+ assert_equal 'logistic_regression', info['type']
+ end
+
+ def test_invalid_classifier_type
+ result = run_cli('-m', 'invalid', 'train', 'spam', '-f', @model_path, stdin: 'test')
+
+ assert_equal 2, result[:exit_code]
+ assert_match(/invalid|unknown|type/i, result[:error])
+ end
+
+ #
+ # KNN Options
+ #
+ def test_knn_with_k_option
+ run_cli('-m', 'knn', '-k', '3', 'train', 'tech', '-f', @model_path, stdin: 'ruby programming')
+ run_cli('-m', 'knn', 'train', 'sports', '-f', @model_path, stdin: 'football soccer')
+
+ result = run_cli('-m', 'knn', '-k', '3', 'ruby code', '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ end
+
+ #
+ # Environment Variables
+ #
+ def test_model_from_environment_variable
+ create_trained_bayes_model
+
+ ENV['CLASSIFIER_MODEL'] = @model_path
+ result = run_cli('buy now free money')
+
+ assert_equal 0, result[:exit_code]
+ assert_equal 'spam', result[:output].strip.downcase
+ ensure
+ ENV.delete('CLASSIFIER_MODEL')
+ end
+
+ def test_type_from_environment_variable
+ ENV['CLASSIFIER_TYPE'] = 'lsi'
+ ENV['CLASSIFIER_MODEL'] = @model_path
+
+ result = run_cli('train', 'tech', stdin: 'programming code')
+
+ assert_equal 0, result[:exit_code]
+
+ result = run_cli('info')
+ info = JSON.parse(result[:output])
+
+ assert_equal 'lsi', info['type']
+ ensure
+ ENV.delete('CLASSIFIER_TYPE')
+ ENV.delete('CLASSIFIER_MODEL')
+ end
+
+ #
+ # Quiet Mode
+ #
+ def test_quiet_mode_minimal_output
+ create_trained_bayes_model
+
+ result = run_cli('-q', 'buy now', '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ # Quiet mode should just output the category, nothing else
+ assert_equal 'spam', result[:output].strip.downcase
+ end
+end
diff --git a/test/cli/lr_commands_test.rb b/test/cli/lr_commands_test.rb
new file mode 100644
index 0000000..00a3aee
--- /dev/null
+++ b/test/cli/lr_commands_test.rb
@@ -0,0 +1,142 @@
+require_relative '../test_helper'
+require 'classifier/cli'
+
+class LRCommandsTest < Minitest::Test
+ def setup
+ @tmpdir = Dir.mktmpdir
+ @model_path = File.join(@tmpdir, 'classifier.json')
+ end
+
+ def teardown
+ FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
+ end
+
+ def run_cli(*args, stdin: nil)
+ cli = Classifier::CLI.new(args, stdin: stdin)
+ cli.run
+ end
+
+ def create_trained_lr_model
+ run_cli('-m', 'lr', 'train', 'spam', '-f', @model_path, stdin: "buy now\nfree money\nlimited offer\nclick here")
+ run_cli('-m', 'lr', 'train', 'ham', '-f', @model_path, stdin: "hello friend\nmeeting tomorrow\nproject update\nweekly report")
+ end
+
+ def create_fitted_lr_model
+ create_trained_lr_model
+ run_cli('fit', '-f', @model_path)
+ end
+
+ #
+ # Explicit Fit Required
+ #
+ def test_classify_without_fit_fails
+ create_trained_lr_model
+
+ # Should fail - model not fitted
+ result = run_cli('-m', 'lr', 'buy now free money', '-f', @model_path)
+
+ assert_equal 1, result[:exit_code]
+ assert_match(/not fitted|run.*fit/i, result[:error])
+ end
+
+ def test_classify_after_fit_succeeds
+ create_fitted_lr_model
+
+ result = run_cli('-m', 'lr', 'buy now free money', '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ assert_equal 'spam', result[:output].strip.downcase
+ end
+
+ def test_lr_info_shows_fit_status_before_fit
+ create_trained_lr_model
+
+ result = run_cli('info', '-f', @model_path)
+ info = JSON.parse(result[:output])
+
+ refute info['fitted']
+ end
+
+ def test_lr_info_shows_fit_status_after_fit
+ create_fitted_lr_model
+
+ result = run_cli('info', '-f', @model_path)
+ info = JSON.parse(result[:output])
+
+ assert info['fitted']
+ end
+
+ #
+ # Fit Command
+ #
+ def test_fit_command
+ create_trained_lr_model
+
+ result = run_cli('fit', '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+
+ result = run_cli('info', '-f', @model_path)
+ info = JSON.parse(result[:output])
+
+ assert info['fitted']
+ end
+
+ def test_fit_on_bayes_is_noop
+ # Create a bayes model
+ run_cli('train', 'spam', '-f', @model_path, stdin: 'buy now')
+
+ result = run_cli('fit', '-f', @model_path)
+
+ # Should succeed (no-op for Bayes)
+ assert_equal 0, result[:exit_code]
+ end
+
+ def test_fit_after_additional_training_invalidates
+ create_fitted_lr_model
+
+ # Add more training data
+ run_cli('-m', 'lr', 'train', 'spam', '-f', @model_path, stdin: 'win big prizes')
+
+ # Info should show needs re-fitting
+ result = run_cli('info', '-f', @model_path)
+ info = JSON.parse(result[:output])
+
+ refute info['fitted']
+ end
+
+ #
+ # LR Hyperparameters
+ #
+ def test_lr_with_learning_rate
+ result = run_cli('-m', 'lr', '--learning-rate', '0.001', 'train', 'spam', '-f', @model_path, stdin: 'buy now')
+
+ assert_equal 0, result[:exit_code]
+ end
+
+ def test_lr_with_regularization
+ result = run_cli('-m', 'lr', '--regularization', '0.1', 'train', 'spam', '-f', @model_path, stdin: 'buy now')
+
+ assert_equal 0, result[:exit_code]
+ end
+
+ def test_lr_with_max_iterations
+ result = run_cli('-m', 'lr', '--max-iterations', '500', 'train', 'spam', '-f', @model_path, stdin: 'buy now')
+
+ assert_equal 0, result[:exit_code]
+ end
+
+ #
+ # LR Classification with Probabilities
+ #
+ def test_lr_classify_with_probabilities
+ create_fitted_lr_model
+
+ result = run_cli('-m', 'lr', '-p', 'buy now free money', '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ # LR should give good probability estimates
+ assert_match(/spam:\d+\.\d+/, result[:output].downcase)
+ assert_match(/ham:\d+\.\d+/, result[:output].downcase)
+ end
+end
diff --git a/test/cli/lsi_commands_test.rb b/test/cli/lsi_commands_test.rb
new file mode 100644
index 0000000..34c9bb1
--- /dev/null
+++ b/test/cli/lsi_commands_test.rb
@@ -0,0 +1,131 @@
+require_relative '../test_helper'
+require 'classifier/cli'
+
+class LSICommandsTest < Minitest::Test
+ def setup
+ @tmpdir = Dir.mktmpdir
+ @model_path = File.join(@tmpdir, 'classifier.json')
+ create_trained_lsi_model
+ end
+
+ def teardown
+ FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
+ end
+
+ def run_cli(*args, stdin: nil)
+ cli = Classifier::CLI.new(args, stdin: stdin)
+ cli.run
+ end
+
+ def create_trained_lsi_model
+ # Create article files for training
+ @articles = {}
+
+ @articles['ruby.txt'] = File.join(@tmpdir, 'ruby.txt')
+ File.write(@articles['ruby.txt'], 'Ruby is an elegant programming language for web development')
+
+ @articles['python.txt'] = File.join(@tmpdir, 'python.txt')
+ File.write(@articles['python.txt'], 'Python is a programming language for data science')
+
+ @articles['rails.txt'] = File.join(@tmpdir, 'rails.txt')
+ File.write(@articles['rails.txt'], 'Rails is a web framework built with Ruby programming')
+
+ @articles['football.txt'] = File.join(@tmpdir, 'football.txt')
+ File.write(@articles['football.txt'], 'Football is a popular sport with teams and goals')
+
+ # Train LSI model
+ run_cli('-m', 'lsi', 'train', 'tech', '-f', @model_path, @articles['ruby.txt'], @articles['python.txt'], @articles['rails.txt'])
+ run_cli('-m', 'lsi', 'train', 'sports', '-f', @model_path, @articles['football.txt'])
+ end
+
+ #
+ # Search Command
+ #
+ def test_search_returns_ranked_documents
+ result = run_cli('search', 'programming language', '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ # Should return documents with scores
+ assert_match(/\.txt:\d+\.\d+/, result[:output])
+ end
+
+ def test_search_from_stdin
+ result = run_cli('search', '-f', @model_path, stdin: 'web development')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/\.txt:\d+\.\d+/, result[:output])
+ end
+
+ def test_search_with_count_limit
+ result = run_cli('search', '-n', '2', 'programming', '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ lines = result[:output].strip.split("\n")
+
+ assert_operator lines.size, :<=, 2
+ end
+
+ def test_search_fails_on_bayes_model
+ bayes_model = File.join(@tmpdir, 'bayes.json')
+ run_cli('train', 'spam', '-f', bayes_model, stdin: 'buy now')
+
+ result = run_cli('search', 'query', '-f', bayes_model)
+
+ assert_equal 1, result[:exit_code]
+ assert_match(/lsi|search.*requires/i, result[:error])
+ end
+
+ #
+ # Related Command
+ #
+ def test_related_finds_similar_documents
+ result = run_cli('related', @articles['ruby.txt'], '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ # Should find rails.txt as related (both about Ruby)
+ assert_match(/\.txt:\d+\.\d+/, result[:output])
+ end
+
+ def test_related_with_count_limit
+ result = run_cli('related', '-n', '1', @articles['ruby.txt'], '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ lines = result[:output].strip.split("\n")
+
+ assert_equal 1, lines.size
+ end
+
+ def test_related_fails_on_bayes_model
+ bayes_model = File.join(@tmpdir, 'bayes.json')
+ run_cli('train', 'spam', '-f', bayes_model, stdin: 'buy now')
+
+ result = run_cli('related', 'some_file.txt', '-f', bayes_model)
+
+ assert_equal 1, result[:exit_code]
+ assert_match(/lsi|related.*requires/i, result[:error])
+ end
+
+ def test_related_with_nonexistent_item
+ result = run_cli('related', '/nonexistent/file.txt', '-f', @model_path)
+
+ assert_equal 1, result[:exit_code]
+ assert_match(/not found|unknown|item/i, result[:error])
+ end
+
+ #
+ # LSI Classification
+ #
+ def test_lsi_classify_text
+ result = run_cli('-m', 'lsi', 'Ruby web framework', '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ assert_equal 'tech', result[:output].strip.downcase
+ end
+
+ def test_lsi_classify_with_probabilities
+ result = run_cli('-m', 'lsi', '-p', 'Ruby web framework', '-f', @model_path)
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/tech:\d+\.\d+/, result[:output].downcase)
+ end
+end
diff --git a/test/cli/registry_commands_test.rb b/test/cli/registry_commands_test.rb
new file mode 100644
index 0000000..c0a5b4b
--- /dev/null
+++ b/test/cli/registry_commands_test.rb
@@ -0,0 +1,457 @@
+require_relative '../test_helper'
+require 'classifier/cli'
+require 'webmock/minitest'
+
+class RegistryCommandsTest < Minitest::Test
+ def setup
+ @tmpdir = Dir.mktmpdir
+ @model_path = File.join(@tmpdir, 'classifier.json')
+ @cache_dir = File.join(@tmpdir, 'cache')
+
+ # Override cache directory for tests
+ @original_cache = Classifier::CLI::CACHE_DIR
+ Classifier::CLI.send(:remove_const, :CACHE_DIR)
+ Classifier::CLI.const_set(:CACHE_DIR, @cache_dir)
+
+ # Mock models.json response
+ @models_json = {
+ 'version' => '1.0.0',
+ 'models' => {
+ 'spam-filter' => {
+ 'description' => 'Email spam detection',
+ 'type' => 'bayes',
+ 'categories' => %w[spam ham],
+ 'file' => 'models/spam-filter.json',
+ 'size' => '245KB'
+ },
+ 'sentiment' => {
+ 'description' => 'Sentiment analysis',
+ 'type' => 'bayes',
+ 'categories' => %w[positive negative neutral],
+ 'file' => 'models/sentiment.json',
+ 'size' => '1.2MB'
+ }
+ }
+ }.to_json
+
+ # Mock classifier model response
+ @model_json = Classifier::Bayes.new('Spam', 'Ham').tap do |b|
+ b.train('Spam', 'buy now free money cheap')
+ b.train('Ham', 'hello friend meeting project')
+ end.to_json
+ end
+
+ def teardown
+ FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
+
+ # Restore original cache directory
+ Classifier::CLI.send(:remove_const, :CACHE_DIR)
+ Classifier::CLI.const_set(:CACHE_DIR, @original_cache)
+
+ WebMock.reset!
+ end
+
+ def run_cli(*args, stdin: nil)
+ cli = Classifier::CLI.new(args, stdin: stdin)
+ cli.run
+ end
+
+ def create_local_model_fixtures
+ # Create some cached models
+ models_dir = File.join(@cache_dir, 'models')
+ FileUtils.mkdir_p(models_dir)
+ File.write(File.join(models_dir, 'spam-filter.json'), @model_json)
+ File.write(File.join(models_dir, 'sentiment.json'), @model_json)
+ end
+
+ #
+ # Models Command
+ #
+ def test_models_lists_available_models
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+
+ result = run_cli('models')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/spam-filter/, result[:output])
+ assert_match(/sentiment/, result[:output])
+ assert_match(/bayes/, result[:output])
+ assert_empty result[:error]
+ end
+
+ def test_models_from_custom_registry
+ stub_request(:get, 'https://raw.githubusercontent.com/someone/models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+
+ result = run_cli('models', '@someone/models')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/spam-filter/, result[:output])
+ assert_empty result[:error]
+ end
+
+ def test_models_handles_empty_registry
+ empty_json = { 'version' => '1.0.0', 'models' => {} }.to_json
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: empty_json)
+
+ result = run_cli('models')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/no models found/i, result[:output])
+ end
+
+ def test_models_handles_network_error
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 404)
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/master/models.json')
+ .to_return(status: 404)
+
+ result = run_cli('models')
+
+ assert_equal 1, result[:exit_code]
+ assert_match(/failed to fetch/i, result[:error])
+ end
+
+ def test_models_model_detail_view
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+
+ result = run_cli('models', 'sentiment')
+
+ assert_equal 0, result[:exit_code]
+ assert_match('Name: sentiment', result[:output])
+ assert_match('Description: Sentiment analysis', result[:output])
+ assert_match('Type: bayes', result[:output])
+ assert_empty result[:error]
+ end
+
+ def test_models_model_detail_view_if_not_found
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+
+ result = run_cli('models', 'no-model')
+
+ assert_equal 0, result[:exit_code]
+ assert_match('No model "no-model" found in registry', result[:output])
+ assert_empty result[:error]
+ end
+
+ def test_models_model_detail_view_with_custom_registry
+ stub_request(:get, 'https://raw.githubusercontent.com/someone/models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+
+ result = run_cli('models', '@someone/models:spam-filter')
+
+ assert_equal 0, result[:exit_code]
+ assert_match('Name: spam-filter', result[:output])
+ assert_match('Description: Email spam detection', result[:output])
+ assert_match('Type: bayes', result[:output])
+ assert_empty result[:error]
+ end
+
+ def test_models_local_lists_cached_models
+ create_local_model_fixtures
+
+ result = run_cli('models', '--local')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/spam-filter/, result[:output])
+ assert_match(/sentiment/, result[:output])
+ assert_match(/bayes/, result[:output])
+ assert_empty result[:error]
+ end
+
+ def test_models_local_lists_models_from_custom_registries
+ # Create cached model from custom registry
+ custom_dir = File.join(@cache_dir, 'models', '@someone/models')
+ FileUtils.mkdir_p(custom_dir)
+ File.write(File.join(custom_dir, 'custom-model.json'), @model_json)
+
+ result = run_cli('models', '--local')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(%r{@someone/models:custom-model}, result[:output])
+ end
+
+ def test_models_local_shows_no_models_when_cache_empty
+ result = run_cli('models', '--local')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/no local models found/i, result[:output])
+ end
+
+ def test_models_local_shows_no_models_when_cache_dir_missing
+ # Cache dir doesn't exist by default in test setup
+ FileUtils.rm_rf(@cache_dir)
+
+ result = run_cli('models', '--local')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/no local models found/i, result[:output])
+ end
+
+ def test_models_local_model_detail_view
+ create_local_model_fixtures
+
+ result = run_cli('models', '--local', 'spam-filter')
+
+ assert_equal 0, result[:exit_code]
+ assert_match('Name: spam-filter', result[:output])
+ assert_match('Type: bayes', result[:output])
+ assert_match('Categories: spam, ham', result[:output])
+ assert_empty result[:error]
+ end
+
+ def test_models_local_model_detail_view_if_not_found
+ create_local_model_fixtures
+
+ result = run_cli('models', '--local', 'no-model')
+
+ assert_equal 0, result[:exit_code]
+ assert_match('No local model "no-model" found', result[:output])
+ assert_empty result[:error]
+ end
+
+ def test_models_remote_search_by_name
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+
+ result = run_cli('models', '--search', 'spam-filter')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/spam-filter/, result[:output])
+ refute_match(/sentiment/, result[:output])
+ end
+
+ def test_models_remote_search_by_description
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+
+ result = run_cli('models', '--search', 'Spam Detection')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/spam-filter/, result[:output])
+ refute_match(/sentiment/, result[:output])
+ end
+
+ def test_models_remote_search_no_found
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+
+ result = run_cli('models', '--search', '[a-z]+')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/No models found in registry/, result[:output])
+ end
+
+ def test_models_local_search_by_name
+ create_local_model_fixtures
+
+ result = run_cli('models', '--local', '--search', 'spam-filter')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/spam-filter/, result[:output])
+ refute_match(/sentiment/, result[:output])
+ end
+
+ def test_models_local_search_no_found
+ # Create some cached models
+ models_dir = File.join(@cache_dir, 'models')
+ FileUtils.mkdir_p(models_dir)
+ File.write(File.join(models_dir, 'spam-filter.json'), @model_json)
+
+ result = run_cli('models', '--local', '--search', '[a-z]+')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/No local models found/, result[:output])
+ end
+
+ #
+ # Pull Command
+ #
+ def test_pull_downloads_model
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models/spam-filter.json')
+ .to_return(status: 200, body: @model_json)
+
+ result = run_cli('pull', 'spam-filter')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/downloading/i, result[:output])
+ assert_match(/saved/i, result[:output])
+
+ cached_path = File.join(@cache_dir, 'models', 'spam-filter.json')
+
+ assert_path_exists cached_path
+ end
+
+ def test_pull_with_custom_output_path
+ output_path = File.join(@tmpdir, 'my-model.json')
+
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models/spam-filter.json')
+ .to_return(status: 200, body: @model_json)
+
+ result = run_cli('pull', 'spam-filter', '-o', output_path)
+
+ assert_equal 0, result[:exit_code]
+ assert_path_exists output_path
+ end
+
+ def test_pull_from_custom_registry
+ stub_request(:get, 'https://raw.githubusercontent.com/someone/models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+ stub_request(:get, 'https://raw.githubusercontent.com/someone/models/main/models/spam-filter.json')
+ .to_return(status: 200, body: @model_json)
+
+ result = run_cli('pull', '@someone/models:spam-filter')
+
+ assert_equal 0, result[:exit_code]
+
+ cached_path = File.join(@cache_dir, 'models', '@someone/models', 'spam-filter.json')
+
+ assert_path_exists cached_path
+ end
+
+ def test_pull_model_not_found
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+
+ result = run_cli('pull', 'nonexistent')
+
+ assert_equal 1, result[:exit_code]
+ assert_match(/not found/i, result[:error])
+ end
+
+ def test_pull_requires_model_name
+ result = run_cli('pull')
+
+ assert_equal 2, result[:exit_code]
+ assert_match(/model name required/i, result[:error])
+ end
+
+ def test_pull_quiet_mode
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models/spam-filter.json')
+ .to_return(status: 200, body: @model_json)
+
+ result = run_cli('pull', 'spam-filter', '-q')
+
+ assert_equal 0, result[:exit_code]
+ assert_empty result[:output]
+ end
+
+ #
+ # Push Command
+ #
+ def test_push_shows_instructions
+ result = run_cli('push', 'my-model.json')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/fork/i, result[:output])
+ assert_match(/classifier-models/i, result[:output])
+ assert_match(/pull request/i, result[:output])
+ end
+
+ #
+ # Remote Classification (-r)
+ #
+ def test_classify_with_remote_model
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models/spam-filter.json')
+ .to_return(status: 200, body: @model_json)
+
+ result = run_cli('-r', 'spam-filter', 'buy now free money')
+
+ assert_equal 0, result[:exit_code]
+ # Last line should be the classification result
+ assert_equal 'spam', result[:output].strip.split("\n").last.downcase
+ end
+
+ def test_classify_with_cached_remote_model
+ # Pre-cache the model
+ cached_path = File.join(@cache_dir, 'models', 'spam-filter.json')
+ FileUtils.mkdir_p(File.dirname(cached_path))
+ File.write(cached_path, @model_json)
+
+ # Should not make any network requests since model is cached
+ result = run_cli('-r', 'spam-filter', 'buy now free money')
+
+ assert_equal 0, result[:exit_code]
+ assert_equal 'spam', result[:output].strip.downcase
+ end
+
+ def test_classify_with_remote_from_custom_registry
+ stub_request(:get, 'https://raw.githubusercontent.com/someone/models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+ stub_request(:get, 'https://raw.githubusercontent.com/someone/models/main/models/spam-filter.json')
+ .to_return(status: 200, body: @model_json)
+
+ result = run_cli('-r', '@someone/models:spam-filter', 'buy now free money')
+
+ assert_equal 0, result[:exit_code]
+ # Last line should be the classification result
+ assert_equal 'spam', result[:output].strip.split("\n").last.downcase
+ end
+
+ def test_classify_with_probabilities_and_remote
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models.json')
+ .to_return(status: 200, body: @models_json)
+ stub_request(:get, 'https://raw.githubusercontent.com/cardmagic/classifier-models/main/models/spam-filter.json')
+ .to_return(status: 200, body: @model_json)
+
+ result = run_cli('-r', 'spam-filter', '-p', 'buy now free money')
+
+ assert_equal 0, result[:exit_code]
+ assert_match(/spam:\d+\.\d+/, result[:output].downcase)
+ assert_match(/ham:\d+\.\d+/, result[:output].downcase)
+ end
+
+ #
+ # Helper Methods
+ #
+ def test_parse_model_spec_simple_name
+ cli = Classifier::CLI.new([])
+ registry, model = cli.send(:parse_model_spec, 'sentiment')
+
+ assert_nil registry
+ assert_equal 'sentiment', model
+ end
+
+ def test_parse_model_spec_custom_registry
+ cli = Classifier::CLI.new([])
+ registry, model = cli.send(:parse_model_spec, '@user/repo:sentiment')
+
+ assert_equal 'user/repo', registry
+ assert_equal 'sentiment', model
+ end
+
+ def test_parse_model_spec_registry_only
+ cli = Classifier::CLI.new([])
+ registry, model = cli.send(:parse_model_spec, '@user/repo')
+
+ assert_equal 'user/repo', registry
+ assert_nil model
+ end
+
+ def test_cache_path_for_default_registry
+ cli = Classifier::CLI.new([])
+ path = cli.send(:cache_path_for, 'cardmagic/classifier-models', 'sentiment')
+
+ assert_match %r{models/sentiment\.json$}, path
+ refute_match(/@/, path)
+ end
+
+ def test_cache_path_for_custom_registry
+ cli = Classifier::CLI.new([])
+ path = cli.send(:cache_path_for, 'user/repo', 'sentiment')
+
+ assert_match %r{@user/repo/sentiment\.json$}, path
+ end
+end
diff --git a/test/config/config_test.rb b/test/config/config_test.rb
new file mode 100644
index 0000000..556d094
--- /dev/null
+++ b/test/config/config_test.rb
@@ -0,0 +1,22 @@
+require_relative '../test_helper'
+require 'classifier/config'
+
+class ConfigTest < Minitest::Test
+ def teardown
+ Classifier.config.min_word_length = 3
+ end
+
+ def test_configure
+ Classifier.configure do |config|
+ config.min_word_length = 1
+ end
+
+ assert_equal(1, Classifier.config.min_word_length)
+ end
+
+ def test_default
+ config = Classifier::Config.new
+
+ assert_equal(3, config.min_word_length)
+ end
+end
diff --git a/test/extensions/word_hash_test.rb b/test/extensions/word_hash_test.rb
index 6d8feed..e42cd4f 100644
--- a/test/extensions/word_hash_test.rb
+++ b/test/extensions/word_hash_test.rb
@@ -1,35 +1,191 @@
-require File.dirname(__FILE__) + '/../test_helper'
-class StringExtensionsTest < Test::Unit::TestCase
- def test_word_hash
- hash = {:good=>1, :"!"=>1, :hope=>1, :"'"=>1, :"."=>1, :love=>1, :word=>1, :them=>1, :test=>1}
- assert_equal hash, "here are some good words of test's. I hope you love them!".word_hash
- end
-
-
- def test_clean_word_hash
- hash = {:good=>1, :word=>1, :hope=>1, :love=>1, :them=>1, :test=>1}
- assert_equal hash, "here are some good words of test's. I hope you love them!".clean_word_hash
- end
+require_relative '../test_helper'
-end
+class StringExtensionsTest < Minitest::Test
+ def test_word_hash
+ hash = { good: 1, '!': 1, hope: 1, "'": 1, '.': 1, love: 1, word: 1, them: 1, test: 1 }
+ assert_equal hash, "here are some good words of test's. I hope you love them!".word_hash
+ end
-class ArrayExtensionsTest < Test::Unit::TestCase
+ def test_clean_word_hash
+ hash = { good: 1, word: 1, hope: 1, love: 1, them: 1, test: 1 }
- def test_plays_nicely_with_any_array
- assert_equal [Array].sum, Array
+ assert_equal hash, "here are some good words of test's. I hope you love them!".clean_word_hash
end
+end
+class ArrayExtensionsTest < Minitest::Test
def test_monkey_path_array_sum
- assert_equal [1,2,3].sum, 6
+ assert_equal 6, [1, 2, 3].sum_with_identity
end
- def test_summing_an_empty_array
- assert_equal [nil].sum, 0
+ def test_summing_a_nil_array
+ assert_equal 0, [nil].sum_with_identity
end
def test_summing_an_empty_array
- assert_equal Array[].sum, 0
+ assert_equal 0, [].sum_with_identity
+ end
+
+ def test_sum_with_block
+ assert_in_delta([1, 2, 3].sum_with_identity { |x| x * 2 }, 12.0)
+ end
+
+ def test_sum_with_custom_identity
+ assert_in_delta([].sum_with_identity(100), 100.0)
+ end
+end
+
+class StringPunctuationTest < Minitest::Test
+ def test_without_punctuation_basic
+ result = 'Hello, world!'.without_punctuation
+
+ assert_equal 'Hello world ', result
+ end
+
+ def test_without_punctuation_many_symbols
+ result = "Hello (greeting's), with {braces} < >...?".without_punctuation
+
+ assert_equal 'Hello greetings with braces ', result
+ end
+
+ def test_without_punctuation_empty_string
+ result = ''.without_punctuation
+
+ assert_equal '', result
+ end
+
+ def test_without_punctuation_no_punctuation
+ result = 'plain text here'.without_punctuation
+
+ assert_equal 'plain text here', result
+ end
+
+ def test_without_punctuation_only_punctuation
+ result = "!@\#$%^&*()".without_punctuation
+
+ assert_equal ' ', result
+ end
+end
+
+class VectorExtensionsTest < Minitest::Test
+ def test_magnitude_basic
+ vec = Vector[3, 4]
+
+ assert_in_delta 5.0, vec.magnitude, 0.001
+ end
+
+ def test_magnitude_zero_vector
+ vec = Vector[0, 0, 0]
+
+ assert_in_delta(0.0, vec.magnitude)
+ end
+
+ def test_magnitude_single_element
+ vec = Vector[5]
+
+ assert_in_delta(5.0, vec.magnitude)
+ end
+
+ def test_magnitude_negative_values
+ vec = Vector[-3, -4]
+
+ assert_in_delta 5.0, vec.magnitude, 0.001
+ end
+
+ def test_normalize_basic
+ vec = Vector[3, 4]
+ normalized = vec.normalize
+
+ assert_in_delta 1.0, normalized.magnitude, 0.001
+ end
+
+ def test_normalize_unit_vector
+ vec = Vector[1, 0, 0]
+ normalized = vec.normalize
+
+ assert_in_delta 1.0, normalized[0], 0.001
+ assert_in_delta 0.0, normalized[1], 0.001
+ end
+
+ def test_normalize_preserves_direction
+ vec = Vector[2, 0]
+ normalized = vec.normalize
+
+ assert_in_delta 1.0, normalized[0], 0.001
+ assert_in_delta 0.0, normalized[1], 0.001
+ end
+
+ def test_normalize_zero_vector_returns_zero_vector
+ vec = Vector[0, 0, 0]
+ normalized = vec.normalize
+
+ assert_in_delta 0.0, normalized[0], 0.001
+ assert_in_delta 0.0, normalized[1], 0.001
+ assert_in_delta 0.0, normalized[2], 0.001
+ end
+
+ def test_normalize_near_zero_vector_normalizes_correctly
+ # Near-zero vectors should still normalize to unit vectors
+ # Only actual zero vectors return zero
+ small = Vector::EPSILON * 10
+ vec = Vector[small, small, small]
+ normalized = vec.normalize
+
+ # Should normalize to [1/sqrt(3), 1/sqrt(3), 1/sqrt(3)]
+ expected = 1.0 / Math.sqrt(3)
+
+ assert_in_delta expected, normalized[0], 0.001
+ assert_in_delta expected, normalized[1], 0.001
+ assert_in_delta expected, normalized[2], 0.001
+ end
+end
+
+class MatrixExtensionsTest < Minitest::Test
+ def test_diag_creates_diagonal_matrix
+ result = Matrix.diag([1, 2, 3])
+ expected = Matrix.diagonal(1, 2, 3)
+
+ assert_equal expected, result
+ end
+
+ def test_trans_alias
+ matrix = Matrix[[1, 2], [3, 4]]
+
+ assert_equal matrix.transpose, matrix.trans
+ end
+
+ def test_matrix_element_assignment
+ matrix = Matrix[[1, 2], [3, 4]]
+ matrix[0, 1] = 99
+
+ assert_equal 99, matrix[0, 1]
end
+ def test_svd_basic
+ matrix = Matrix[[1, 0], [0, 1], [0, 0]]
+ _u, _v, s = matrix.SV_decomp
+
+ assert_equal 2, s.size
+ assert(s.all? { |val| val >= 0 })
+ end
+
+ def test_svd_with_zero_rows
+ # Matrix with linearly dependent rows that could cause zero singular values
+ matrix = Matrix[[1, 1], [1, 1], [0, 0]]
+ _u, _v, s = matrix.SV_decomp
+
+ # Should not raise an error
+ assert_equal 2, s.size
+ end
+
+ def test_svd_handles_near_singular_matrix
+ # Near-singular matrix that previously caused Math::DomainError
+ matrix = Matrix[[1e-10, 0], [0, 1e-10]]
+
+ # Should not raise an error
+ _u, _v, s = matrix.SV_decomp
+
+ assert_equal 2, s.size
+ end
end
diff --git a/test/knn/knn_test.rb b/test/knn/knn_test.rb
new file mode 100644
index 0000000..f9e213b
--- /dev/null
+++ b/test/knn/knn_test.rb
@@ -0,0 +1,584 @@
+require_relative '../test_helper'
+
+class KNNTest < Minitest::Test
+ def setup
+ @str1 = 'This text deals with dogs. Dogs.'
+ @str2 = 'This text involves dogs too. Dogs!'
+ @str3 = 'This text revolves around cats. Cats.'
+ @str4 = 'This text also involves cats. Cats!'
+ @str5 = 'This text involves birds. Birds.'
+ end
+
+ # Initialization tests
+
+ def test_default_initialization
+ knn = Classifier::KNN.new
+
+ assert_equal 5, knn.k
+ refute knn.weighted
+ assert_empty knn.items
+ end
+
+ def test_custom_k_initialization
+ knn = Classifier::KNN.new(k: 3)
+
+ assert_equal 3, knn.k
+ end
+
+ def test_weighted_initialization
+ knn = Classifier::KNN.new(weighted: true)
+
+ assert knn.weighted
+ end
+
+ def test_invalid_k_raises_error
+ assert_raises(ArgumentError) { Classifier::KNN.new(k: 0) }
+ assert_raises(ArgumentError) { Classifier::KNN.new(k: -1) }
+ assert_raises(ArgumentError) { Classifier::KNN.new(k: 1.5) }
+ end
+
+ def test_k_setter
+ knn = Classifier::KNN.new(k: 5)
+ knn.k = 3
+
+ assert_equal 3, knn.k
+ end
+
+ def test_k_setter_validation
+ knn = Classifier::KNN.new
+
+ assert_raises(ArgumentError) { knn.k = 0 }
+ assert_raises(ArgumentError) { knn.k = -1 }
+ end
+
+ # Adding items tests
+
+ def test_add_with_hash_syntax
+ knn = Classifier::KNN.new
+ knn.add('Dog' => 'Dogs are loyal pets')
+ knn.add('Cat' => 'Cats are independent')
+
+ assert_equal 2, knn.items.size
+ assert_includes knn.items, 'Dogs are loyal pets'
+ assert_includes knn.items, 'Cats are independent'
+ end
+
+ def test_add_with_symbol_keys
+ knn = Classifier::KNN.new
+ knn.add(Dog: 'Dogs are loyal', Cat: 'Cats are independent')
+
+ assert_equal 2, knn.items.size
+ assert_equal ['Dog'], knn.categories_for('Dogs are loyal')
+ assert_equal ['Cat'], knn.categories_for('Cats are independent')
+ end
+
+ def test_add_multiple_items_same_category
+ knn = Classifier::KNN.new
+ knn.add('Dog' => ['Dogs are loyal', 'Puppies are cute', 'Canines are friendly'])
+
+ assert_equal 3, knn.items.size
+ assert_equal ['Dog'], knn.categories_for('Dogs are loyal')
+ assert_equal ['Dog'], knn.categories_for('Puppies are cute')
+ assert_equal ['Dog'], knn.categories_for('Canines are friendly')
+ end
+
+ def test_add_batch_operations
+ knn = Classifier::KNN.new
+ knn.add(
+ 'Dog' => ['Dogs are loyal', 'Puppies are cute'],
+ 'Cat' => ['Cats are independent', 'Kittens are playful']
+ )
+
+ assert_equal 4, knn.items.size
+ assert_equal ['Dog'], knn.categories_for('Dogs are loyal')
+ assert_equal ['Cat'], knn.categories_for('Cats are independent')
+ end
+
+ # Classification tests
+
+ def test_basic_classification
+ knn = Classifier::KNN.new(k: 3)
+ knn.add(
+ 'Dog' => [@str1, @str2],
+ 'Cat' => [@str3, @str4],
+ 'Bird' => @str5
+ )
+
+ assert_equal 'Dog', knn.classify('This is about dogs')
+ assert_equal 'Cat', knn.classify('This is about cats')
+ assert_equal 'Bird', knn.classify('This is about birds')
+ end
+
+ def test_classify_empty_classifier
+ knn = Classifier::KNN.new
+
+ assert_nil knn.classify('Some text')
+ end
+
+ def test_classify_with_k_larger_than_items
+ knn = Classifier::KNN.new(k: 10)
+ knn.add('Dog' => 'Dogs are pets')
+ knn.add('Cat' => 'Cats are pets')
+
+ # Should still work with fewer items than k
+ result = knn.classify('Dogs are great')
+
+ refute_nil result
+ end
+
+ # classify_with_neighbors tests
+
+ def test_classify_with_neighbors_structure
+ knn = Classifier::KNN.new(k: 3)
+ knn.add(
+ 'Dog' => [@str1, @str2],
+ 'Cat' => [@str3, @str4]
+ )
+
+ result = knn.classify_with_neighbors('Dogs are great pets')
+
+ assert_instance_of Hash, result
+ assert result.key?(:category)
+ assert result.key?(:neighbors)
+ assert result.key?(:votes)
+ assert result.key?(:confidence)
+ end
+
+ def test_classify_with_neighbors_returns_neighbors
+ knn = Classifier::KNN.new(k: 2)
+ knn.add(
+ 'Dog' => [@str1, @str2],
+ 'Cat' => @str3
+ )
+
+ result = knn.classify_with_neighbors('Dogs are great')
+
+ assert_equal 2, result[:neighbors].size
+ result[:neighbors].each do |neighbor|
+ assert neighbor.key?(:item)
+ assert neighbor.key?(:category)
+ assert neighbor.key?(:similarity)
+ end
+ end
+
+ def test_classify_with_neighbors_empty_classifier
+ knn = Classifier::KNN.new
+
+ result = knn.classify_with_neighbors('Some text')
+
+ assert_nil result[:category]
+ assert_empty result[:neighbors]
+ assert_empty result[:votes]
+ assert_in_delta(0.0, result[:confidence])
+ end
+
+ def test_classify_with_neighbors_confidence
+ knn = Classifier::KNN.new(k: 3)
+ knn.add(
+ 'Dog' => [@str1, @str2],
+ 'Cat' => @str3
+ )
+
+ result = knn.classify_with_neighbors('Dogs are wonderful')
+
+ assert_kind_of Float, result[:confidence]
+ assert_operator result[:confidence], :>=, 0.0
+ assert_operator result[:confidence], :<=, 1.0
+ end
+
+ # Weighted voting tests
+
+ def test_weighted_voting
+ knn = Classifier::KNN.new(k: 3, weighted: true)
+ knn.add(
+ 'Dog' => [@str1, @str2],
+ 'Cat' => [@str3, @str4]
+ )
+
+ result = knn.classify_with_neighbors('Dogs are great')
+
+ # Votes should be weighted by similarity
+ assert knn.weighted
+ # Weighted votes should have non-integer values
+ assert(result[:votes].values.any? { |v| v != v.to_i })
+ end
+
+ def test_unweighted_voting
+ knn = Classifier::KNN.new(k: 3, weighted: false)
+ knn.add(
+ 'Dog' => [@str1, @str2],
+ 'Cat' => @str3
+ )
+
+ result = knn.classify_with_neighbors('Dogs are great')
+
+ # Unweighted votes should be integers (counts)
+ result[:votes].each_value do |vote|
+ assert_equal vote.to_i.to_f, vote
+ end
+ end
+
+ # Categories tests
+
+ def test_categories
+ knn = Classifier::KNN.new
+ knn.add(
+ 'Dog' => 'Dogs are loyal',
+ 'Cat' => 'Cats are independent',
+ 'Bird' => 'Birds can fly'
+ )
+
+ cats = knn.categories
+
+ assert_equal 3, cats.size
+ assert_includes cats, 'Dog'
+ assert_includes cats, 'Cat'
+ assert_includes cats, 'Bird'
+ end
+
+ def test_categories_empty
+ knn = Classifier::KNN.new
+
+ assert_empty knn.categories
+ end
+
+ # Remove item tests
+
+ def test_remove_item
+ knn = Classifier::KNN.new
+ knn.add('Dog' => [@str1, @str2])
+
+ assert_equal 2, knn.items.size
+
+ knn.remove_item(@str1)
+
+ assert_equal 1, knn.items.size
+ refute_includes knn.items, @str1
+ end
+
+ def test_remove_nonexistent_item
+ knn = Classifier::KNN.new
+ knn.add('Dog' => @str1)
+
+ knn.remove_item('nonexistent')
+
+ assert_equal 1, knn.items.size
+ end
+
+ # Serialization tests
+
+ def test_as_json
+ knn = Classifier::KNN.new(k: 3, weighted: true)
+ knn.add('Dog' => @str1, 'Cat' => @str2)
+
+ data = knn.as_json
+
+ assert_instance_of Hash, data
+ assert_equal 1, data[:version]
+ assert_equal 'knn', data[:type]
+ assert_equal 3, data[:k]
+ assert data[:weighted]
+ assert data.key?(:lsi)
+ end
+
+ def test_to_json
+ knn = Classifier::KNN.new(k: 3)
+ knn.add('Dog' => @str1)
+
+ json = knn.to_json
+ data = JSON.parse(json)
+
+ assert_equal 'knn', data['type']
+ assert_equal 3, data['k']
+ end
+
+ def test_from_json_with_string
+ knn = Classifier::KNN.new(k: 3, weighted: true)
+ knn.add(
+ 'Dog' => [@str1, @str2],
+ 'Cat' => @str3
+ )
+
+ json = knn.to_json
+ loaded = Classifier::KNN.from_json(json)
+
+ assert_equal knn.k, loaded.k
+ assert_equal knn.weighted, loaded.weighted
+ assert_equal knn.items.sort, loaded.items.sort
+ assert_equal knn.classify('Dogs are great'), loaded.classify('Dogs are great')
+ end
+
+ def test_from_json_with_hash
+ knn = Classifier::KNN.new(k: 5)
+ knn.add('Dog' => @str1, 'Cat' => @str2)
+
+ hash = JSON.parse(knn.to_json)
+ loaded = Classifier::KNN.from_json(hash)
+
+ assert_equal knn.k, loaded.k
+ assert_equal knn.items.sort, loaded.items.sort
+ end
+
+ def test_from_json_invalid_type
+ invalid_json = { version: 1, type: 'invalid' }.to_json
+
+ assert_raises(ArgumentError) { Classifier::KNN.from_json(invalid_json) }
+ end
+
+ def test_save_and_load_from_file
+ knn = Classifier::KNN.new(k: 3, weighted: true)
+ knn.add(
+ 'Dog' => [@str1, @str2],
+ 'Cat' => [@str3, @str4]
+ )
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'knn.json')
+ knn.save_to_file(path)
+
+ assert_path_exists path
+
+ loaded = Classifier::KNN.load_from_file(path)
+
+ assert_equal knn.k, loaded.k
+ assert_equal knn.weighted, loaded.weighted
+ assert_equal knn.classify('Dogs are great'), loaded.classify('Dogs are great')
+ end
+ end
+
+ def test_save_load_preserves_classification
+ knn = Classifier::KNN.new(k: 3)
+ knn.add(
+ 'Dog' => [@str1, @str2],
+ 'Cat' => [@str3, @str4],
+ 'Bird' => @str5
+ )
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'knn.json')
+ knn.save_to_file(path)
+ loaded = Classifier::KNN.load_from_file(path)
+
+ assert_equal knn.classify(@str1), loaded.classify(@str1)
+ assert_equal knn.classify('Dogs are nice'), loaded.classify('Dogs are nice')
+ assert_equal knn.classify('Cats are cute'), loaded.classify('Cats are cute')
+ end
+ end
+
+ # Marshal tests
+
+ def test_marshal_dump_load
+ knn = Classifier::KNN.new(k: 3, weighted: true)
+ knn.add('Dog' => [@str1, @str2], 'Cat' => @str3)
+
+ dumped = Marshal.dump(knn)
+ loaded = Marshal.load(dumped)
+
+ assert_equal knn.k, loaded.k
+ assert_equal knn.weighted, loaded.weighted
+ assert_equal knn.items.sort, loaded.items.sort
+ assert_equal knn.classify('Dogs are great'), loaded.classify('Dogs are great')
+ end
+
+ # Dirty tracking tests
+
+ def test_dirty_after_add
+ knn = Classifier::KNN.new
+
+ refute_predicate knn, :dirty?
+
+ knn.add('Dog' => 'Dogs are great')
+
+ assert_predicate knn, :dirty?
+ end
+
+ def test_dirty_after_remove
+ knn = Classifier::KNN.new
+ knn.add('Dog' => 'Dogs are great')
+ knn.instance_variable_set(:@dirty, false)
+
+ knn.remove_item('Dogs are great')
+
+ assert_predicate knn, :dirty?
+ end
+
+ def test_save_clears_dirty
+ knn = Classifier::KNN.new
+ knn.add('Dog' => 'Dogs are great')
+
+ assert_predicate knn, :dirty?
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'knn.json')
+ knn.save_to_file(path)
+
+ refute_predicate knn, :dirty?
+ end
+ end
+
+ # Storage tests
+
+ def test_save_without_storage_raises
+ knn = Classifier::KNN.new
+
+ assert_raises(ArgumentError) { knn.save }
+ end
+
+ def test_reload_without_storage_raises
+ knn = Classifier::KNN.new
+
+ assert_raises(ArgumentError) { knn.reload }
+ end
+
+ def test_storage_save_and_load
+ knn = Classifier::KNN.new(k: 3)
+ knn.add('Dog' => @str1, 'Cat' => @str2)
+
+ storage = Classifier::Storage::Memory.new
+ knn.storage = storage
+ knn.save
+
+ loaded = Classifier::KNN.load(storage: storage)
+
+ assert_equal knn.k, loaded.k
+ assert_equal knn.items.sort, loaded.items.sort
+ end
+
+ def test_reload
+ storage = Classifier::Storage::Memory.new
+
+ knn = Classifier::KNN.new(k: 3)
+ knn.add('Dog' => @str1)
+ knn.storage = storage
+ knn.save
+
+ # Modify after save
+ knn.add('Cat' => @str2)
+
+ assert_equal 2, knn.items.size
+
+ # Reload should restore to saved state
+ knn.reload!
+
+ assert_equal 1, knn.items.size
+ assert_includes knn.items, @str1
+ end
+
+ def test_reload_with_unsaved_changes
+ storage = Classifier::Storage::Memory.new
+
+ knn = Classifier::KNN.new
+ knn.add('Dog' => @str1)
+ knn.storage = storage
+ knn.save
+
+ knn.add('Cat' => @str2)
+
+ assert_raises(Classifier::UnsavedChangesError) { knn.reload }
+ end
+
+ def test_reload_success
+ storage = Classifier::Storage::Memory.new
+
+ knn = Classifier::KNN.new(k: 3)
+ knn.add('Dog' => @str1)
+ knn.storage = storage
+ knn.save
+
+ # Modify but don't mark as dirty (simulate external change)
+ knn.instance_variable_set(:@dirty, false)
+
+ result = knn.reload
+
+ assert_same knn, result
+ refute_predicate knn, :dirty?
+ end
+
+ # Edge cases
+
+ def test_single_item_classification
+ knn = Classifier::KNN.new(k: 5)
+ knn.add('Dog' => 'Dogs are great')
+
+ result = knn.classify('Something about dogs')
+
+ assert_equal 'Dog', result
+ end
+
+ def test_classification_with_very_different_text
+ knn = Classifier::KNN.new(k: 3)
+ knn.add(
+ 'Dog' => [@str1, @str2],
+ 'Cat' => [@str3, @str4]
+ )
+
+ # Even very different text should return some classification
+ result = knn.classify('Completely unrelated computer programming text')
+
+ refute_nil result
+ end
+
+ def test_items_returns_copy
+ knn = Classifier::KNN.new
+ knn.add('Dog' => 'Dogs are great')
+
+ items = knn.items
+
+ # Modifying returned array shouldn't affect internal state
+ items.clear
+
+ assert_equal 1, knn.items.size
+ end
+
+ # API consistency tests (with Bayes and LogisticRegression)
+
+ def test_train_alias_for_add
+ knn = Classifier::KNN.new(k: 3)
+ knn.train(Dog: [@str1, @str2], Cat: [@str3, @str4])
+
+ assert_equal 4, knn.items.size
+ assert_equal 'Dog', knn.classify('This is about dogs')
+ assert_equal 'Cat', knn.classify('This is about cats')
+ end
+
+ def test_dynamic_train_methods
+ knn = Classifier::KNN.new(k: 3)
+ knn.train_dog @str1, @str2
+ knn.train_cat @str3, @str4
+
+ assert_equal 4, knn.items.size
+ # Dynamic methods create lowercase category names from method name
+ assert_equal 'dog', knn.classify('This is about dogs')
+ assert_equal 'cat', knn.classify('This is about cats')
+ end
+
+ def test_respond_to_train_methods
+ knn = Classifier::KNN.new
+
+ assert_respond_to knn, :train
+ assert_respond_to knn, :train_spam
+ assert_respond_to knn, :train_any_category
+ end
+
+ def test_classify_returns_string_not_symbol
+ knn = Classifier::KNN.new(k: 3)
+ knn.add(dog: [@str1, @str2], cat: [@str3, @str4]) # symbol keys
+
+ result = knn.classify('This is about dogs')
+
+ assert_instance_of String, result
+ assert_equal 'dog', result
+ end
+
+ def test_categories_returns_array_of_strings
+ knn = Classifier::KNN.new
+ knn.add(dog: 'Dogs are great', cat: 'Cats are independent')
+
+ cats = knn.categories
+
+ assert_instance_of Array, cats
+ cats.each { |cat| assert_instance_of String, cat }
+ assert_includes cats, 'dog'
+ assert_includes cats, 'cat'
+ end
+end
diff --git a/test/knn/streaming_test.rb b/test/knn/streaming_test.rb
new file mode 100644
index 0000000..f3cdd4d
--- /dev/null
+++ b/test/knn/streaming_test.rb
@@ -0,0 +1,37 @@
+require_relative '../test_helper'
+require 'stringio'
+
+class KNNStreamingTest < Minitest::Test
+ def test_train_from_stream_basic
+ knn = Classifier::KNN.new
+ knn.train_from_stream(:spam, StringIO.new("buy now cheap\nfree money\nlimited offer\n"))
+
+ assert_equal 'spam', knn.classify('buy cheap free')
+ end
+
+ def test_train_from_stream_many_categories
+ knn = Classifier::KNN.new
+ knn.train_from_stream(
+ spam: StringIO.new("buy now cheap\nfree money\nlimited offer\n"),
+ ham: StringIO.new("hello friend\nmeeting tomorrow\nhello fellow\n")
+ )
+
+ assert_equal 'spam', knn.classify('free offer')
+ assert_equal 'ham', knn.classify('hello')
+ end
+
+ def test_train_from_stream_invalid_io_type
+ knn = Classifier::KNN.new
+ assert_raises(ArgumentError) { knn.train_from_stream(spam: Object.new) }
+ end
+
+ def test_train_from_stream_raises_without_args
+ knn = Classifier::KNN.new
+ assert_raises(ArgumentError) { knn.train_from_stream }
+ end
+
+ def test_train_from_stream_raises_with_partial_args
+ knn = Classifier::KNN.new
+ assert_raises(ArgumentError) { knn.train_from_stream(:spam) }
+ end
+end
diff --git a/test/linalg/native_ext_test.rb b/test/linalg/native_ext_test.rb
new file mode 100644
index 0000000..9a1d9d8
--- /dev/null
+++ b/test/linalg/native_ext_test.rb
@@ -0,0 +1,196 @@
+require_relative '../test_helper'
+
+# Skip these tests if native extension isn't available
+return unless Classifier::LSI.backend == :native
+
+class NativeExtTest < Minitest::Test
+ def test_vector_alloc_with_size
+ v = Classifier::Linalg::Vector.alloc(5)
+
+ assert_equal 5, v.size
+ assert_equal [0.0, 0.0, 0.0, 0.0, 0.0], v.to_a
+ end
+
+ def test_vector_alloc_with_array
+ v = Classifier::Linalg::Vector.alloc([1.0, 2.0, 3.0])
+
+ assert_equal 3, v.size
+ assert_equal [1.0, 2.0, 3.0], v.to_a
+ end
+
+ def test_vector_element_access
+ v = Classifier::Linalg::Vector.alloc(3)
+ v[0] = 1.0
+ v[1] = 2.0
+ v[2] = 3.0
+
+ assert_in_delta(1.0, v[0])
+ assert_in_delta(2.0, v[1])
+ assert_in_delta(3.0, v[2])
+ end
+
+ def test_vector_sum
+ v = Classifier::Linalg::Vector.alloc([1.0, 2.0, 3.0, 4.0])
+
+ assert_in_delta(10.0, v.sum)
+ end
+
+ def test_vector_normalize
+ v = Classifier::Linalg::Vector.alloc([3.0, 4.0])
+ n = v.normalize
+
+ assert_in_delta 0.6, n[0], 0.0001
+ assert_in_delta 0.8, n[1], 0.0001
+ end
+
+ def test_vector_normalize_zero
+ v = Classifier::Linalg::Vector.alloc([0.0, 0.0, 0.0])
+ n = v.normalize
+
+ assert_equal [0.0, 0.0, 0.0], n.to_a
+ end
+
+ def test_vector_each
+ v = Classifier::Linalg::Vector.alloc([1.0, 2.0, 3.0])
+ values = []
+ v.each { |x| values << x } # rubocop:disable Style/MapIntoArray
+
+ assert_equal [1.0, 2.0, 3.0], values
+ end
+
+ def test_vector_collect
+ v = Classifier::Linalg::Vector.alloc([1.0, 2.0, 3.0])
+ v2 = v.collect { |x| x * 2 }
+
+ assert_equal [2.0, 4.0, 6.0], v2.to_a
+ end
+
+ def test_vector_row_col
+ v = Classifier::Linalg::Vector.alloc([1.0, 2.0])
+ row = v.row
+ col = v.col
+
+ assert_equal [1.0, 2.0], row.to_a
+ assert_equal [1.0, 2.0], col.to_a
+ end
+
+ def test_vector_dot_product
+ v1 = Classifier::Linalg::Vector.alloc([1.0, 2.0, 3.0])
+ v2 = Classifier::Linalg::Vector.alloc([4.0, 5.0, 6.0])
+
+ assert_in_delta(32.0, v1 * v2)
+ end
+
+ def test_vector_scalar_multiply
+ v = Classifier::Linalg::Vector.alloc([1.0, 2.0, 3.0])
+ v2 = v * 2.0
+
+ assert_equal [2.0, 4.0, 6.0], v2.to_a
+ end
+
+ def test_matrix_alloc
+ m = Classifier::Linalg::Matrix.alloc([1.0, 2.0], [3.0, 4.0])
+
+ assert_equal [2, 2], m.size
+ assert_in_delta(1.0, m[0, 0])
+ assert_in_delta(2.0, m[0, 1])
+ assert_in_delta(3.0, m[1, 0])
+ assert_in_delta(4.0, m[1, 1])
+ end
+
+ def test_matrix_transpose
+ m = Classifier::Linalg::Matrix.alloc([1.0, 2.0, 3.0], [4.0, 5.0, 6.0])
+ t = m.trans
+
+ assert_equal [3, 2], t.size
+ assert_equal [[1.0, 4.0], [2.0, 5.0], [3.0, 6.0]], t.to_a
+ end
+
+ def test_matrix_column
+ m = Classifier::Linalg::Matrix.alloc([1.0, 2.0], [3.0, 4.0], [5.0, 6.0])
+ col = m.column(0)
+
+ assert_equal [1.0, 3.0, 5.0], col.to_a
+ col = m.column(1)
+
+ assert_equal [2.0, 4.0, 6.0], col.to_a
+ end
+
+ def test_matrix_diag
+ m = Classifier::Linalg::Matrix.diag([1.0, 2.0, 3.0])
+
+ assert_equal [3, 3], m.size
+ assert_in_delta(1.0, m[0, 0])
+ assert_in_delta(2.0, m[1, 1])
+ assert_in_delta(3.0, m[2, 2])
+ assert_in_delta(0.0, m[0, 1])
+ end
+
+ def test_matrix_multiply
+ a = Classifier::Linalg::Matrix.alloc([1.0, 2.0], [3.0, 4.0])
+ b = Classifier::Linalg::Matrix.alloc([5.0, 6.0], [7.0, 8.0])
+ c = a * b
+
+ assert_equal [[19.0, 22.0], [43.0, 50.0]], c.to_a
+ end
+
+ def test_matrix_vector_multiply
+ m = Classifier::Linalg::Matrix.alloc([1.0, 2.0], [3.0, 4.0])
+ v = Classifier::Linalg::Vector.alloc([1.0, 2.0])
+ result = m * v
+
+ assert_equal [5.0, 11.0], result.to_a
+ end
+
+ def test_svd_basic
+ m = Classifier::Linalg::Matrix.alloc([1.0, 2.0], [3.0, 4.0])
+ u, v, s = m.SV_decomp
+
+ # Verify dimensions
+ assert_equal 2, s.size
+
+ # Verify reconstruction: A ≈ U * diag(S) * V^T
+ s_diag = Classifier::Linalg::Matrix.diag(s.to_a)
+ reconstructed = u * s_diag * v.trans
+
+ # Check that reconstruction is close to original
+ 2.times do |i|
+ 2.times do |j|
+ assert_in_delta m[i, j], reconstructed[i, j], 0.0001,
+ "Element [#{i},#{j}] mismatch in reconstruction"
+ end
+ end
+ end
+
+ def test_svd_rectangular_tall
+ # More rows than columns
+ m = Classifier::Linalg::Matrix.alloc([1.0, 2.0], [3.0, 4.0], [5.0, 6.0])
+ _u, _v, s = m.SV_decomp
+
+ assert_equal 2, s.size
+ end
+
+ def test_svd_rectangular_wide
+ # More columns than rows
+ m = Classifier::Linalg::Matrix.alloc([1.0, 2.0, 3.0], [4.0, 5.0, 6.0])
+ _u, _v, s = m.SV_decomp
+
+ assert_equal 2, s.size
+ end
+
+ def test_vector_marshal
+ v = Classifier::Linalg::Vector.alloc([1.0, 2.0, 3.0])
+ dumped = Marshal.dump(v)
+ loaded = Marshal.load(dumped)
+
+ assert_equal v.to_a, loaded.to_a
+ end
+
+ def test_matrix_marshal
+ m = Classifier::Linalg::Matrix.alloc([1.0, 2.0], [3.0, 4.0])
+ dumped = Marshal.dump(m)
+ loaded = Marshal.load(dumped)
+
+ assert_equal m.to_a, loaded.to_a
+ end
+end
diff --git a/test/logistic_regression/logistic_regression_test.rb b/test/logistic_regression/logistic_regression_test.rb
new file mode 100644
index 0000000..73e0bf4
--- /dev/null
+++ b/test/logistic_regression/logistic_regression_test.rb
@@ -0,0 +1,597 @@
+require_relative '../test_helper'
+
+class LogisticRegressionTest < Minitest::Test
+ def setup
+ @classifier = Classifier::LogisticRegression.new 'Spam', 'Ham'
+ end
+
+ # Helper: train and fit for tests that need classification
+ def train_and_fit
+ @classifier.train_spam 'buy now free money offer limited'
+ @classifier.train_ham 'hello friend meeting project update'
+ @classifier.fit
+ end
+
+ # Initialization tests
+
+ def test_requires_at_least_two_categories_at_fit
+ classifier = Classifier::LogisticRegression.new 'Only'
+ classifier.train(:only, 'test text')
+
+ # Error raised at fit time, not initialization
+ assert_raises(ArgumentError) { classifier.fit }
+ end
+
+ def test_accepts_symbols_and_strings
+ classifier1 = Classifier::LogisticRegression.new :spam, :ham
+ classifier2 = Classifier::LogisticRegression.new 'Spam', 'Ham'
+
+ assert_equal %w[Spam Ham].sort, classifier1.categories.sort
+ assert_equal %w[Spam Ham].sort, classifier2.categories.sort
+ end
+
+ def test_accepts_array_of_categories
+ classifier = Classifier::LogisticRegression.new(%w[Spam Ham])
+
+ assert_equal %w[Ham Spam], classifier.categories.sort
+ end
+
+ def test_array_initialization_requires_at_least_two_at_fit
+ classifier = Classifier::LogisticRegression.new(['Only'])
+ classifier.train(:only, 'test text')
+
+ # Error raised at fit time, not initialization
+ assert_raises(ArgumentError) { classifier.fit }
+ end
+
+ def test_custom_hyperparameters
+ classifier = Classifier::LogisticRegression.new(
+ :spam, :ham,
+ learning_rate: 0.01,
+ regularization: 0.1,
+ max_iterations: 50,
+ tolerance: 1e-6
+ )
+
+ assert_instance_of Classifier::LogisticRegression, classifier
+ end
+
+ def test_initialization_with_min_word_length
+ classifier = Classifier::LogisticRegression.new(%w[Spam Ham], min_word_length: 5)
+
+ classifier.train_spam 'bad nasty spam email'
+ classifier.train_ham 'good legitimate email'
+ classifier.fit
+
+ assert_equal 'Spam', classifier.classify('nasty text')
+ assert_equal 'Ham', classifier.classify('legitimate text')
+ assert_in_delta 0.5, classifier.probabilities('good text')['Spam'], 0.02
+ end
+
+ def test_categories
+ assert_equal %w[Spam Ham].sort, @classifier.categories.sort
+ end
+
+ # Training tests
+
+ def test_train_with_positional_arguments
+ @classifier.train :spam, 'Buy now! Free money!'
+ @classifier.train :ham, 'Hello friend, meeting tomorrow'
+ @classifier.fit
+
+ assert_equal 'Spam', @classifier.classify('Buy free money')
+ assert_equal 'Ham', @classifier.classify('Hello meeting friend')
+ end
+
+ def test_train_with_keyword_arguments
+ @classifier.train(spam: 'Buy now! Free money!')
+ @classifier.train(ham: 'Hello friend, meeting tomorrow')
+ @classifier.fit
+
+ assert_equal 'Spam', @classifier.classify('Buy free money')
+ assert_equal 'Ham', @classifier.classify('Hello meeting friend')
+ end
+
+ def test_train_with_array_value
+ @classifier.train(spam: ['Buy now!', 'Free money!', 'Click here!'])
+ @classifier.train(ham: 'Normal email content')
+ @classifier.fit
+
+ assert_equal 'Spam', @classifier.classify('Buy click free')
+ end
+
+ def test_train_with_multiple_categories
+ @classifier.train(
+ spam: ['Buy now!', 'Free money!'],
+ ham: ['Hello friend', 'Meeting tomorrow']
+ )
+ @classifier.fit
+
+ assert_equal 'Spam', @classifier.classify('Buy free')
+ assert_equal 'Ham', @classifier.classify('Hello meeting')
+ end
+
+ def test_train_dynamic_method
+ @classifier.train_spam 'Buy now! Free money!'
+ @classifier.train_ham 'Hello friend'
+ @classifier.fit
+
+ assert_equal 'Spam', @classifier.classify('Buy free money')
+ assert_equal 'Ham', @classifier.classify('Hello friend')
+ end
+
+ def test_train_invalid_category
+ assert_raises(StandardError) { @classifier.train(:invalid, 'text') }
+ assert_raises(StandardError) { @classifier.train_invalid 'text' }
+ end
+
+ # Classification tests
+
+ def test_classify_basic
+ @classifier.train_spam 'Buy now! Free money! Limited offer!'
+ @classifier.train_ham 'Hello, how are you? Meeting tomorrow.'
+ @classifier.fit
+
+ assert_equal 'Spam', @classifier.classify('Free money offer')
+ assert_equal 'Ham', @classifier.classify('Hello, how are you?')
+ end
+
+ def test_classify_with_more_training_data
+ # Spam examples
+ @classifier.train(spam: [
+ 'Buy now and save!',
+ 'Free money waiting for you',
+ 'Click here for amazing deals',
+ 'Limited time offer expires soon',
+ 'Congratulations you won a prize'
+ ])
+
+ # Ham examples
+ @classifier.train(ham: [
+ 'Meeting scheduled for tomorrow at 10am',
+ 'Please review the attached document',
+ 'Can we discuss the project timeline?',
+ 'Thanks for your help yesterday',
+ 'Looking forward to seeing you'
+ ])
+ @classifier.fit
+
+ assert_equal 'Spam', @classifier.classify('Free prize money')
+ assert_equal 'Ham', @classifier.classify('Project meeting tomorrow')
+ end
+
+ def test_classifications_returns_scores
+ @classifier.train_spam 'spam words'
+ @classifier.train_ham 'ham words'
+ @classifier.fit
+
+ scores = @classifier.classifications('spam words')
+
+ assert_instance_of Hash, scores
+ assert_includes scores.keys, 'Spam'
+ assert_includes scores.keys, 'Ham'
+ scores.each_value { |v| assert_kind_of Numeric, v }
+ end
+
+ # Probability tests
+
+ def test_probabilities_sum_to_one
+ @classifier.train_spam 'spam words here'
+ @classifier.train_ham 'ham words here'
+ @classifier.fit
+
+ probs = @classifier.probabilities('test words')
+
+ assert_in_delta 1.0, probs.values.sum, 0.001
+ end
+
+ def test_probabilities_are_between_zero_and_one
+ @classifier.train_spam 'spam words here'
+ @classifier.train_ham 'ham words here'
+ @classifier.fit
+
+ probs = @classifier.probabilities('test words')
+
+ probs.each_value do |prob|
+ assert_operator prob, :>=, 0.0
+ assert_operator prob, :<=, 1.0
+ end
+ end
+
+ def test_probabilities_reflect_confidence
+ @classifier.train(spam: ['spam spam spam'] * 10)
+ @classifier.train(ham: ['ham ham ham'] * 10)
+ @classifier.fit
+
+ spam_probs = @classifier.probabilities('spam spam spam')
+ ham_probs = @classifier.probabilities('ham ham ham')
+
+ assert_operator spam_probs['Spam'], :>, 0.5, 'Spam text should have high spam probability'
+ assert_operator ham_probs['Ham'], :>, 0.5, 'Ham text should have high ham probability'
+ end
+
+ # Feature weights tests
+
+ def test_weights_returns_hash
+ @classifier.train_spam 'buy free money'
+ @classifier.train_ham 'hello friend meeting'
+ @classifier.fit
+
+ weights = @classifier.weights(:spam)
+
+ assert_instance_of Hash, weights
+ refute_empty weights
+ end
+
+ def test_weights_sorted_by_importance
+ @classifier.train_spam 'spam spam spam important'
+ @classifier.train_ham 'ham ham ham'
+ @classifier.fit
+
+ weights = @classifier.weights(:spam)
+ values = weights.values
+
+ # Should be sorted by absolute value (descending)
+ sorted_by_abs = values.sort_by { |v| -v.abs }
+
+ assert_equal sorted_by_abs, values
+ end
+
+ def test_weights_with_limit
+ @classifier.train_spam 'one two three four five'
+ @classifier.train_ham 'six seven eight nine ten'
+ @classifier.fit
+
+ weights = @classifier.weights(:spam, limit: 3)
+
+ assert_equal 3, weights.size
+ end
+
+ def test_weights_invalid_category
+ @classifier.train_spam 'spam'
+ @classifier.train_ham 'ham'
+ @classifier.fit
+
+ assert_raises(StandardError) { @classifier.weights(:invalid) }
+ end
+
+ # Fitted state tests
+
+ def test_fitted_state
+ refute_predicate @classifier, :fitted?
+
+ @classifier.train_spam 'spam'
+ @classifier.train_ham 'ham'
+
+ refute_predicate @classifier, :fitted?
+
+ @classifier.fit
+ @classifier.classify('test')
+
+ assert_predicate @classifier, :fitted?
+ end
+
+ def test_fit_explicitly
+ @classifier.train_spam 'spam'
+ @classifier.train_ham 'ham'
+
+ result = @classifier.fit
+
+ assert_same @classifier, result
+ assert_predicate @classifier, :fitted?
+ end
+
+ def test_classify_without_fit_raises_error
+ @classifier.train_spam 'spam'
+ @classifier.train_ham 'ham'
+
+ refute_predicate @classifier, :fitted?
+ assert_raises(Classifier::NotFittedError) { @classifier.classify('test') }
+ end
+
+ def test_probabilities_without_fit_raises_error
+ @classifier.train_spam 'spam'
+ @classifier.train_ham 'ham'
+
+ refute_predicate @classifier, :fitted?
+ assert_raises(Classifier::NotFittedError) { @classifier.probabilities('test') }
+ end
+
+ def test_explicit_fit_enables_classify
+ @classifier.train_spam 'spam'
+ @classifier.train_ham 'ham'
+
+ refute_predicate @classifier, :fitted?
+ @classifier.fit
+
+ assert_predicate @classifier, :fitted?
+ assert_equal 'Spam', @classifier.classify('spam words')
+ end
+
+ # Multi-class tests
+
+ def test_three_class_classification
+ classifier = Classifier::LogisticRegression.new :positive, :negative, :neutral
+
+ classifier.train(positive: ['great amazing wonderful love happy'])
+ classifier.train(negative: ['terrible awful hate bad angry'])
+ classifier.train(neutral: ['okay average normal regular'])
+ classifier.fit
+
+ assert_equal 'Positive', classifier.classify('great love happy')
+ assert_equal 'Negative', classifier.classify('terrible hate angry')
+ assert_equal 'Neutral', classifier.classify('normal regular okay')
+ end
+
+ def test_multi_class_probabilities_sum_to_one
+ classifier = Classifier::LogisticRegression.new :a, :b, :c, :d
+
+ classifier.train(a: 'alpha', b: 'beta', c: 'gamma', d: 'delta')
+ classifier.fit
+
+ probs = classifier.probabilities('test')
+
+ assert_in_delta 1.0, probs.values.sum, 0.001
+ end
+
+ # Serialization tests
+
+ def test_as_json
+ @classifier.train_spam 'spam words'
+ @classifier.train_ham 'ham words'
+ @classifier.fit
+
+ data = @classifier.as_json
+
+ assert_equal 1, data[:version]
+ assert_equal 'logistic_regression', data[:type]
+ assert_includes data[:categories], 'Spam'
+ assert_includes data[:categories], 'Ham'
+ assert_instance_of Hash, data[:weights]
+ assert_instance_of Hash, data[:bias]
+ end
+
+ def test_to_json
+ @classifier.train_spam 'spam words'
+ @classifier.train_ham 'ham words'
+ @classifier.fit
+
+ json = @classifier.to_json
+ data = JSON.parse(json)
+
+ assert_equal 'logistic_regression', data['type']
+ end
+
+ def test_from_json_with_string
+ @classifier.train_spam 'spam words'
+ @classifier.train_ham 'ham words'
+ @classifier.fit
+
+ json = @classifier.to_json
+ loaded = Classifier::LogisticRegression.from_json(json)
+
+ assert_equal @classifier.categories.sort, loaded.categories.sort
+ assert_equal @classifier.classify('spam'), loaded.classify('spam')
+ assert_equal @classifier.classify('ham'), loaded.classify('ham')
+ end
+
+ def test_from_json_with_hash
+ @classifier.train_spam 'spam words'
+ @classifier.train_ham 'ham words'
+ @classifier.fit
+
+ hash = JSON.parse(@classifier.to_json)
+ loaded = Classifier::LogisticRegression.from_json(hash)
+
+ assert_equal @classifier.categories.sort, loaded.categories.sort
+ end
+
+ def test_from_json_invalid_type
+ invalid_json = { version: 1, type: 'invalid' }.to_json
+
+ assert_raises(ArgumentError) { Classifier::LogisticRegression.from_json(invalid_json) }
+ end
+
+ def test_save_and_load_file
+ @classifier.train_spam 'spam words'
+ @classifier.train_ham 'ham words'
+ @classifier.fit
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ @classifier.save_to_file(path)
+
+ assert_path_exists path
+
+ loaded = Classifier::LogisticRegression.load_from_file(path)
+
+ assert_equal @classifier.categories.sort, loaded.categories.sort
+ assert_equal @classifier.classify('spam'), loaded.classify('spam')
+ end
+ end
+
+ def test_loaded_classifier_preserves_predictions
+ @classifier.train(spam: ['buy free money offer'] * 5)
+ @classifier.train(ham: ['hello meeting project friend'] * 5)
+ @classifier.fit
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ @classifier.save_to_file(path)
+ loaded = Classifier::LogisticRegression.load_from_file(path)
+
+ # Check that loaded classifier makes same predictions
+ test_texts = ['buy free', 'hello meeting', 'money offer', 'project friend']
+
+ test_texts.each do |text|
+ assert_equal @classifier.classify(text), loaded.classify(text)
+ end
+ end
+ end
+
+ # Marshal tests
+
+ def test_marshal_dump_and_load
+ @classifier.train_spam 'spam words'
+ @classifier.train_ham 'ham words'
+ @classifier.fit
+
+ dumped = Marshal.dump(@classifier)
+ loaded = Marshal.load(dumped)
+
+ assert_equal @classifier.categories.sort, loaded.categories.sort
+ assert_equal @classifier.classify('spam'), loaded.classify('spam')
+ assert_equal @classifier.classify('ham'), loaded.classify('ham')
+ end
+
+ # Dirty flag tests
+
+ def test_dirty_flag_after_training
+ refute_predicate @classifier, :dirty?
+
+ @classifier.train_spam 'spam'
+
+ assert_predicate @classifier, :dirty?
+ end
+
+ def test_dirty_flag_cleared_after_save
+ @classifier.train_spam 'spam'
+ @classifier.train_ham 'ham'
+
+ assert_predicate @classifier, :dirty?
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ @classifier.save_to_file(path)
+
+ refute_predicate @classifier, :dirty?
+ end
+ end
+
+ # Edge case tests
+
+ def test_empty_string_training
+ @classifier.train_spam ''
+ @classifier.train_ham 'ham words'
+ @classifier.fit
+
+ # Should not crash
+ result = @classifier.classify('test')
+
+ assert_includes @classifier.categories, result
+ end
+
+ def test_empty_string_classification
+ @classifier.train_spam 'spam words'
+ @classifier.train_ham 'ham words'
+ @classifier.fit
+
+ result = @classifier.classify('')
+
+ assert_includes @classifier.categories, result
+ end
+
+ def test_unicode_text
+ @classifier.train_spam 'spam japonais 日本語'
+ @classifier.train_ham 'ham chinese 中文'
+ @classifier.fit
+
+ # Should handle unicode without crashing
+ result = @classifier.classify('日本語 test')
+
+ assert_includes @classifier.categories, result
+ end
+
+ def test_single_word_documents
+ @classifier.train_spam 'spam'
+ @classifier.train_ham 'ham'
+ @classifier.fit
+
+ assert_equal 'Spam', @classifier.classify('spam')
+ assert_equal 'Ham', @classifier.classify('ham')
+ end
+
+ def test_very_long_text
+ long_spam = 'spam buy free money ' * 100
+ long_ham = 'hello meeting project ' * 100
+
+ @classifier.train_spam long_spam
+ @classifier.train_ham long_ham
+ @classifier.fit
+
+ assert_equal 'Spam', @classifier.classify('buy free money')
+ end
+
+ def test_special_characters
+ @classifier.train_spam 'Buy! @#$% now!!!'
+ @classifier.train_ham 'Hello... how are you???'
+ @classifier.fit
+
+ # Should not crash on special characters
+ @classifier.classify('!@#$%^&*()')
+ end
+
+ # Numerical stability tests
+
+ def test_softmax_numerical_stability
+ # Train with many samples to potentially create large scores
+ 100.times do
+ @classifier.train_spam 'spam spam spam spam spam'
+ @classifier.train_ham 'ham ham ham ham ham'
+ end
+ @classifier.fit
+
+ probs = @classifier.probabilities('spam spam spam')
+
+ # Should not be NaN or Inf
+ probs.each_value do |p|
+ refute_predicate p, :nan?
+ refute_predicate p, :infinite?
+ end
+ end
+
+ # Regularization tests
+
+ def test_regularization_prevents_overfitting
+ # Without regularization, weights could become very large
+ classifier = Classifier::LogisticRegression.new(
+ :spam, :ham,
+ regularization: 1.0 # Strong regularization
+ )
+
+ classifier.train_spam 'unique_spam_word'
+ classifier.train_ham 'unique_ham_word'
+ classifier.fit
+
+ weights = classifier.weights(:spam)
+
+ # With strong regularization, weights should be relatively small
+ weights.each_value do |w|
+ assert_operator w.abs, :<, 10.0, 'Weights should be constrained by regularization'
+ end
+ end
+
+ # Convergence tests
+
+ def test_convergence_with_separable_data
+ # Clear separation between classes should converge quickly
+ @classifier.train(spam: ['spam spam spam'] * 20)
+ @classifier.train(ham: ['ham ham ham'] * 20)
+ @classifier.fit
+
+ # Should be able to perfectly classify training data
+ probs = @classifier.probabilities('spam spam spam')
+
+ assert_operator probs['Spam'], :>, 0.9
+ end
+
+ # respond_to? tests
+
+ def test_respond_to_train_methods
+ assert_respond_to @classifier, :train_spam
+ assert_respond_to @classifier, :train_ham
+ # train_* methods respond true (dynamic methods), but raise on invalid categories
+ assert_respond_to @classifier, :train_invalid
+ refute_respond_to @classifier, :invalid_method
+ end
+end
diff --git a/test/logistic_regression/streaming_test.rb b/test/logistic_regression/streaming_test.rb
new file mode 100644
index 0000000..2fd542b
--- /dev/null
+++ b/test/logistic_regression/streaming_test.rb
@@ -0,0 +1,39 @@
+require_relative '../test_helper'
+require 'stringio'
+
+class LogisticRegressionStreamingTest < Minitest::Test
+ def test_train_from_stream_basic
+ classifier = Classifier::LogisticRegression.new('Spam', 'Ham')
+ classifier.train_from_stream(:spam, StringIO.new("buy now cheap\nfree money\nlimited offer\n"))
+ classifier.fit
+
+ assert_equal 'Spam', classifier.classify('buy cheap free')
+ end
+
+ def test_train_from_stream_many_categories
+ classifier = Classifier::LogisticRegression.new('Spam', 'Ham')
+ classifier.train_from_stream(
+ spam: StringIO.new("buy now cheap\nfree money\nlimited offer\n"),
+ ham: StringIO.new("hello friend\nmeeting tomorrow\n")
+ )
+ classifier.fit
+
+ assert_equal 'Spam', classifier.classify('buy free')
+ assert_equal 'Ham', classifier.classify('hello meeting')
+ end
+
+ def test_train_from_stream_invalid_io_type
+ classifier = Classifier::LogisticRegression.new('Spam', 'Ham')
+ assert_raises(ArgumentError) { classifier.train_from_stream(spam: Object.new) }
+ end
+
+ def test_train_from_stream_raises_without_args
+ classifier = Classifier::LogisticRegression.new('Spam', 'Ham')
+ assert_raises(ArgumentError) { classifier.train_from_stream }
+ end
+
+ def test_train_from_stream_raises_with_partial_args
+ classifier = Classifier::LogisticRegression.new('Spam', 'Ham')
+ assert_raises(ArgumentError) { classifier.train_from_stream(:spam) }
+ end
+end
diff --git a/test/lsi/incremental_svd_test.rb b/test/lsi/incremental_svd_test.rb
new file mode 100644
index 0000000..9ddfbcc
--- /dev/null
+++ b/test/lsi/incremental_svd_test.rb
@@ -0,0 +1,304 @@
+require_relative '../test_helper'
+
+class IncrementalSVDModuleTest < Minitest::Test
+ def test_update_with_new_direction
+ # Create a simple U matrix (3 terms × 2 components)
+ u = Matrix[
+ [0.5, 0.5],
+ [0.5, -0.5],
+ [0.707, 0.0]
+ ]
+ s = [2.0, 1.0]
+
+ # New document vector (3 terms)
+ c = Vector[0.1, 0.2, 0.9]
+
+ u_new, s_new = Classifier::LSI::IncrementalSVD.update(u, s, c, max_rank: 10)
+
+ # Should have updated matrices
+ assert_instance_of Matrix, u_new
+ assert_instance_of Array, s_new
+
+ # Rank may have increased by 1 (if new direction found)
+ assert_operator s_new.size, :>=, s.size
+ assert_operator s_new.size, :<=, s.size + 1
+ end
+
+ def test_update_preserves_orthogonality
+ # Start with orthonormal U
+ u = Matrix[
+ [1.0, 0.0],
+ [0.0, 1.0],
+ [0.0, 0.0]
+ ]
+ s = [2.0, 1.5]
+
+ c = Vector[0.5, 0.5, 0.707]
+
+ u_new, _s_new = Classifier::LSI::IncrementalSVD.update(u, s, c, max_rank: 10)
+
+ # Check columns are approximately orthonormal
+ u_new.column_size.times do |i|
+ col_i = u_new.column(i)
+ # Column should have unit length (approximately)
+ norm = Math.sqrt(col_i.to_a.sum { |x| x * x })
+
+ assert_in_delta 1.0, norm, 0.1, "Column #{i} should have unit length"
+ end
+ end
+
+ def test_update_with_vector_in_span
+ # U spans the first two dimensions
+ u = Matrix[
+ [1.0, 0.0],
+ [0.0, 1.0],
+ [0.0, 0.0]
+ ]
+ s = [2.0, 1.0]
+
+ # Vector entirely in span of U (no component in third dimension)
+ c = Vector[0.6, 0.8, 0.0]
+
+ _u_new, s_new = Classifier::LSI::IncrementalSVD.update(u, s, c, max_rank: 10)
+
+ # Rank should not increase when vector is in span
+ assert_equal s.size, s_new.size
+ end
+
+ def test_update_respects_max_rank
+ u = Matrix[
+ [1.0, 0.0],
+ [0.0, 1.0],
+ [0.0, 0.0]
+ ]
+ s = [2.0, 1.0]
+
+ c = Vector[0.1, 0.1, 0.99]
+
+ # With max_rank = 2, should not exceed 2 components
+ u_new, s_new = Classifier::LSI::IncrementalSVD.update(u, s, c, max_rank: 2)
+
+ assert_equal 2, s_new.size
+ assert_equal 2, u_new.column_size
+ end
+
+ def test_singular_values_sorted_descending
+ u = Matrix[
+ [0.707, 0.707],
+ [0.707, -0.707],
+ [0.0, 0.0]
+ ]
+ s = [3.0, 1.0]
+
+ c = Vector[0.5, 0.5, 0.707]
+
+ _u_new, s_new = Classifier::LSI::IncrementalSVD.update(u, s, c, max_rank: 10)
+
+ # Singular values should be sorted in descending order
+ (0...(s_new.size - 1)).each do |i|
+ assert_operator s_new[i], :>=, s_new[i + 1], 'Singular values should be descending'
+ end
+ end
+end
+
+class LSIIncrementalModeTest < Minitest::Test
+ def setup
+ @dog_docs = [
+ 'dogs are loyal pets that bark',
+ 'puppies are playful young dogs',
+ 'dogs love to play fetch'
+ ]
+ @cat_docs = [
+ 'cats are independent pets',
+ 'kittens are curious creatures',
+ 'cats meow and purr softly'
+ ]
+ end
+
+ def test_incremental_mode_initialization
+ lsi = Classifier::LSI.new(incremental: true)
+
+ assert lsi.instance_variable_get(:@incremental_mode)
+ refute_predicate lsi, :incremental_enabled? # Not active until first build
+ end
+
+ def test_incremental_mode_with_max_rank
+ lsi = Classifier::LSI.new(incremental: true, max_rank: 50)
+
+ assert_equal 50, lsi.instance_variable_get(:@max_rank)
+ end
+
+ def test_incremental_enabled_after_build
+ lsi = Classifier::LSI.new(incremental: true, auto_rebuild: false)
+
+ @dog_docs.each { |doc| lsi.add_item(doc, :dog) }
+ @cat_docs.each { |doc| lsi.add_item(doc, :cat) }
+
+ refute_predicate lsi, :incremental_enabled?
+
+ lsi.build_index
+
+ assert_predicate lsi, :incremental_enabled?
+ assert_instance_of Matrix, lsi.instance_variable_get(:@u_matrix)
+ end
+
+ def test_incremental_add_uses_incremental_update
+ lsi = Classifier::LSI.new(incremental: true, auto_rebuild: false)
+
+ # Add initial documents
+ @dog_docs.each { |doc| lsi.add_item(doc, :dog) }
+ @cat_docs.each { |doc| lsi.add_item(doc, :cat) }
+ lsi.build_index
+
+ initial_version = lsi.instance_variable_get(:@version)
+
+ # Add new document - should use incremental update
+ lsi.add_item('my dog loves to run and play', :dog)
+
+ # Version should have incremented
+ assert_equal initial_version + 1, lsi.instance_variable_get(:@version)
+
+ # Should still be in incremental mode
+ assert_predicate lsi, :incremental_enabled?
+ end
+
+ def test_incremental_classification_works
+ lsi = Classifier::LSI.new(incremental: true, auto_rebuild: false)
+
+ @dog_docs.each { |doc| lsi.add_item(doc, :dog) }
+ @cat_docs.each { |doc| lsi.add_item(doc, :cat) }
+ lsi.build_index
+
+ # Add more documents incrementally
+ lsi.add_item('dogs are wonderful companions', :dog)
+ lsi.add_item('cats sleep a lot during the day', :cat)
+
+ # Classification should work
+ result = lsi.classify('loyal pet that barks').to_s
+
+ assert_equal 'dog', result
+
+ result = lsi.classify('independent creature that meows').to_s
+
+ assert_equal 'cat', result
+ end
+
+ def test_current_rank
+ lsi = Classifier::LSI.new(incremental: true, auto_rebuild: false)
+
+ @dog_docs.each { |doc| lsi.add_item(doc, :dog) }
+ @cat_docs.each { |doc| lsi.add_item(doc, :cat) }
+ lsi.build_index
+
+ rank = lsi.current_rank
+
+ assert_instance_of Integer, rank
+ assert_predicate rank, :positive?
+ end
+
+ def test_disable_incremental_mode
+ lsi = Classifier::LSI.new(incremental: true, auto_rebuild: false)
+
+ @dog_docs.each { |doc| lsi.add_item(doc, :dog) }
+ lsi.build_index
+
+ assert_predicate lsi, :incremental_enabled?
+
+ lsi.disable_incremental_mode!
+
+ refute_predicate lsi, :incremental_enabled?
+ assert_nil lsi.instance_variable_get(:@u_matrix)
+ end
+
+ def test_enable_incremental_mode
+ lsi = Classifier::LSI.new(auto_rebuild: false)
+
+ @dog_docs.each { |doc| lsi.add_item(doc, :dog) }
+ lsi.build_index
+
+ refute_predicate lsi, :incremental_enabled?
+
+ lsi.enable_incremental_mode!(max_rank: 75)
+ lsi.build_index(force: true)
+
+ assert_predicate lsi, :incremental_enabled?
+ assert_equal 75, lsi.instance_variable_get(:@max_rank)
+ end
+
+ def test_force_rebuild
+ lsi = Classifier::LSI.new(incremental: true, auto_rebuild: false)
+
+ @dog_docs.each { |doc| lsi.add_item(doc, :dog) }
+ lsi.build_index
+
+ # Force rebuild should work
+ lsi.build_index(force: true)
+
+ assert_predicate lsi, :incremental_enabled?
+ end
+
+ def test_vocabulary_growth_triggers_full_rebuild
+ lsi = Classifier::LSI.new(incremental: true, auto_rebuild: false)
+
+ # Start with a small vocabulary
+ lsi.add_item('dog', :animal)
+ lsi.add_item('cat', :animal)
+ lsi.build_index
+
+ # Store initial vocab size to verify growth detection works
+ _initial_vocab_size = lsi.instance_variable_get(:@initial_vocab_size)
+
+ # Add document with many new words (> 20% of initial vocabulary)
+ # This should trigger a full rebuild and disable incremental mode
+ many_new_words = (1..100).map { |i| "newword#{i}" }.join(' ')
+ lsi.add_item(many_new_words, :animal)
+
+ # After vocabulary growth > 20%, incremental mode should be disabled
+ # and a full rebuild should have occurred
+ refute_predicate lsi, :incremental_enabled?
+ end
+
+ def test_incremental_produces_reasonable_results
+ # Build with full SVD
+ lsi_full = Classifier::LSI.new(auto_rebuild: false)
+ @dog_docs.each { |doc| lsi_full.add_item(doc, :dog) }
+ @cat_docs.each { |doc| lsi_full.add_item(doc, :cat) }
+ lsi_full.add_item('my dog is a great friend', :dog)
+ lsi_full.build_index
+
+ # Build with incremental mode
+ lsi_inc = Classifier::LSI.new(incremental: true, auto_rebuild: false)
+ @dog_docs.each { |doc| lsi_inc.add_item(doc, :dog) }
+ @cat_docs.each { |doc| lsi_inc.add_item(doc, :cat) }
+ lsi_inc.build_index
+ lsi_inc.add_item('my dog is a great friend', :dog)
+
+ # Both should classify test documents reasonably
+ # Note: Results may differ due to approximation, but should be reasonable
+ test_doc = 'loyal pet barking'
+
+ _full_result = lsi_full.classify(test_doc).to_s
+ inc_result = lsi_inc.classify(test_doc).to_s
+
+ # At minimum, incremental should produce a valid classification
+ assert_includes %w[dog cat], inc_result
+ end
+
+ def test_incremental_with_streaming_api
+ lsi = Classifier::LSI.new(incremental: true, auto_rebuild: false)
+
+ # Add initial batch
+ @dog_docs.each { |doc| lsi.add_item(doc, :dog) }
+ @cat_docs.each { |doc| lsi.add_item(doc, :cat) }
+ lsi.build_index
+
+ # Use streaming API to add more
+ io = StringIO.new("dogs bark loudly\ndogs wag their tails\n")
+ lsi.train_from_stream(:dog, io)
+
+ # Should still work
+ result = lsi.classify('barking dog wags tail').to_s
+
+ assert_equal 'dog', result
+ end
+end
diff --git a/test/lsi/lsi_test.rb b/test/lsi/lsi_test.rb
index 5f5c65a..08f2977 100644
--- a/test/lsi/lsi_test.rb
+++ b/test/lsi/lsi_test.rb
@@ -1,123 +1,797 @@
-require File.dirname(__FILE__) + '/../test_helper'
-class LSITest < Test::Unit::TestCase
- def setup
- # we repeat principle words to help weight them.
- # This test is rather delicate, since this system is mostly noise.
- @str1 = "This text deals with dogs. Dogs."
- @str2 = "This text involves dogs too. Dogs! "
- @str3 = "This text revolves around cats. Cats."
- @str4 = "This text also involves cats. Cats!"
- @str5 = "This text involves birds. Birds."
- end
-
- def test_basic_indexing
- lsi = Classifier::LSI.new
- [@str1, @str2, @str3, @str4, @str5].each { |x| lsi << x }
- assert ! lsi.needs_rebuild?
-
- # note that the closest match to str1 is str2, even though it is not
- # the closest text match.
- assert_equal [@str2, @str5, @str3], lsi.find_related(@str1, 3)
- end
-
- def test_not_auto_rebuild
- lsi = Classifier::LSI.new :auto_rebuild => false
- lsi.add_item @str1, "Dog"
- lsi.add_item @str2, "Dog"
- assert lsi.needs_rebuild?
- lsi.build_index
- assert ! lsi.needs_rebuild?
- end
-
- def test_basic_categorizing
- lsi = Classifier::LSI.new
- lsi.add_item @str2, "Dog"
- lsi.add_item @str3, "Cat"
- lsi.add_item @str4, "Cat"
- lsi.add_item @str5, "Bird"
-
- assert_equal "Dog", lsi.classify( @str1 )
- assert_equal "Cat", lsi.classify( @str3 )
- assert_equal "Bird", lsi.classify( @str5 )
- end
-
- def test_external_classifying
- lsi = Classifier::LSI.new
- bayes = Classifier::Bayes.new 'Dog', 'Cat', 'Bird'
- lsi.add_item @str1, "Dog" ; bayes.train_dog @str1
- lsi.add_item @str2, "Dog" ; bayes.train_dog @str2
- lsi.add_item @str3, "Cat" ; bayes.train_cat @str3
- lsi.add_item @str4, "Cat" ; bayes.train_cat @str4
- lsi.add_item @str5, "Bird" ; bayes.train_bird @str5
-
- # We're talking about dogs. Even though the text matches the corpus on
- # cats better. Dogs have more semantic weight than cats. So bayes
- # will fail here, but the LSI recognizes content.
- tricky_case = "This text revolves around dogs."
- assert_equal "Dog", lsi.classify( tricky_case )
- assert_not_equal "Dog", bayes.classify( tricky_case )
- end
-
- def test_recategorize_interface
- lsi = Classifier::LSI.new
- lsi.add_item @str1, "Dog"
- lsi.add_item @str2, "Dog"
- lsi.add_item @str3, "Cat"
- lsi.add_item @str4, "Cat"
- lsi.add_item @str5, "Bird"
-
- tricky_case = "This text revolves around dogs."
- assert_equal "Dog", lsi.classify( tricky_case )
-
- # Recategorize as needed.
- lsi.categories_for(@str1).clear.push "Cow"
- lsi.categories_for(@str2).clear.push "Cow"
-
- assert !lsi.needs_rebuild?
- assert_equal "Cow", lsi.classify( tricky_case )
- end
-
- def test_search
- lsi = Classifier::LSI.new
- [@str1, @str2, @str3, @str4, @str5].each { |x| lsi << x }
-
- # Searching by content and text, note that @str2 comes up first, because
- # both "dog" and "involve" are present. But, the next match is @str1 instead
- # of @str4, because "dog" carries more weight than involves.
- assert_equal( [@str2, @str1, @str4, @str5, @str3],
- lsi.search("dog involves", 100) )
-
- # Keyword search shows how the space is mapped out in relation to
- # dog when magnitude is remove. Note the relations. We move from dog
- # through involve and then finally to other words.
- assert_equal( [@str1, @str2, @str4, @str5, @str3],
- lsi.search("dog", 5) )
- end
-
- def test_serialize_safe
- lsi = Classifier::LSI.new
- [@str1, @str2, @str3, @str4, @str5].each { |x| lsi << x }
-
- lsi_md = Marshal.dump lsi
- lsi_m = Marshal.load lsi_md
-
- assert_equal lsi_m.search("cat", 3), lsi.search("cat", 3)
- assert_equal lsi_m.find_related(@str1, 3), lsi.find_related(@str1, 3)
- end
-
- def test_keyword_search
- lsi = Classifier::LSI.new
- lsi.add_item @str1, "Dog"
- lsi.add_item @str2, "Dog"
- lsi.add_item @str3, "Cat"
- lsi.add_item @str4, "Cat"
- lsi.add_item @str5, "Bird"
-
- assert_equal [:dog, :text, :deal], lsi.highest_ranked_stems(@str1)
- end
-
- def test_summary
- assert_equal "This text involves dogs too [...] This text also involves cats", [@str1, @str2, @str3, @str4, @str5].join.summary(2)
- end
-
-end \ No newline at end of file
+require_relative '../test_helper'
+
+class LSITest < Minitest::Test
+ def setup
+ # we repeat principle words to help weight them.
+ # This test is rather delicate, since this system is mostly noise.
+ @str1 = 'This text deals with dogs. Dogs.'
+ @str2 = 'This text involves dogs too. Dogs! '
+ @str3 = 'This text revolves around cats. Cats.'
+ @str4 = 'This text also involves cats. Cats!'
+ @str5 = 'This text involves birds. Birds.'
+ end
+
+ # Hash-style add API tests (Issue #100)
+
+ def test_add_with_hash_syntax
+ lsi = Classifier::LSI.new
+ lsi.add('Dog' => 'Dogs are loyal pets')
+ lsi.add('Cat' => 'Cats are independent')
+
+ assert_equal 2, lsi.items.size
+ assert_includes lsi.items, 'Dogs are loyal pets'
+ assert_includes lsi.items, 'Cats are independent'
+ end
+
+ def test_add_with_symbol_keys
+ lsi = Classifier::LSI.new
+ lsi.add(Dog: 'Dogs are loyal', Cat: 'Cats are independent')
+
+ assert_equal 2, lsi.items.size
+ assert_equal ['Dog'], lsi.categories_for('Dogs are loyal')
+ assert_equal ['Cat'], lsi.categories_for('Cats are independent')
+ end
+
+ def test_add_multiple_items_same_category
+ lsi = Classifier::LSI.new
+ lsi.add('Dog' => ['Dogs are loyal', 'Puppies are cute', 'Canines are friendly'])
+
+ assert_equal 3, lsi.items.size
+ assert_equal ['Dog'], lsi.categories_for('Dogs are loyal')
+ assert_equal ['Dog'], lsi.categories_for('Puppies are cute')
+ assert_equal ['Dog'], lsi.categories_for('Canines are friendly')
+ end
+
+ def test_add_batch_operations
+ lsi = Classifier::LSI.new
+ lsi.add(
+ 'Dog' => ['Dogs are loyal', 'Puppies are cute'],
+ 'Cat' => ['Cats are independent', 'Kittens are playful']
+ )
+
+ assert_equal 4, lsi.items.size
+ assert_equal ['Dog'], lsi.categories_for('Dogs are loyal')
+ assert_equal ['Cat'], lsi.categories_for('Cats are independent')
+ end
+
+ def test_custom_min_word_length
+ lsi = Classifier::LSI.new(min_word_length: 5)
+ lsi.add(
+ 'Dog' => ['Dogs are loyal', 'Puppies are cute'],
+ 'Cat' => ['Cats are independent', 'Kittens are playful']
+ )
+
+ assert_equal(['Dog'], lsi.categories_for('Puppies are cute'))
+ end
+
+ def test_add_classification_works
+ lsi = Classifier::LSI.new
+ lsi.add(
+ 'Dog' => @str2,
+ 'Cat' => [@str3, @str4],
+ 'Bird' => @str5
+ )
+
+ assert_equal 'Dog', lsi.classify(@str1)
+ assert_equal 'Cat', lsi.classify(@str3)
+ assert_equal 'Bird', lsi.classify(@str5)
+ end
+
+ def test_add_find_related_works
+ lsi = Classifier::LSI.new
+ lsi.add(
+ 'Dog' => [@str1, @str2],
+ 'Cat' => [@str3, @str4],
+ 'Bird' => @str5
+ )
+
+ # The closest match to str1 should be str2 (both about dogs)
+ related = lsi.find_related(@str1, 3)
+
+ assert_equal @str2, related.first, 'Most related to dog text should be other dog text'
+ end
+
+ def test_add_equivalence_to_add_item
+ # Using add
+ lsi1 = Classifier::LSI.new
+ lsi1.add(
+ 'Programming' => ['Ruby programming language', 'Java enterprise development'],
+ 'Entertainment' => 'Cat pictures are cute'
+ )
+
+ # Using add_item (legacy)
+ lsi2 = Classifier::LSI.new
+ lsi2.add_item 'Ruby programming language', 'Programming'
+ lsi2.add_item 'Java enterprise development', 'Programming'
+ lsi2.add_item 'Cat pictures are cute', 'Entertainment'
+
+ # Both should classify the same
+ test_text = 'Python programming'
+
+ assert_equal lsi1.classify(test_text), lsi2.classify(test_text)
+ end
+
+ def test_add_triggers_auto_rebuild
+ lsi = Classifier::LSI.new auto_rebuild: true
+ lsi.add('Dog' => ['Dogs are great', 'More about dogs'])
+
+ refute_predicate lsi, :needs_rebuild?, 'Auto-rebuild should keep index current'
+ end
+
+ def test_add_respects_auto_rebuild_false
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add('Dog' => ['Dogs are great', 'More about dogs'])
+
+ assert_predicate lsi, :needs_rebuild?, 'Index should need rebuild when auto_rebuild is false'
+ end
+
+ def test_basic_indexing
+ lsi = Classifier::LSI.new
+ [@str1, @str2, @str3, @str4, @str5].each { |x| lsi << x }
+
+ refute_predicate lsi, :needs_rebuild?
+
+ # NOTE: that the closest match to str1 is str2, even though it is not
+ # the closest text match.
+ assert_equal [@str2, @str5, @str3], lsi.find_related(@str1, 3)
+ end
+
+ def test_not_auto_rebuild
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+
+ assert_predicate lsi, :needs_rebuild?
+ lsi.build_index
+
+ refute_predicate lsi, :needs_rebuild?
+ end
+
+ def test_basic_categorizing
+ lsi = Classifier::LSI.new
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+ lsi.add_item @str4, 'Cat'
+ lsi.add_item @str5, 'Bird'
+
+ assert_equal 'Dog', lsi.classify(@str1)
+ assert_equal 'Cat', lsi.classify(@str3)
+ assert_equal 'Bird', lsi.classify(@str5)
+ assert_equal 'Bird', lsi.classify('Bird me to Bird')
+ end
+
+ def test_external_classifying
+ lsi = Classifier::LSI.new
+ bayes = Classifier::Bayes.new 'Dog', 'Cat', 'Bird'
+ lsi.add_item @str1, 'Dog'
+ bayes.train_dog @str1
+ lsi.add_item @str2, 'Dog'
+ bayes.train_dog @str2
+ lsi.add_item @str3, 'Cat'
+ bayes.train_cat @str3
+ lsi.add_item @str4, 'Cat'
+ bayes.train_cat @str4
+ lsi.add_item @str5, 'Bird'
+ bayes.train_bird @str5
+
+ # Both classifiers should recognize this is about dogs
+ tricky_case = 'This text revolves around dogs.'
+
+ assert_equal 'Dog', lsi.classify(tricky_case)
+ assert_equal 'Dog', bayes.classify(tricky_case)
+ end
+
+ def test_recategorize_interface
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+ lsi.add_item @str4, 'Cat'
+ lsi.add_item @str5, 'Bird'
+
+ tricky_case = 'This text revolves around dogs.'
+
+ assert_equal 'Dog', lsi.classify(tricky_case)
+
+ # Recategorize as needed.
+ lsi.categories_for(@str1).clear.push 'Cow'
+ lsi.categories_for(@str2).clear.push 'Cow'
+
+ refute_predicate lsi, :needs_rebuild?
+ assert_equal 'Cow', lsi.classify(tricky_case)
+ end
+
+ def test_classify_with_confidence
+ lsi = Classifier::LSI.new
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+ lsi.add_item @str4, 'Cat'
+ lsi.add_item @str5, 'Bird'
+
+ category, confidence = lsi.classify_with_confidence(@str1)
+
+ assert_equal 'Dog', category
+ assert_operator confidence, :>, 0.5, "Confidence should be greater than 0.5, but was #{confidence}"
+
+ category, confidence = lsi.classify_with_confidence(@str3)
+
+ assert_equal 'Cat', category
+ assert_operator confidence, :>, 0.5, "Confidence should be greater than 0.5, but was #{confidence}"
+
+ category, confidence = lsi.classify_with_confidence(@str5)
+
+ assert_equal 'Bird', category
+ assert_operator confidence, :>, 0.5, "Confidence should be greater than 0.5, but was #{confidence}"
+
+ tricky_case = 'This text revolves around dogs.'
+ category, confidence = lsi.classify_with_confidence(tricky_case)
+
+ assert_equal 'Dog', category
+ assert_operator confidence, :>, 0.3, "Confidence should be greater than 0.3, but was #{confidence}"
+ end
+
+ def test_search
+ lsi = Classifier::LSI.new
+ [@str1, @str2, @str3, @str4, @str5].each { |x| lsi << x }
+
+ # Searching by content and text - top 2 should be dog-related
+ results = lsi.search('dog involves', 100)
+
+ assert_equal Set.new([@str2, @str1]), Set.new(results.first(2)), 'Top 2 results should be dog-related'
+ assert_includes [@str3, @str4], results.last, 'Least related should be cat-only text'
+ assert_equal Set.new([@str1, @str2, @str3, @str4, @str5]), Set.new(results)
+
+ # Keyword search - top 2 should be dog-related
+ results = lsi.search('dog', 5)
+
+ assert_equal Set.new([@str1, @str2]), Set.new(results.first(2)), 'Top 2 results should be dog-related'
+ assert_includes [@str3, @str4], results.last, 'Least related should be cat-only text'
+ end
+
+ def test_serialize_safe
+ lsi = Classifier::LSI.new
+ [@str1, @str2, @str3, @str4, @str5].each { |x| lsi << x }
+
+ lsi_md = Marshal.dump lsi
+ lsi_m = Marshal.load lsi_md
+
+ assert_equal lsi_m.search('cat', 3), lsi.search('cat', 3)
+ assert_equal lsi_m.find_related(@str1, 3), lsi.find_related(@str1, 3)
+ end
+
+ def test_keyword_search
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+ lsi.add_item @str4, 'Cat'
+ lsi.add_item @str5, 'Bird'
+
+ assert_equal %i[dog text deal], lsi.highest_ranked_stems(@str1)
+ end
+
+ def test_summary
+ summary = [@str1, @str2, @str3, @str4, @str5].join.summary(2)
+ # Summary should contain 2 sentences separated by [...]
+ assert_match(/\[\.\.\.\]/, summary, 'Summary should contain [...] separator')
+ parts = summary.split('[...]').map(&:strip)
+
+ assert_equal 2, parts.size, 'Summary should have 2 parts'
+ # Each part should be one of our test strings (stripped)
+ all_texts = [@str1, @str2, @str3, @str4, @str5].map(&:strip)
+
+ parts.each do |part|
+ assert all_texts.any? { |t| t.include?(part.gsub('This text ', '').split.first) },
+ "Summary part '#{part}' should be from test texts"
+ end
+ end
+
+ # Edge case tests
+
+ def test_empty_index_needs_rebuild
+ lsi = Classifier::LSI.new
+
+ refute_predicate lsi, :needs_rebuild?, 'Empty index should not need rebuild'
+ end
+
+ def test_single_item_needs_rebuild
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item 'Single document', 'Category'
+
+ refute_predicate lsi, :needs_rebuild?, 'Single item index should not need rebuild'
+ end
+
+ def test_remove_item
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+
+ assert_equal 2, lsi.items.size
+
+ lsi.remove_item @str1
+
+ assert_equal 1, lsi.items.size
+ refute_includes lsi.items, @str1
+ end
+
+ def test_remove_nonexistent_item
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+
+ lsi.remove_item 'nonexistent'
+
+ assert_equal 1, lsi.items.size, 'Should not affect index when removing nonexistent item'
+ end
+
+ def test_remove_item_triggers_needs_rebuild
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+ lsi.build_index
+
+ refute_predicate lsi, :needs_rebuild?, 'Index should be current after build'
+
+ lsi.remove_item @str1
+
+ assert_predicate lsi, :needs_rebuild?, 'Index should need rebuild after removing item'
+ end
+
+ def test_items_method
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Cat'
+
+ items = lsi.items
+
+ assert_equal 2, items.size
+ assert_includes items, @str1
+ assert_includes items, @str2
+ end
+
+ def test_find_related_excludes_self
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ result = lsi.find_related(@str1, 3)
+
+ refute_includes result, @str1, 'Should not include the source document in related results'
+ end
+
+ def test_unicode_mixed_with_ascii
+ lsi = Classifier::LSI.new
+ lsi.add_item 'English words and text here', 'English'
+ lsi.add_item 'More english content available', 'English'
+ lsi.add_item 'French words bonjour merci', 'French'
+
+ result = lsi.classify('english content')
+
+ assert_equal 'English', result
+ end
+
+ def test_needs_rebuild_with_auto_rebuild_true
+ lsi = Classifier::LSI.new auto_rebuild: true
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+
+ refute_predicate lsi, :needs_rebuild?, 'Auto-rebuild should keep index current'
+ end
+
+ def test_categories_for_nonexistent_item
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+
+ result = lsi.categories_for('nonexistent')
+
+ assert_empty result, 'Should return empty array for nonexistent item'
+ end
+
+ # Numerical stability tests
+
+ def test_identical_documents
+ lsi = Classifier::LSI.new auto_rebuild: false
+
+ # Identical documents could cause zero singular values
+ lsi.add_item 'Exactly the same text', 'A'
+ lsi.add_item 'Exactly the same text', 'A'
+ lsi.add_item 'Different content here', 'B'
+
+ # Should handle gracefully without crashing
+ lsi.build_index
+
+ refute_predicate lsi, :needs_rebuild?
+ end
+
+ def test_single_word_documents
+ lsi = Classifier::LSI.new auto_rebuild: false
+
+ # Very short documents could cause edge cases
+ lsi.add_item 'dog', 'Animal'
+ lsi.add_item 'cat', 'Animal'
+ lsi.add_item 'car', 'Vehicle'
+
+ # Should handle gracefully
+ lsi.build_index
+
+ refute_predicate lsi, :needs_rebuild?
+ end
+
+ def test_empty_word_hash_handling
+ lsi = Classifier::LSI.new auto_rebuild: false
+
+ # Documents with only stop words result in empty word hashes
+ lsi.add_item 'the a an', 'StopWords'
+ lsi.add_item 'Dogs are great', 'Animal'
+ lsi.add_item 'Cats are nice', 'Animal'
+
+ # Should handle gracefully
+ lsi.build_index
+
+ refute_predicate lsi, :needs_rebuild?
+ end
+
+ def test_large_similar_document_sets
+ # Regression test for issue #72
+ # When many similar documents create few unique terms (M < N),
+ # native Ruby SVD returns transposed dimensions causing ErrDimensionMismatch
+ lsi = Classifier::LSI.new auto_rebuild: false
+
+ 10.times do |i|
+ lsi.add_item "This text deals with dogs. Dogs number #{i}.", 'Dog'
+ end
+ 10.times do |i|
+ lsi.add_item "This text deals with cats. Cats number #{i}.", 'Cat'
+ end
+
+ lsi.build_index
+
+ result = lsi.classify('Dogs are great pets')
+
+ assert_equal 'Dog', result
+ end
+
+ # Save/Load tests
+
+ def test_as_json
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ data = lsi.as_json
+
+ assert_instance_of Hash, data
+ assert_equal 1, data[:version]
+ assert_equal 'lsi', data[:type]
+ assert_equal 3, data[:items].size
+ assert data[:auto_rebuild]
+ end
+
+ def test_to_json
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ json = lsi.to_json
+ data = JSON.parse(json)
+
+ assert_equal 1, data['version']
+ assert_equal 'lsi', data['type']
+ assert_equal 3, data['items'].size
+ assert data['auto_rebuild']
+ end
+
+ def test_from_json_with_string
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ json = lsi.to_json
+ loaded = Classifier::LSI.from_json(json)
+
+ assert_equal lsi.items.sort, loaded.items.sort
+ assert_equal lsi.classify(@str1), loaded.classify(@str1)
+ end
+
+ def test_from_json_with_hash
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ hash = JSON.parse(lsi.to_json)
+ loaded = Classifier::LSI.from_json(hash)
+
+ assert_equal lsi.items.sort, loaded.items.sort
+ assert_equal lsi.classify(@str1), loaded.classify(@str1)
+ end
+
+ def test_from_json_invalid_type
+ invalid_json = { version: 1, type: 'invalid' }.to_json
+
+ assert_raises(ArgumentError) { Classifier::LSI.from_json(invalid_json) }
+ end
+
+ def test_save_and_load
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'lsi.json')
+ lsi.save_to_file(path)
+
+ assert_path_exists path, 'Save should create file'
+
+ loaded = Classifier::LSI.load_from_file(path)
+
+ assert_equal lsi.items.sort, loaded.items.sort
+ assert_equal 'Dog', loaded.classify(@str1)
+ assert_equal 'Cat', loaded.classify(@str3)
+ end
+ end
+
+ def test_save_load_preserves_classification
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+ lsi.add_item @str4, 'Cat'
+ lsi.add_item @str5, 'Bird'
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'lsi.json')
+ lsi.save_to_file(path)
+ loaded = Classifier::LSI.load_from_file(path)
+
+ # Verify classifications match
+ assert_equal lsi.classify(@str1), loaded.classify(@str1)
+ assert_equal lsi.classify('Dogs are nice'), loaded.classify('Dogs are nice')
+ assert_equal lsi.classify('Cats are cute'), loaded.classify('Cats are cute')
+ end
+ end
+
+ def test_save_load_preserves_auto_rebuild_setting
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.build_index
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'lsi.json')
+ lsi.save_to_file(path)
+ loaded = Classifier::LSI.load_from_file(path)
+
+ refute loaded.auto_rebuild, 'Should preserve auto_rebuild: false setting'
+ end
+ end
+
+ def test_loaded_lsi_can_continue_adding_items
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'lsi.json')
+ lsi.save_to_file(path)
+ loaded = Classifier::LSI.load_from_file(path)
+
+ # Continue adding items to loaded LSI
+ loaded.add_item @str3, 'Cat'
+ loaded.add_item @str4, 'Cat'
+
+ assert_equal 4, loaded.items.size
+ assert_equal 'Cat', loaded.classify(@str3)
+ end
+ end
+
+ def test_save_load_search_functionality
+ lsi = Classifier::LSI.new
+ [@str1, @str2, @str3, @str4, @str5].each { |x| lsi << x }
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'lsi.json')
+ lsi.save_to_file(path)
+ loaded = Classifier::LSI.load_from_file(path)
+
+ # Verify search works after load
+ assert_equal lsi.search('dog', 3), loaded.search('dog', 3)
+ end
+ end
+
+ # Cutoff parameter validation tests (Issue #67)
+
+ def test_build_index_cutoff_validation_too_low
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ assert_raises(ArgumentError) { lsi.build_index(0.0) }
+ assert_raises(ArgumentError) { lsi.build_index(-0.5) }
+ end
+
+ def test_build_index_cutoff_validation_too_high
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ assert_raises(ArgumentError) { lsi.build_index(1.0) }
+ assert_raises(ArgumentError) { lsi.build_index(1.5) }
+ end
+
+ def test_build_index_cutoff_validation_valid_range
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ # Should not raise for valid cutoffs
+ lsi.build_index(0.01)
+ lsi.build_index(0.5)
+ lsi.build_index(0.99)
+ end
+
+ def test_build_index_very_small_cutoff_no_negative_index
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ lsi.build_index(0.01)
+
+ assert_equal 'Dog', lsi.classify(@str1)
+ refute_nil lsi.singular_values
+ end
+
+ def test_classify_cutoff_validation
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ assert_raises(ArgumentError) { lsi.classify(@str1, 0.0) }
+ assert_raises(ArgumentError) { lsi.classify(@str1, 1.0) }
+ assert_raises(ArgumentError) { lsi.classify(@str1, -0.1) }
+ assert_raises(ArgumentError) { lsi.classify(@str1, 1.5) }
+ end
+
+ def test_vote_cutoff_validation
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ assert_raises(ArgumentError) { lsi.vote(@str1, 0.0) }
+ assert_raises(ArgumentError) { lsi.vote(@str1, 1.0) }
+ end
+
+ def test_classify_with_confidence_cutoff_validation
+ lsi = Classifier::LSI.new
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+
+ assert_raises(ArgumentError) { lsi.classify_with_confidence(@str1, 0.0) }
+ assert_raises(ArgumentError) { lsi.classify_with_confidence(@str1, 1.0) }
+ end
+
+ # Singular value introspection tests (Issue #67)
+
+ def test_singular_values_nil_before_build
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+
+ assert_nil lsi.singular_values
+ end
+
+ def test_singular_values_populated_after_build
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+ lsi.build_index
+
+ refute_nil lsi.singular_values
+ assert_instance_of Array, lsi.singular_values
+ assert(lsi.singular_values.all?(Numeric))
+ assert_predicate lsi.singular_values.size, :positive?
+ end
+
+ def test_singular_values_sorted_descending
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+ lsi.add_item @str4, 'Cat'
+ lsi.add_item @str5, 'Bird'
+ lsi.build_index
+
+ values = lsi.singular_values
+ sorted = values.sort.reverse
+
+ assert_equal sorted, values, 'Singular values should be sorted in descending order'
+ end
+
+ def test_singular_value_spectrum_nil_before_build
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+
+ assert_nil lsi.singular_value_spectrum
+ end
+
+ def test_singular_value_spectrum_structure
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+ lsi.add_item @str4, 'Cat'
+ lsi.add_item @str5, 'Bird'
+ lsi.build_index
+
+ spectrum = lsi.singular_value_spectrum
+
+ refute_nil spectrum
+ assert_instance_of Array, spectrum
+
+ # Each entry should have required keys
+ spectrum.each_with_index do |entry, i|
+ assert_equal i, entry[:dimension]
+ assert entry.key?(:value)
+ assert entry.key?(:percentage)
+ assert entry.key?(:cumulative_percentage)
+ end
+ end
+
+ def test_singular_value_spectrum_percentages
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+ lsi.add_item @str4, 'Cat'
+ lsi.add_item @str5, 'Bird'
+ lsi.build_index
+
+ spectrum = lsi.singular_value_spectrum
+
+ # Individual percentages should sum to 1
+ total_pct = spectrum.sum { |e| e[:percentage] }
+
+ assert_in_delta 1.0, total_pct, 0.001
+
+ # Cumulative should reach 1.0 at the end
+ assert_in_delta 1.0, spectrum.last[:cumulative_percentage], 0.001
+
+ # Cumulative should be monotonically increasing
+ spectrum.each_cons(2) do |a, b|
+ assert_operator a[:cumulative_percentage], :<=, b[:cumulative_percentage]
+ end
+ end
+
+ def test_singular_value_spectrum_for_tuning
+ lsi = Classifier::LSI.new auto_rebuild: false
+ lsi.add_item @str1, 'Dog'
+ lsi.add_item @str2, 'Dog'
+ lsi.add_item @str3, 'Cat'
+ lsi.add_item @str4, 'Cat'
+ lsi.add_item @str5, 'Bird'
+ lsi.build_index
+
+ spectrum = lsi.singular_value_spectrum
+
+ # Find how many dimensions capture 75% of variance (the default cutoff)
+ dims_for_threshold = spectrum.find_index { |e| e[:cumulative_percentage] >= 0.75 }
+
+ # This should be usable for tuning decisions
+ refute_nil dims_for_threshold, 'Should be able to find dimensions for 75% variance'
+ assert_operator dims_for_threshold, :<, spectrum.size, 'Some dimensions should be below 75% threshold'
+ end
+end
diff --git a/test/lsi/streaming_test.rb b/test/lsi/streaming_test.rb
new file mode 100644
index 0000000..787e625
--- /dev/null
+++ b/test/lsi/streaming_test.rb
@@ -0,0 +1,400 @@
+require_relative '../test_helper'
+require 'stringio'
+require 'tempfile'
+
+class LSIStreamingTest < Minitest::Test
+ def setup
+ @lsi = Classifier::LSI.new
+ end
+
+ # train_from_stream tests
+
+ def test_train_from_stream_basic
+ dog_docs = "dogs are loyal pets\npuppies are playful\ndogs bark at strangers\n"
+ cat_docs = "cats are independent\nkittens are curious\ncats meow softly\n"
+
+ @lsi.train_from_stream(:dog, StringIO.new(dog_docs))
+ @lsi.train_from_stream(:cat, StringIO.new(cat_docs))
+
+ # Should be able to classify
+ # Note: classify returns the category as originally stored (symbol in this case)
+ result = @lsi.classify('loyal pet that barks')
+
+ assert_equal 'dog', result.to_s
+ end
+
+ def test_train_from_stream_many_categories
+ lsi = Classifier::LSI.new
+ lsi.train_from_stream(
+ dog: StringIO.new("dogs are loyal pets\npuppies are playful\ndogs bark at strangers\n"),
+ cat: StringIO.new("cats are independent\nkittens are curious\ncats meow softly\n")
+ )
+
+ assert_equal :dog, lsi.classify('loyal pet that barks')
+ assert_equal :cat, lsi.classify('independent curious pet')
+ end
+
+ def test_train_from_stream_raises_without_args
+ assert_raises(ArgumentError) { @lsi.train_from_stream }
+ end
+
+ def test_train_from_stream_raises_with_partial_args
+ assert_raises(ArgumentError) { @lsi.train_from_stream(:spam) }
+ end
+
+ def test_train_from_stream_invalid_io_type
+ assert_raises(ArgumentError) { @lsi.train_from_stream(category: Object.new) }
+ end
+
+ def test_train_from_stream_with_invalid_io_type_does_not_modify_auto_rebuild_setting
+ @lsi = Classifier::LSI.new(auto_rebuild: true)
+
+ assert_raises(ArgumentError) do
+ @lsi.train_from_stream(cat1: StringIO.new("one\ntwo\n"), cat2: Object.new)
+ end
+
+ assert @lsi.auto_rebuild
+ end
+
+ def test_train_from_stream_empty_io
+ @lsi.train_from_stream(:category, StringIO.new(''))
+
+ # No items added
+ assert_empty @lsi.items
+ end
+
+ def test_train_from_stream_single_line
+ @lsi.train_from_stream(:dog, StringIO.new("dogs are loyal pets\n"))
+ @lsi.train_from_stream(:cat, StringIO.new("cats are independent\n"))
+
+ # Should have 2 items
+ assert_equal 2, @lsi.items.size
+ end
+
+ def test_train_from_stream_with_batch_size
+ lines = (1..50).map { |i| "document number #{i} about dogs" }
+ io = StringIO.new(lines.join("\n"))
+
+ batches_processed = 0
+ @lsi.train_from_stream(:dog, io, batch_size: 10) do |progress|
+ batches_processed = progress.current_batch
+ end
+
+ assert_equal 5, batches_processed
+ end
+
+ def test_train_from_stream_progress_tracking
+ lines = (1..25).map { |i| "document #{i}" }
+ io = StringIO.new(lines.join("\n"))
+
+ completed_values = []
+ @lsi.train_from_stream(:category, io, batch_size: 10) do |progress|
+ completed_values << progress.completed
+ end
+
+ assert_equal [10, 20, 25], completed_values
+ end
+
+ def test_train_from_stream_marks_dirty
+ refute_predicate @lsi, :dirty?
+ @lsi.train_from_stream(:category, StringIO.new("content\n"))
+
+ assert_predicate @lsi, :dirty?
+ end
+
+ def test_train_from_stream_rebuilds_index_when_auto_rebuild
+ @lsi = Classifier::LSI.new(auto_rebuild: true)
+
+ dog_docs = "dogs are loyal\ndogs bark\n"
+ cat_docs = "cats are independent\ncats meow\n"
+
+ @lsi.train_from_stream(:dog, StringIO.new(dog_docs))
+ @lsi.train_from_stream(:cat, StringIO.new(cat_docs))
+
+ # Index should be built
+ refute_predicate @lsi, :needs_rebuild?
+ end
+
+ def test_train_from_stream_with_keyword_categories_rebuilds_index_when_auto_rebuild
+ @lsi = Classifier::LSI.new(auto_rebuild: true)
+
+ @lsi.train_from_stream(
+ dog: StringIO.new("dogs are loyal\ndogs bark\n"),
+ cat: StringIO.new("cats are independent\ncats meow\n")
+ )
+
+ # Index should be built
+ refute_predicate @lsi, :needs_rebuild?
+ end
+
+ def test_train_from_stream_skips_rebuild_when_auto_rebuild_false
+ @lsi = Classifier::LSI.new(auto_rebuild: false)
+
+ @lsi.train_from_stream(:category, StringIO.new("document one\ndocument two\n"))
+
+ # Index should need rebuild
+ assert_predicate @lsi, :needs_rebuild?
+ end
+
+ def test_train_from_stream_with_keyword_categories_skips_rebuild_when_auto_rebuild_false
+ @lsi = Classifier::LSI.new(auto_rebuild: false)
+
+ @lsi.train_from_stream(
+ cat1: StringIO.new("document one\ndocument two\n"),
+ cat2: StringIO.new("document three\ndocument four\n")
+ )
+
+ # Index should need rebuild
+ assert_predicate @lsi, :needs_rebuild?
+ end
+
+ def test_train_from_stream_with_file
+ Tempfile.create(['corpus', '.txt']) do |file|
+ file.puts 'dogs are loyal pets'
+ file.puts 'puppies are playful animals'
+ file.puts 'dogs bark and play'
+ file.flush
+ file.rewind
+
+ @lsi.train_from_stream(:dog, file)
+ end
+
+ @lsi.add_item('cats are independent', :cat)
+ @lsi.add_item('kittens are curious', :cat)
+
+ result = @lsi.classify('loyal pet')
+
+ assert_equal 'dog', result.to_s
+ end
+
+ # add_batch tests
+
+ def test_add_batch_basic
+ dog_docs = ['dogs are loyal', 'puppies play', 'dogs bark']
+ cat_docs = ['cats are independent', 'kittens curious', 'cats meow']
+
+ @lsi.add_batch(dog: dog_docs, cat: cat_docs)
+
+ assert_equal 6, @lsi.items.size
+ assert_equal 'dog', @lsi.classify('loyal dog barks').to_s
+ end
+
+ def test_add_batch_with_progress
+ docs = (1..30).map { |i| "document #{i}" }
+
+ completed_values = []
+ @lsi.add_batch(batch_size: 10, category: docs) do |progress|
+ completed_values << progress.completed
+ end
+
+ assert_equal [10, 20, 30], completed_values
+ end
+
+ def test_add_batch_empty
+ @lsi.add_batch(category: [])
+
+ assert_empty @lsi.items
+ end
+
+ def test_add_batch_marks_dirty
+ refute_predicate @lsi, :dirty?
+ @lsi.add_batch(category: ['doc'])
+
+ assert_predicate @lsi, :dirty?
+ end
+
+ def test_add_batch_rebuilds_when_auto_rebuild
+ @lsi = Classifier::LSI.new(auto_rebuild: true)
+
+ @lsi.add_batch(
+ dog: ['dogs bark', 'puppies play'],
+ cat: ['cats meow', 'kittens purr']
+ )
+
+ refute_predicate @lsi, :needs_rebuild?
+ end
+
+ # train_batch tests (alias for add_batch)
+
+ def test_train_batch_positional_style
+ docs = ['dogs are loyal', 'puppies play']
+ @lsi.train_batch(:dog, docs)
+
+ @lsi.add_item('cats are independent', :cat)
+
+ assert_equal 3, @lsi.items.size
+ end
+
+ def test_train_batch_keyword_style
+ @lsi.train_batch(
+ dog: ['dogs are loyal', 'puppies play'],
+ cat: ['cats are independent', 'kittens curious']
+ )
+
+ assert_equal 4, @lsi.items.size
+ assert_equal 'dog', @lsi.classify('loyal dog').to_s
+ end
+
+ def test_train_batch_with_progress
+ docs = (1..20).map { |i| "doc #{i}" }
+
+ batches = 0
+ @lsi.train_batch(:category, docs, batch_size: 5) do |_progress|
+ batches += 1
+ end
+
+ assert_equal 4, batches
+ end
+
+ # Equivalence tests
+
+ def test_train_from_stream_equivalent_to_add_item
+ lsi1 = Classifier::LSI.new
+ lsi2 = Classifier::LSI.new
+
+ documents = ['dogs are loyal pets', 'puppies are playful', 'dogs bark at strangers']
+
+ # Add with add_item
+ documents.each { |doc| lsi1.add_item(doc, :dog) }
+
+ # Add with train_from_stream
+ io = StringIO.new(documents.join("\n"))
+ lsi2.train_from_stream(:dog, io)
+
+ # Both should have same items
+ assert_equal lsi1.items.size, lsi2.items.size
+
+ # Add some cat documents to both for classification
+ lsi1.add_item('cats are independent', :cat)
+ lsi2.add_item('cats are independent', :cat)
+
+ # Both should classify the same
+ test_doc = 'loyal playful pet'
+
+ assert_equal lsi1.classify(test_doc).to_s, lsi2.classify(test_doc).to_s
+ end
+
+ def test_add_batch_equivalent_to_add
+ lsi1 = Classifier::LSI.new
+ lsi2 = Classifier::LSI.new
+
+ dog_docs = ['dogs bark', 'puppies play']
+ cat_docs = ['cats meow', 'kittens purr']
+
+ # Add with hash-style add
+ lsi1.add(dog: dog_docs, cat: cat_docs)
+
+ # Add with add_batch
+ lsi2.add_batch(dog: dog_docs, cat: cat_docs)
+
+ # Both should have same items
+ assert_equal lsi1.items.size, lsi2.items.size
+
+ # Both should classify the same
+ test_doc = 'barking dog'
+
+ assert_equal lsi1.classify(test_doc).to_s, lsi2.classify(test_doc).to_s
+ end
+
+ # Checkpoint tests
+
+ def test_save_checkpoint
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'lsi.json')
+ @lsi.storage = Classifier::Storage::File.new(path: path)
+
+ @lsi.add_item('dogs are loyal', :dog)
+ @lsi.add_item('cats are independent', :cat)
+ @lsi.save_checkpoint('50pct')
+
+ checkpoint_path = File.join(dir, 'lsi_checkpoint_50pct.json')
+
+ assert_path_exists checkpoint_path
+ end
+ end
+
+ def test_load_checkpoint
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'lsi.json')
+ storage = Classifier::Storage::File.new(path: path)
+ @lsi.storage = storage
+
+ @lsi.add_item('dogs are loyal', :dog)
+ @lsi.add_item('cats are independent', :cat)
+ @lsi.save_checkpoint('halfway')
+
+ # Load from checkpoint
+ loaded = Classifier::LSI.load_checkpoint(storage: storage, checkpoint_id: 'halfway')
+
+ # Should have the same items and same classification
+ assert_equal @lsi.items.size, loaded.items.size
+ # Compare as strings since loaded classifier may have string categories
+ assert_equal @lsi.classify('loyal dog').to_s, loaded.classify('loyal dog').to_s
+ end
+ end
+
+ def test_list_checkpoints
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'lsi.json')
+ @lsi.storage = Classifier::Storage::File.new(path: path)
+
+ @lsi.add_item('content', :category)
+ @lsi.save_checkpoint('10pct')
+ @lsi.save_checkpoint('50pct')
+ @lsi.save_checkpoint('90pct')
+
+ checkpoints = @lsi.list_checkpoints
+
+ assert_equal %w[10pct 50pct 90pct], checkpoints.sort
+ end
+ end
+
+ def test_delete_checkpoint
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'lsi.json')
+ @lsi.storage = Classifier::Storage::File.new(path: path)
+
+ @lsi.add_item('content', :category)
+ @lsi.save_checkpoint('test')
+
+ checkpoint_path = File.join(dir, 'lsi_checkpoint_test.json')
+
+ assert_path_exists checkpoint_path
+
+ @lsi.delete_checkpoint('test')
+
+ refute_path_exists checkpoint_path
+ end
+ end
+
+ def test_checkpoint_workflow
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'lsi.json')
+ storage = Classifier::Storage::File.new(path: path)
+
+ # First training session
+ lsi1 = Classifier::LSI.new
+ lsi1.storage = storage
+
+ lsi1.add_item('dogs are loyal', :dog)
+ lsi1.add_item('puppies are playful', :dog)
+ lsi1.save_checkpoint('phase1')
+
+ # Simulate restart - load from checkpoint
+ lsi2 = Classifier::LSI.load_checkpoint(storage: storage, checkpoint_id: 'phase1')
+
+ # Continue training
+ lsi2.add_item('cats are independent', :cat)
+ lsi2.add_item('kittens are curious', :cat)
+ lsi2.save_checkpoint('phase2')
+
+ # Load final checkpoint
+ final = Classifier::LSI.load_checkpoint(storage: storage, checkpoint_id: 'phase2')
+
+ # Should have all training
+ assert_equal 4, final.items.size
+ assert_equal 'dog', final.classify('loyal playful').to_s
+ assert_equal 'cat', final.classify('independent curious').to_s
+ end
+ end
+end
diff --git a/test/lsi/summary_test.rb b/test/lsi/summary_test.rb
new file mode 100644
index 0000000..4716c30
--- /dev/null
+++ b/test/lsi/summary_test.rb
@@ -0,0 +1,113 @@
+require_relative '../test_helper'
+
+class SummaryTest < Minitest::Test
+ def test_split_sentences_basic
+ text = 'Hello world. This is a test. How are you?'
+ sentences = text.split_sentences
+
+ assert_equal 3, sentences.size
+ assert_equal 'Hello world.', sentences[0]
+ assert_equal 'This is a test.', sentences[1]
+ assert_equal 'How are you?', sentences[2]
+ end
+
+ def test_split_sentences_with_abbreviations
+ text = 'Dr. Smith went to the store. He bought milk.'
+ sentences = text.split_sentences
+
+ assert_equal 2, sentences.size
+ assert_equal 'Dr. Smith went to the store.', sentences[0]
+ assert_equal 'He bought milk.', sentences[1]
+ end
+
+ def test_split_sentences_with_mr_mrs
+ text = 'Mr. Jones met Mrs. Smith. They talked.'
+ sentences = text.split_sentences
+
+ assert_equal 2, sentences.size
+ assert_equal 'Mr. Jones met Mrs. Smith.', sentences[0]
+ assert_equal 'They talked.', sentences[1]
+ end
+
+ def test_split_sentences_with_decimals
+ text = 'The price is $3.50 per unit. That is expensive.'
+ sentences = text.split_sentences
+
+ assert_equal 2, sentences.size
+ assert_equal 'The price is $3.50 per unit.', sentences[0]
+ assert_equal 'That is expensive.', sentences[1]
+ end
+
+ def test_split_sentences_with_exclamation
+ text = 'Hello! How are you? I am fine.'
+ sentences = text.split_sentences
+
+ assert_equal 3, sentences.size
+ assert_equal 'Hello!', sentences[0]
+ assert_equal 'How are you?', sentences[1]
+ assert_equal 'I am fine.', sentences[2]
+ end
+
+ def test_split_sentences_with_inc_corp
+ text = 'Apple Inc. makes phones. Microsoft Corp. makes software.'
+ sentences = text.split_sentences
+
+ assert_equal 2, sentences.size
+ assert_equal 'Apple Inc. makes phones.', sentences[0]
+ assert_equal 'Microsoft Corp. makes software.', sentences[1]
+ end
+
+ def test_split_sentences_with_etc
+ text = 'We need apples, oranges, etc. for the party. Please bring them.'
+ sentences = text.split_sentences
+
+ assert_equal 2, sentences.size
+ assert_includes sentences[0], 'etc.'
+ end
+
+ def test_split_paragraphs_double_newline
+ text = "First paragraph.\n\nSecond paragraph."
+ paragraphs = text.split_paragraphs
+
+ assert_equal 2, paragraphs.size
+ assert_equal 'First paragraph.', paragraphs[0]
+ assert_equal 'Second paragraph.', paragraphs[1]
+ end
+
+ def test_split_paragraphs_windows_line_endings
+ text = "First paragraph.\r\n\r\nSecond paragraph."
+ paragraphs = text.split_paragraphs
+
+ assert_equal 2, paragraphs.size
+ assert_equal 'First paragraph.', paragraphs[0]
+ assert_equal 'Second paragraph.', paragraphs[1]
+ end
+
+ def test_split_paragraphs_multiple_newlines
+ text = "First paragraph.\n\n\n\nSecond paragraph."
+ paragraphs = text.split_paragraphs
+
+ assert_equal 2, paragraphs.size
+ end
+
+ def test_split_paragraphs_mixed_line_endings
+ text = "First.\r\n\r\nSecond.\n\nThird."
+ paragraphs = text.split_paragraphs
+
+ assert_equal 3, paragraphs.size
+ end
+
+ def test_summary_returns_string
+ text = 'This is sentence one. This is sentence two. This is sentence three.'
+ result = text.summary(2)
+
+ assert_instance_of String, result
+ end
+
+ def test_paragraph_summary_returns_string
+ text = "First paragraph with content.\n\nSecond paragraph with more content."
+ result = text.paragraph_summary(1)
+
+ assert_instance_of String, result
+ end
+end
diff --git a/test/property/property_test.rb b/test/property/property_test.rb
new file mode 100644
index 0000000..c68d0c8
--- /dev/null
+++ b/test/property/property_test.rb
@@ -0,0 +1,325 @@
+require_relative '../test_helper'
+require 'rantly'
+
+class PropertyTest < Minitest::Test
+ ITERATIONS = Integer(ENV.fetch('RANTLY_COUNT', 50))
+
+ SAMPLE_WORDS = %w[
+ apple banana cherry orange grape mango peach plum
+ computer software hardware programming algorithm database
+ running jumping swimming cycling hiking climbing skiing
+ happy excited joyful peaceful calm relaxed content
+ mountain river ocean forest desert valley meadow
+ ].freeze
+
+ def setup
+ @classifier = Classifier::Bayes.new 'Spam', 'Ham'
+ @classifier.train_spam 'buy now free offer limited time deal discount'
+ @classifier.train_ham 'hello friend meeting project work schedule'
+ end
+
+ def random_alpha_string(min_len = 5, max_len = 100)
+ Rantly { sized(range(min_len, max_len)) { string(:alpha) } }
+ end
+
+ def random_meaningful_text(word_count = 5)
+ SAMPLE_WORDS.sample(word_count).join(' ')
+ end
+
+ def test_classification_is_deterministic
+ ITERATIONS.times do
+ random_text = random_alpha_string
+ c1 = @classifier.classify(random_text)
+ c2 = @classifier.classify(random_text)
+
+ assert_equal c1, c2, "Classification should be deterministic for: #{random_text.inspect}"
+ end
+ end
+
+ def test_classification_scores_are_deterministic
+ ITERATIONS.times do
+ random_text = random_alpha_string(10, 50)
+ scores1 = @classifier.classifications(random_text)
+ scores2 = @classifier.classifications(random_text)
+
+ assert_equal scores1, scores2, 'Classification scores should be deterministic'
+ end
+ end
+
+ def test_training_order_independence
+ 30.times do
+ word_count = Rantly { range(3, 6) }
+ texts = Array.new(word_count) { random_meaningful_text(5) }
+
+ c1 = Classifier::Bayes.new 'A', 'B'
+ c2 = Classifier::Bayes.new 'A', 'B'
+
+ c1.train_b 'different category words'
+ c2.train_b 'different category words'
+
+ texts.each { |t| c1.train_a(t) }
+ texts.shuffle.each { |t| c2.train_a(t) }
+
+ test_phrase = 'test classification'
+ scores1 = c1.classifications(test_phrase)
+ scores2 = c2.classifications(test_phrase)
+
+ assert_in_delta scores1['A'], scores2['A'], 0.0001,
+ 'Training order should not affect classification scores'
+ assert_in_delta scores1['B'], scores2['B'], 0.0001,
+ 'Training order should not affect classification scores'
+ end
+ end
+
+ def test_untrain_is_inverse_of_train
+ 30.times do
+ text = random_meaningful_text(5)
+
+ classifier = Classifier::Bayes.new 'Spam', 'Ham'
+ classifier.train_spam 'initial training data here'
+ classifier.train_ham 'other category data here'
+
+ original_scores = classifier.classifications('test phrase')
+
+ classifier.train_spam(text)
+ classifier.untrain_spam(text)
+
+ restored_scores = classifier.classifications('test phrase')
+
+ assert_in_delta original_scores['Spam'], restored_scores['Spam'], 0.0001,
+ 'Untrain should restore original state'
+ assert_in_delta original_scores['Ham'], restored_scores['Ham'], 0.0001,
+ 'Untrain should restore original state'
+ end
+ end
+
+ def test_word_counts_never_negative
+ 30.times do
+ train_text = random_meaningful_text(3)
+ untrain_text = random_meaningful_text(8)
+
+ classifier = Classifier::Bayes.new 'A', 'B'
+ classifier.train_a train_text
+ classifier.untrain_a untrain_text
+
+ category_words = classifier.instance_variable_get(:@categories)[:A]
+
+ category_words.each_value do |count|
+ assert_operator count, :>=, 0, 'Word counts should never be negative'
+ end
+
+ total = classifier.instance_variable_get(:@total_words)
+
+ assert_operator total, :>=, 0, 'Total words should never be negative'
+ end
+ end
+
+ def test_category_counts_are_consistent
+ 20.times do
+ classifier = Classifier::Bayes.new 'A', 'B'
+ classifier.train_a 'single document'
+ classifier.train_a 'another document'
+
+ initial_count = classifier.instance_variable_get(:@category_counts)[:A]
+
+ assert_equal 2, initial_count
+
+ classifier.untrain_a 'some text'
+ after_untrain = classifier.instance_variable_get(:@category_counts)[:A]
+
+ assert_equal 1, after_untrain, 'Untrain should decrement category count'
+ end
+ end
+
+ def test_classification_returns_valid_category
+ ITERATIONS.times do
+ random_text = Rantly { sized(range(1, 100)) { string } }
+ result = @classifier.classify(random_text)
+
+ assert_includes %w[Spam Ham], result,
+ 'Classification must return a valid category'
+ end
+ end
+
+ def test_classifications_contains_all_categories
+ 30.times do
+ random_text = random_alpha_string(5, 50)
+ scores = @classifier.classifications(random_text)
+
+ assert_includes scores.keys, 'Spam', 'Should contain Spam category'
+ assert_includes scores.keys, 'Ham', 'Should contain Ham category'
+ assert_equal 2, scores.size, 'Should have exactly 2 categories'
+ end
+ end
+
+ def test_log_probabilities_are_finite
+ ITERATIONS.times do
+ random_text = random_alpha_string
+ scores = @classifier.classifications(random_text)
+
+ scores.each do |category, score|
+ assert_predicate score, :finite?,
+ "Score for #{category} should be finite, got: #{score}"
+ end
+ end
+ end
+
+ def test_multiple_training_equivalence
+ 20.times do
+ text = random_meaningful_text(3)
+
+ c1 = Classifier::Bayes.new 'A', 'B'
+ c2 = Classifier::Bayes.new 'A', 'B'
+
+ 3.times { c1.train_a(text) }
+ c2.train_a("#{text} #{text} #{text}")
+
+ scores1 = c1.classifications('test')
+ scores2 = c2.classifications('test')
+
+ assert_in_delta scores1['A'], scores2['A'], 0.0001,
+ 'Multiple trains should equal single train with repeated text'
+ end
+ end
+end
+
+class LSIPropertyTest < Minitest::Test
+ def test_lsi_classification_is_deterministic
+ tech_docs = [
+ 'This text deals with computers. Computers and programming.',
+ 'This document involves software development. Software!',
+ 'This text revolves around technology. Technology!'
+ ]
+ sports_docs = [
+ 'This text deals with sports. Sports and football.',
+ 'This document involves basketball. Basketball!',
+ 'This text revolves around athletics. Athletics!'
+ ]
+
+ 20.times do
+ lsi = Classifier::LSI.new
+ tech_docs.each { |doc| lsi.add_item doc, 'Tech' }
+ sports_docs.each { |doc| lsi.add_item doc, 'Sports' }
+
+ test_doc = 'This is about programming and computers.'
+
+ c1 = lsi.classify(test_doc)
+ c2 = lsi.classify(test_doc)
+
+ assert_equal c1, c2, 'LSI classification should be deterministic'
+ assert_equal 'Tech', c1, 'Tech document should classify as Tech'
+ end
+ end
+
+ def test_find_related_is_deterministic
+ 15.times do
+ lsi = Classifier::LSI.new
+ doc1 = 'This text deals with dogs. Dogs are great pets.'
+ doc2 = 'This text involves cats. Cats are independent.'
+ doc3 = 'This text revolves around dogs too. Dogs!'
+
+ lsi << doc1
+ lsi << doc2
+ lsi << doc3
+
+ related1 = lsi.find_related(doc1, 2)
+ related2 = lsi.find_related(doc1, 2)
+
+ assert_equal related1, related2, 'find_related should be deterministic'
+ end
+ end
+
+ def test_search_is_deterministic
+ 15.times do
+ lsi = Classifier::LSI.new
+ lsi << 'This text deals with dogs. Dogs are loyal pets.'
+ lsi << 'This text involves cats. Cats are curious animals.'
+ lsi << 'This text revolves around birds. Birds can fly.'
+
+ query = 'dogs pets'
+
+ results1 = lsi.search(query, 2)
+ results2 = lsi.search(query, 2)
+
+ assert_equal results1, results2, 'Search should be deterministic'
+ end
+ end
+
+ def test_lsi_handles_uncategorized_items
+ lsi = Classifier::LSI.new
+ lsi.add_item 'This text deals with technology. Technology!', 'Tech'
+ lsi.add_item 'This text involves sports. Sports!', 'Sports'
+ lsi << 'This is a random document about nothing.'
+
+ result = lsi.classify('This is random content.')
+
+ assert(result.nil? || result.is_a?(String),
+ 'Should return nil or a string category')
+ end
+
+ def test_lsi_rebuild_consistency
+ 10.times do
+ lsi = Classifier::LSI.new(auto_rebuild: true)
+
+ lsi.add_item 'This text deals with computers. Computers!', 'Tech'
+ lsi.add_item 'This text involves sports. Sports!', 'Sports'
+
+ lsi.add_item 'This text revolves around programming. Programming!', 'Tech'
+ lsi.add_item 'This text involves football. Football!', 'Sports'
+
+ test_text = 'This is about programming and computers.'
+ result1 = lsi.classify(test_text)
+ result2 = lsi.classify(test_text)
+
+ assert_equal result1, result2, 'Classification should be deterministic after rebuild'
+ end
+ end
+end
+
+class MultiCategoryPropertyTest < Minitest::Test
+ def test_category_operations_maintain_consistency
+ 20.times do
+ category_name = "Category#{rand(1000)}"
+
+ classifier = Classifier::Bayes.new 'Default'
+ classifier.add_category(category_name)
+
+ normalized_name = category_name.prepare_category_name.to_s
+
+ assert_includes classifier.categories, normalized_name,
+ 'Added category should be present'
+
+ classifier.remove_category(category_name)
+
+ refute_includes classifier.categories, normalized_name,
+ 'Removed category should not be present'
+ end
+ end
+
+ def test_training_data_isolation
+ words_a = %w[apple banana cherry orange grape]
+ words_b = %w[dog elephant fox giraffe horse]
+
+ 20.times do
+ text1 = words_a.sample(3).join(' ')
+ text2 = words_b.sample(3).join(' ')
+
+ classifier = Classifier::Bayes.new 'A', 'B'
+ classifier.train_a text1
+ classifier.train_b text2
+
+ category_a = classifier.instance_variable_get(:@categories)[:A]
+ category_b = classifier.instance_variable_get(:@categories)[:B]
+
+ text1.downcase.split.each do |word|
+ next if word.length < 3
+
+ stemmed = word.stem
+ next unless category_a[stemmed.to_sym]
+
+ refute category_b[stemmed.to_sym],
+ 'Words trained in A should not appear in B'
+ end
+ end
+ end
+end
diff --git a/test/storage/storage_test.rb b/test/storage/storage_test.rb
new file mode 100644
index 0000000..c581617
--- /dev/null
+++ b/test/storage/storage_test.rb
@@ -0,0 +1,993 @@
+require_relative '../test_helper'
+
+class StorageAPIConsistencyTest < Minitest::Test
+ # Dynamically discover all classifier/vectorizer classes
+ CLASSIFIERS = Classifier.constants.filter_map do |const|
+ klass = Classifier.const_get(const)
+ next unless klass.is_a?(Class)
+
+ klass if klass.method_defined?(:classify) || klass.method_defined?(:transform)
+ end.freeze
+
+ INSTANCE_METHODS = %i[save reload reload! dirty? storage storage=].freeze
+ CLASS_METHODS = %i[load].freeze
+
+ def test_classifiers_discovered
+ assert_operator CLASSIFIERS.size, :>=, 5, "Expected at least 5 classifiers, found: #{CLASSIFIERS.map(&:name)}"
+ end
+
+ CLASSIFIERS.each do |klass|
+ class_name = klass.name.split('::').last.downcase
+
+ INSTANCE_METHODS.each do |method|
+ define_method "test_#{class_name}_responds_to_#{method}" do
+ assert_respond_to klass.allocate, method, "#{klass} missing ##{method}"
+ end
+ end
+
+ CLASS_METHODS.each do |method|
+ define_method "test_#{class_name}_class_responds_to_#{method}" do
+ assert_respond_to klass, method, "#{klass} missing .#{method}"
+ end
+ end
+ end
+end
+
+class StorageTest < Minitest::Test
+ # Storage::Base tests
+ def test_base_storage_raises_not_implemented_for_write
+ storage = Classifier::Storage::Base.new
+ assert_raises(NotImplementedError) { storage.write('data') }
+ end
+
+ def test_base_storage_raises_not_implemented_for_read
+ storage = Classifier::Storage::Base.new
+ assert_raises(NotImplementedError) { storage.read }
+ end
+
+ def test_base_storage_raises_not_implemented_for_delete
+ storage = Classifier::Storage::Base.new
+ assert_raises(NotImplementedError) { storage.delete }
+ end
+
+ def test_base_storage_raises_not_implemented_for_exists
+ storage = Classifier::Storage::Base.new
+ assert_raises(NotImplementedError) { storage.exists? }
+ end
+
+ # Storage::Memory tests
+ def test_memory_storage_write_and_read
+ storage = Classifier::Storage::Memory.new
+ storage.write('test data')
+
+ assert_equal 'test data', storage.read
+ end
+
+ def test_memory_storage_exists_false_initially
+ storage = Classifier::Storage::Memory.new
+
+ refute_predicate storage, :exists?
+ end
+
+ def test_memory_storage_exists_true_after_write
+ storage = Classifier::Storage::Memory.new
+ storage.write('test data')
+
+ assert_predicate storage, :exists?
+ end
+
+ def test_memory_storage_delete
+ storage = Classifier::Storage::Memory.new
+ storage.write('test data')
+ storage.delete
+
+ refute_predicate storage, :exists?
+ assert_nil storage.read
+ end
+
+ def test_memory_storage_returns_nil_when_empty
+ storage = Classifier::Storage::Memory.new
+
+ assert_nil storage.read
+ end
+
+ # Storage::File tests
+ def test_file_storage_write_and_read
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'test.json')
+ storage = Classifier::Storage::File.new(path: path)
+ storage.write('{"test": "data"}')
+
+ assert_equal '{"test": "data"}', storage.read
+ end
+ end
+
+ def test_file_storage_exists_false_initially
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'nonexistent.json')
+ storage = Classifier::Storage::File.new(path: path)
+
+ refute_predicate storage, :exists?
+ end
+ end
+
+ def test_file_storage_exists_true_after_write
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'test.json')
+ storage = Classifier::Storage::File.new(path: path)
+ storage.write('test data')
+
+ assert_predicate storage, :exists?
+ end
+ end
+
+ def test_file_storage_delete
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'test.json')
+ storage = Classifier::Storage::File.new(path: path)
+ storage.write('test data')
+ storage.delete
+
+ refute_predicate storage, :exists?
+ end
+ end
+
+ def test_file_storage_returns_nil_when_missing
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'nonexistent.json')
+ storage = Classifier::Storage::File.new(path: path)
+
+ assert_nil storage.read
+ end
+ end
+
+ def test_file_storage_exposes_path
+ storage = Classifier::Storage::File.new(path: '/some/path.json')
+
+ assert_equal '/some/path.json', storage.path
+ end
+end
+
+class BayesStorageTest < Minitest::Test
+ def setup
+ @classifier = Classifier::Bayes.new 'Spam', 'Ham'
+ end
+
+ # Dirty tracking tests
+ def test_new_classifier_is_not_dirty
+ refute_predicate @classifier, :dirty?
+ end
+
+ def test_training_makes_classifier_dirty
+ @classifier.train_spam 'buy now'
+
+ assert_predicate @classifier, :dirty?
+ end
+
+ def test_untraining_makes_classifier_dirty
+ @classifier.train_spam 'buy now'
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.save
+
+ refute_predicate @classifier, :dirty?
+
+ @classifier.untrain_spam 'buy now'
+
+ assert_predicate @classifier, :dirty?
+ end
+
+ def test_add_category_makes_classifier_dirty
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.save
+
+ refute_predicate @classifier, :dirty?
+
+ @classifier.add_category 'Phishing'
+
+ assert_predicate @classifier, :dirty?
+ end
+
+ def test_remove_category_makes_classifier_dirty
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.save
+
+ refute_predicate @classifier, :dirty?
+
+ @classifier.remove_category 'Spam'
+
+ assert_predicate @classifier, :dirty?
+ end
+
+ # Storage accessor tests
+ def test_storage_accessor
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+
+ assert_equal storage, @classifier.storage
+ end
+
+ def test_storage_is_nil_by_default
+ assert_nil @classifier.storage
+ end
+
+ # Save tests
+ def test_save_raises_without_storage
+ assert_raises(ArgumentError) { @classifier.save }
+ end
+
+ def test_save_with_storage
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now'
+ @classifier.save
+
+ assert_predicate storage, :exists?
+ end
+
+ def test_save_clears_dirty_flag
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now'
+
+ assert_predicate @classifier, :dirty?
+
+ @classifier.save
+
+ refute_predicate @classifier, :dirty?
+ end
+
+ def test_save_to_file_clears_dirty_flag
+ @classifier.train_spam 'buy now'
+
+ assert_predicate @classifier, :dirty?
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ @classifier.save_to_file(path)
+
+ refute_predicate @classifier, :dirty?
+ end
+ end
+
+ # Reload tests
+ def test_reload_raises_without_storage
+ assert_raises(ArgumentError) { @classifier.reload }
+ end
+
+ def test_reload_raises_when_dirty
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now'
+ @classifier.save
+
+ @classifier.train_ham 'hello friend'
+
+ assert_predicate @classifier, :dirty?
+
+ assert_raises(Classifier::UnsavedChangesError) { @classifier.reload }
+ end
+
+ def test_reload_raises_when_no_saved_state
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ assert_raises(Classifier::StorageError) { @classifier.reload }
+ end
+
+ def test_reload_restores_saved_state
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now limited offer'
+ @classifier.save
+
+ original_classification = @classifier.classify('buy now')
+
+ # Modify the classifier (but don't mark as dirty for this test)
+ @classifier.instance_variable_set(:@dirty, false)
+
+ result = @classifier.reload
+
+ assert_equal @classifier, result
+ assert_equal original_classification, @classifier.classify('buy now')
+ end
+
+ def test_reload_bang_forces_reload_even_when_dirty
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now'
+ @classifier.save
+
+ @classifier.train_ham 'hello friend'
+
+ assert_predicate @classifier, :dirty?
+
+ @classifier.reload!
+
+ refute_predicate @classifier, :dirty?
+ end
+
+ def test_reload_bang_raises_when_no_saved_state
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ assert_raises(Classifier::StorageError) { @classifier.reload! }
+ end
+
+ # Load with storage tests
+ def test_load_with_storage
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now limited offer'
+ @classifier.train_ham 'hello friend'
+ @classifier.save
+
+ loaded = Classifier::Bayes.load(storage: storage)
+
+ assert_equal storage, loaded.storage
+ assert_equal @classifier.classify('buy now'), loaded.classify('buy now')
+ assert_equal @classifier.classify('hello friend'), loaded.classify('hello friend')
+ end
+
+ def test_load_raises_when_no_saved_state
+ storage = Classifier::Storage::Memory.new
+ assert_raises(Classifier::StorageError) { Classifier::Bayes.load(storage: storage) }
+ end
+
+ def test_loaded_classifier_can_save_immediately
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now'
+ @classifier.save
+
+ loaded = Classifier::Bayes.load(storage: storage)
+ loaded.train_ham 'hello friend'
+ loaded.save # Should not raise
+
+ reloaded = Classifier::Bayes.load(storage: storage)
+
+ assert_equal 'Ham', reloaded.classify('hello friend')
+ end
+
+ # Marshal tests
+ def test_marshal_preserves_dirty_flag
+ @classifier.train_spam 'buy now'
+
+ assert_predicate @classifier, :dirty?
+
+ dumped = Marshal.dump(@classifier)
+ loaded = Marshal.load(dumped)
+
+ assert_predicate loaded, :dirty?
+ end
+
+ def test_marshal_does_not_preserve_storage
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now'
+
+ dumped = Marshal.dump(@classifier)
+ loaded = Marshal.load(dumped)
+
+ assert_nil loaded.storage
+ end
+end
+
+class LSIStorageTest < Minitest::Test
+ def setup
+ @lsi = Classifier::LSI.new
+ @str1 = 'Dogs love to run and play outside with their owners'
+ @str2 = 'Cats prefer to relax indoors and sleep all day'
+ end
+
+ # Dirty tracking tests
+ def test_new_lsi_is_not_dirty
+ refute_predicate @lsi, :dirty?
+ end
+
+ def test_add_item_makes_lsi_dirty
+ @lsi.add_item @str1, 'Dog'
+
+ assert_predicate @lsi, :dirty?
+ end
+
+ def test_remove_item_makes_lsi_dirty
+ @lsi.add_item @str1, 'Dog'
+ storage = Classifier::Storage::Memory.new
+ @lsi.storage = storage
+ @lsi.save
+
+ refute_predicate @lsi, :dirty?
+
+ @lsi.remove_item @str1
+
+ assert_predicate @lsi, :dirty?
+ end
+
+ # Storage accessor tests
+ def test_storage_accessor
+ storage = Classifier::Storage::Memory.new
+ @lsi.storage = storage
+
+ assert_equal storage, @lsi.storage
+ end
+
+ def test_storage_is_nil_by_default
+ assert_nil @lsi.storage
+ end
+
+ # Save tests
+ def test_save_raises_without_storage
+ assert_raises(ArgumentError) { @lsi.save }
+ end
+
+ def test_save_with_storage
+ storage = Classifier::Storage::Memory.new
+ @lsi.storage = storage
+ @lsi.add_item @str1, 'Dog'
+ @lsi.add_item @str2, 'Cat'
+ @lsi.save
+
+ assert_predicate storage, :exists?
+ end
+
+ def test_save_clears_dirty_flag
+ storage = Classifier::Storage::Memory.new
+ @lsi.storage = storage
+ @lsi.add_item @str1, 'Dog'
+
+ assert_predicate @lsi, :dirty?
+
+ @lsi.add_item @str2, 'Cat'
+ @lsi.save
+
+ refute_predicate @lsi, :dirty?
+ end
+
+ def test_save_to_file_clears_dirty_flag
+ @lsi.add_item @str1, 'Dog'
+ @lsi.add_item @str2, 'Cat'
+
+ assert_predicate @lsi, :dirty?
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'lsi.json')
+ @lsi.save_to_file(path)
+
+ refute_predicate @lsi, :dirty?
+ end
+ end
+
+ # Reload tests
+ def test_reload_raises_without_storage
+ assert_raises(ArgumentError) { @lsi.reload }
+ end
+
+ def test_reload_raises_when_dirty
+ storage = Classifier::Storage::Memory.new
+ @lsi.storage = storage
+ @lsi.add_item @str1, 'Dog'
+ @lsi.add_item @str2, 'Cat'
+ @lsi.save
+
+ @lsi.add_item 'Birds fly in the sky', 'Bird'
+
+ assert_predicate @lsi, :dirty?
+
+ assert_raises(Classifier::UnsavedChangesError) { @lsi.reload }
+ end
+
+ def test_reload_raises_when_no_saved_state
+ storage = Classifier::Storage::Memory.new
+ @lsi.storage = storage
+ assert_raises(Classifier::StorageError) { @lsi.reload }
+ end
+
+ def test_reload_bang_forces_reload_even_when_dirty
+ storage = Classifier::Storage::Memory.new
+ @lsi.storage = storage
+ @lsi.add_item @str1, 'Dog'
+ @lsi.add_item @str2, 'Cat'
+ @lsi.save
+
+ @lsi.add_item 'Birds fly in the sky', 'Bird'
+
+ assert_predicate @lsi, :dirty?
+
+ @lsi.reload!
+
+ refute_predicate @lsi, :dirty?
+ assert_equal 2, @lsi.items.size
+ end
+
+ def test_reload_bang_raises_when_no_saved_state
+ storage = Classifier::Storage::Memory.new
+ @lsi.storage = storage
+ assert_raises(Classifier::StorageError) { @lsi.reload! }
+ end
+
+ # Load with storage tests
+ def test_load_with_storage
+ storage = Classifier::Storage::Memory.new
+ @lsi.storage = storage
+ @lsi.add_item @str1, 'Dog'
+ @lsi.add_item @str2, 'Cat'
+ @lsi.save
+
+ loaded = Classifier::LSI.load(storage: storage)
+
+ assert_equal storage, loaded.storage
+ assert_equal 2, loaded.items.size
+ end
+
+ def test_load_raises_when_no_saved_state
+ storage = Classifier::Storage::Memory.new
+ assert_raises(Classifier::StorageError) { Classifier::LSI.load(storage: storage) }
+ end
+
+ def test_loaded_lsi_can_save_immediately
+ storage = Classifier::Storage::Memory.new
+ @lsi.storage = storage
+ @lsi.add_item @str1, 'Dog'
+ @lsi.add_item @str2, 'Cat'
+ @lsi.save
+
+ loaded = Classifier::LSI.load(storage: storage)
+ loaded.add_item 'Birds fly in the sky', 'Bird'
+ loaded.save # Should not raise
+
+ reloaded = Classifier::LSI.load(storage: storage)
+
+ assert_equal 3, reloaded.items.size
+ end
+
+ # Marshal tests
+ def test_marshal_preserves_dirty_flag
+ @lsi.add_item @str1, 'Dog'
+ @lsi.add_item @str2, 'Cat'
+
+ assert_predicate @lsi, :dirty?
+
+ dumped = Marshal.dump(@lsi)
+ loaded = Marshal.load(dumped)
+
+ assert_predicate loaded, :dirty?
+ end
+
+ def test_marshal_does_not_preserve_storage
+ storage = Classifier::Storage::Memory.new
+ @lsi.storage = storage
+ @lsi.add_item @str1, 'Dog'
+ @lsi.add_item @str2, 'Cat'
+
+ dumped = Marshal.dump(@lsi)
+ loaded = Marshal.load(dumped)
+
+ assert_nil loaded.storage
+ end
+end
+
+class LogisticRegressionStorageTest < Minitest::Test
+ def setup
+ @classifier = Classifier::LogisticRegression.new 'Spam', 'Ham'
+ end
+
+ # Dirty tracking tests
+ def test_new_classifier_is_not_dirty
+ refute_predicate @classifier, :dirty?
+ end
+
+ def test_training_makes_classifier_dirty
+ @classifier.train_spam 'buy now'
+
+ assert_predicate @classifier, :dirty?
+ end
+
+ # Storage accessor tests
+ def test_storage_accessor
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+
+ assert_equal storage, @classifier.storage
+ end
+
+ def test_storage_is_nil_by_default
+ assert_nil @classifier.storage
+ end
+
+ # Save tests
+ def test_save_raises_without_storage
+ assert_raises(ArgumentError) { @classifier.save }
+ end
+
+ def test_save_with_storage
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now'
+ @classifier.train_ham 'hello friend'
+ @classifier.save
+
+ assert_predicate storage, :exists?
+ end
+
+ def test_save_clears_dirty_flag
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now'
+ @classifier.train_ham 'hello friend'
+
+ assert_predicate @classifier, :dirty?
+
+ @classifier.save
+
+ refute_predicate @classifier, :dirty?
+ end
+
+ def test_save_to_file_clears_dirty_flag
+ @classifier.train_spam 'buy now'
+ @classifier.train_ham 'hello friend'
+
+ assert_predicate @classifier, :dirty?
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'classifier.json')
+ @classifier.save_to_file(path)
+
+ refute_predicate @classifier, :dirty?
+ end
+ end
+
+ # Reload tests
+ def test_reload_raises_without_storage
+ assert_raises(ArgumentError) { @classifier.reload }
+ end
+
+ def test_reload_raises_when_dirty
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now'
+ @classifier.train_ham 'hello friend'
+ @classifier.save
+
+ @classifier.train_spam 'more spam'
+
+ assert_predicate @classifier, :dirty?
+
+ assert_raises(Classifier::UnsavedChangesError) { @classifier.reload }
+ end
+
+ def test_reload_raises_when_no_saved_state
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+
+ assert_raises(Classifier::StorageError) { @classifier.reload }
+ end
+
+ def test_reload_restores_saved_state
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now limited offer'
+ @classifier.train_ham 'hello friend meeting'
+ @classifier.fit
+ @classifier.save
+
+ original_classification = @classifier.classify('buy now')
+
+ @classifier.instance_variable_set(:@dirty, false)
+
+ result = @classifier.reload
+
+ assert_equal @classifier, result
+ assert_equal original_classification, @classifier.classify('buy now')
+ end
+
+ def test_reload_bang_forces_reload_even_when_dirty
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now'
+ @classifier.train_ham 'hello friend'
+ @classifier.save
+
+ @classifier.train_spam 'more spam'
+
+ assert_predicate @classifier, :dirty?
+
+ @classifier.reload!
+
+ refute_predicate @classifier, :dirty?
+ end
+
+ def test_reload_bang_raises_when_no_saved_state
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+
+ assert_raises(Classifier::StorageError) { @classifier.reload! }
+ end
+
+ # Load with storage tests
+ def test_load_with_storage
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now limited offer'
+ @classifier.train_ham 'hello friend meeting'
+ @classifier.fit
+ @classifier.save
+
+ loaded = Classifier::LogisticRegression.load(storage: storage)
+
+ assert_equal storage, loaded.storage
+ assert_equal @classifier.classify('buy now'), loaded.classify('buy now')
+ assert_equal @classifier.classify('hello friend'), loaded.classify('hello friend')
+ end
+
+ def test_load_raises_when_no_saved_state
+ storage = Classifier::Storage::Memory.new
+
+ assert_raises(Classifier::StorageError) { Classifier::LogisticRegression.load(storage: storage) }
+ end
+
+ def test_loaded_classifier_can_save_immediately
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now'
+ @classifier.train_ham 'hello friend'
+ @classifier.fit
+ @classifier.save
+
+ loaded = Classifier::LogisticRegression.load(storage: storage)
+ loaded.train_spam 'more spam words'
+ loaded.fit
+ loaded.save
+
+ reloaded = Classifier::LogisticRegression.load(storage: storage)
+
+ assert_equal 'Spam', reloaded.classify('spam words')
+ end
+
+ # Marshal tests
+ def test_marshal_preserves_fitted_state
+ @classifier.train_spam 'buy now'
+ @classifier.train_ham 'hello friend'
+ @classifier.fit
+
+ assert_predicate @classifier, :fitted?
+
+ dumped = Marshal.dump(@classifier)
+ loaded = Marshal.load(dumped)
+
+ assert_predicate loaded, :fitted?
+ end
+
+ def test_marshal_does_not_preserve_storage
+ storage = Classifier::Storage::Memory.new
+ @classifier.storage = storage
+ @classifier.train_spam 'buy now'
+ @classifier.train_ham 'hello friend'
+
+ dumped = Marshal.dump(@classifier)
+ loaded = Marshal.load(dumped)
+
+ assert_nil loaded.storage
+ end
+end
+
+class TFIDFStorageTest < Minitest::Test
+ def setup
+ @tfidf = Classifier::TFIDF.new
+ @documents = ['Dogs are great pets', 'Cats are independent', 'Birds can fly']
+ end
+
+ # Dirty tracking tests
+ def test_new_tfidf_is_not_dirty
+ refute_predicate @tfidf, :dirty?
+ end
+
+ def test_fit_makes_tfidf_dirty
+ @tfidf.fit(@documents)
+
+ assert_predicate @tfidf, :dirty?
+ end
+
+ # Storage accessor tests
+ def test_storage_accessor
+ storage = Classifier::Storage::Memory.new
+ @tfidf.storage = storage
+
+ assert_equal storage, @tfidf.storage
+ end
+
+ def test_storage_is_nil_by_default
+ assert_nil @tfidf.storage
+ end
+
+ # Save tests
+ def test_save_raises_without_storage
+ @tfidf.fit(@documents)
+
+ assert_raises(ArgumentError) { @tfidf.save }
+ end
+
+ def test_save_with_storage
+ storage = Classifier::Storage::Memory.new
+ @tfidf.storage = storage
+ @tfidf.fit(@documents)
+ @tfidf.save
+
+ assert_predicate storage, :exists?
+ end
+
+ def test_save_clears_dirty_flag
+ storage = Classifier::Storage::Memory.new
+ @tfidf.storage = storage
+ @tfidf.fit(@documents)
+
+ assert_predicate @tfidf, :dirty?
+
+ @tfidf.save
+
+ refute_predicate @tfidf, :dirty?
+ end
+
+ def test_save_to_file_clears_dirty_flag
+ @tfidf.fit(@documents)
+
+ assert_predicate @tfidf, :dirty?
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'tfidf.json')
+ @tfidf.save_to_file(path)
+
+ refute_predicate @tfidf, :dirty?
+ end
+ end
+
+ # Reload tests
+ def test_reload_raises_without_storage
+ assert_raises(ArgumentError) { @tfidf.reload }
+ end
+
+ def test_reload_raises_when_dirty
+ storage = Classifier::Storage::Memory.new
+ @tfidf.storage = storage
+ @tfidf.fit(@documents)
+ @tfidf.save
+
+ @tfidf.fit(['New documents here'])
+
+ assert_predicate @tfidf, :dirty?
+
+ assert_raises(Classifier::UnsavedChangesError) { @tfidf.reload }
+ end
+
+ def test_reload_raises_when_no_saved_state
+ storage = Classifier::Storage::Memory.new
+ @tfidf.storage = storage
+
+ assert_raises(Classifier::StorageError) { @tfidf.reload }
+ end
+
+ def test_reload_restores_saved_state
+ storage = Classifier::Storage::Memory.new
+ @tfidf.storage = storage
+ @tfidf.fit(@documents)
+ @tfidf.save
+
+ original_vocab_size = @tfidf.vocabulary.size
+
+ @tfidf.instance_variable_set(:@dirty, false)
+
+ result = @tfidf.reload
+
+ assert_equal @tfidf, result
+ assert_equal original_vocab_size, @tfidf.vocabulary.size
+ end
+
+ def test_reload_bang_forces_reload_even_when_dirty
+ storage = Classifier::Storage::Memory.new
+ @tfidf.storage = storage
+ @tfidf.fit(@documents)
+ @tfidf.save
+
+ original_vocab_size = @tfidf.vocabulary.size
+
+ @tfidf.fit(['Completely different documents with new words'])
+
+ assert_predicate @tfidf, :dirty?
+
+ @tfidf.reload!
+
+ refute_predicate @tfidf, :dirty?
+ assert_equal original_vocab_size, @tfidf.vocabulary.size
+ end
+
+ def test_reload_bang_raises_when_no_saved_state
+ storage = Classifier::Storage::Memory.new
+ @tfidf.storage = storage
+
+ assert_raises(Classifier::StorageError) { @tfidf.reload! }
+ end
+
+ # Load with storage tests
+ def test_load_with_storage
+ storage = Classifier::Storage::Memory.new
+ @tfidf.storage = storage
+ @tfidf.fit(@documents)
+ @tfidf.save
+
+ loaded = Classifier::TFIDF.load(storage: storage)
+
+ assert_equal storage, loaded.storage
+ assert_equal @tfidf.vocabulary, loaded.vocabulary
+ assert_equal @tfidf.idf, loaded.idf
+ end
+
+ def test_load_raises_when_no_saved_state
+ storage = Classifier::Storage::Memory.new
+
+ assert_raises(Classifier::StorageError) { Classifier::TFIDF.load(storage: storage) }
+ end
+
+ def test_loaded_tfidf_can_save_immediately
+ storage = Classifier::Storage::Memory.new
+ @tfidf.storage = storage
+ @tfidf.fit(@documents)
+ @tfidf.save
+
+ loaded = Classifier::TFIDF.load(storage: storage)
+ loaded.fit(['New set of documents'])
+ loaded.save
+
+ reloaded = Classifier::TFIDF.load(storage: storage)
+
+ assert_predicate reloaded, :fitted?
+ end
+
+ def test_load_from_file
+ @tfidf.fit(@documents)
+
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, 'tfidf.json')
+ @tfidf.save_to_file(path)
+
+ loaded = Classifier::TFIDF.load_from_file(path)
+
+ assert_equal @tfidf.vocabulary, loaded.vocabulary
+ assert_equal @tfidf.idf, loaded.idf
+ end
+ end
+
+ # Marshal tests
+ def test_marshal_preserves_fitted_state
+ @tfidf.fit(@documents)
+
+ assert_predicate @tfidf, :fitted?
+
+ dumped = Marshal.dump(@tfidf)
+ loaded = Marshal.load(dumped)
+
+ assert_predicate loaded, :fitted?
+ assert_equal @tfidf.vocabulary, loaded.vocabulary
+ end
+
+ def test_marshal_does_not_preserve_storage
+ storage = Classifier::Storage::Memory.new
+ @tfidf.storage = storage
+ @tfidf.fit(@documents)
+
+ dumped = Marshal.dump(@tfidf)
+ loaded = Marshal.load(dumped)
+
+ assert_nil loaded.storage
+ end
+
+ def test_marshal_sets_dirty_to_false
+ @tfidf.fit(@documents)
+
+ assert_predicate @tfidf, :dirty?
+
+ dumped = Marshal.dump(@tfidf)
+ loaded = Marshal.load(dumped)
+
+ refute_predicate loaded, :dirty?
+ end
+end
diff --git a/test/streaming/streaming_test.rb b/test/streaming/streaming_test.rb
new file mode 100644
index 0000000..5e4c5c9
--- /dev/null
+++ b/test/streaming/streaming_test.rb
@@ -0,0 +1,374 @@
+require_relative '../test_helper'
+require 'stringio'
+
+class ProgressTest < Minitest::Test
+ def test_initialization_with_defaults
+ progress = Classifier::Streaming::Progress.new
+
+ assert_equal 0, progress.completed
+ assert_nil progress.total
+ assert_instance_of Time, progress.start_time
+ end
+
+ def test_initialization_with_total
+ progress = Classifier::Streaming::Progress.new(total: 100)
+
+ assert_equal 0, progress.completed
+ assert_equal 100, progress.total
+ end
+
+ def test_initialization_with_completed
+ progress = Classifier::Streaming::Progress.new(total: 100, completed: 50)
+
+ assert_equal 50, progress.completed
+ assert_equal 100, progress.total
+ end
+
+ def test_percent_with_known_total
+ progress = Classifier::Streaming::Progress.new(total: 100, completed: 25)
+
+ assert_in_delta(25.0, progress.percent)
+ end
+
+ def test_percent_with_fractional_value
+ progress = Classifier::Streaming::Progress.new(total: 3, completed: 1)
+
+ assert_in_delta(33.33, progress.percent)
+ end
+
+ def test_percent_with_unknown_total
+ progress = Classifier::Streaming::Progress.new
+ progress.completed = 50
+
+ assert_nil progress.percent
+ end
+
+ def test_percent_with_zero_total
+ progress = Classifier::Streaming::Progress.new(total: 0)
+
+ assert_nil progress.percent
+ end
+
+ def test_elapsed_time
+ progress = Classifier::Streaming::Progress.new
+ sleep 0.01
+
+ assert_operator progress.elapsed, :>=, 0.01
+ assert_operator progress.elapsed, :<, 1
+ end
+
+ def test_rate_calculation
+ progress = Classifier::Streaming::Progress.new(completed: 0)
+ sleep 0.01 # Ensure some time passes
+ progress.completed = 100
+ # Rate should be roughly 100 / elapsed (approximately 10000/s)
+ rate = progress.rate
+
+ assert_predicate rate, :positive?
+ end
+
+ def test_rate_with_zero_elapsed
+ # This is tricky to test since time always passes,
+ # but rate should handle edge cases gracefully
+ progress = Classifier::Streaming::Progress.new
+ rate = progress.rate
+ # Rate is 0 when completed is 0
+ assert_in_delta(0.0, rate)
+ end
+
+ def test_eta_with_known_total
+ progress = Classifier::Streaming::Progress.new(total: 100, completed: 50)
+ sleep 0.01 # Ensure some time passes
+ eta = progress.eta
+
+ assert_instance_of Float, eta
+ assert_operator eta, :>=, 0
+ end
+
+ def test_eta_with_unknown_total
+ progress = Classifier::Streaming::Progress.new(completed: 50)
+
+ assert_nil progress.eta
+ end
+
+ def test_eta_when_complete
+ progress = Classifier::Streaming::Progress.new(total: 100, completed: 100)
+ sleep 0.01
+
+ assert_in_delta(0.0, progress.eta)
+ end
+
+ def test_eta_with_zero_rate
+ progress = Classifier::Streaming::Progress.new(total: 100, completed: 0)
+
+ assert_nil progress.eta
+ end
+
+ def test_complete_when_finished
+ progress = Classifier::Streaming::Progress.new(total: 100, completed: 100)
+
+ assert_predicate progress, :complete?
+ end
+
+ def test_complete_when_not_finished
+ progress = Classifier::Streaming::Progress.new(total: 100, completed: 50)
+
+ refute_predicate progress, :complete?
+ end
+
+ def test_complete_with_unknown_total
+ progress = Classifier::Streaming::Progress.new(completed: 100)
+
+ refute_predicate progress, :complete?
+ end
+
+ def test_to_h
+ progress = Classifier::Streaming::Progress.new(total: 100, completed: 50)
+ hash = progress.to_h
+
+ assert_equal 50, hash[:completed]
+ assert_equal 100, hash[:total]
+ assert_in_delta(50.0, hash[:percent])
+ assert_instance_of Float, hash[:elapsed]
+ assert_instance_of Float, hash[:rate]
+ end
+
+ def test_completed_is_mutable
+ progress = Classifier::Streaming::Progress.new(total: 100)
+
+ assert_equal 0, progress.completed
+
+ progress.completed = 25
+
+ assert_equal 25, progress.completed
+
+ progress.completed += 25
+
+ assert_equal 50, progress.completed
+ end
+
+ def test_current_batch_tracking
+ progress = Classifier::Streaming::Progress.new(total: 100)
+
+ assert_equal 0, progress.current_batch
+
+ progress.current_batch = 5
+
+ assert_equal 5, progress.current_batch
+ end
+end
+
+class LineReaderTest < Minitest::Test
+ def test_each_line
+ io = StringIO.new("line1\nline2\nline3\n")
+ reader = Classifier::Streaming::LineReader.new(io)
+
+ lines = reader.each.to_a
+
+ assert_equal %w[line1 line2 line3], lines
+ end
+
+ def test_each_line_removes_trailing_newlines
+ io = StringIO.new("line1\nline2\r\nline3")
+ reader = Classifier::Streaming::LineReader.new(io)
+
+ lines = reader.each.to_a
+ # chomp removes both \n and \r\n
+ assert_equal %w[line1 line2 line3], lines
+ end
+
+ def test_each_with_block
+ io = StringIO.new("a\nb\nc\n")
+ reader = Classifier::Streaming::LineReader.new(io)
+
+ collected = reader.map(&:upcase)
+
+ assert_equal %w[A B C], collected
+ end
+
+ def test_enumerable_methods
+ io = StringIO.new("1\n2\n3\n")
+ reader = Classifier::Streaming::LineReader.new(io)
+
+ # LineReader includes Enumerable
+ assert_equal %w[1 2 3], reader.first(3)
+ end
+
+ def test_each_batch_default_size
+ lines = (1..250).map { |i| "line#{i}" }.join("\n")
+ io = StringIO.new(lines)
+ reader = Classifier::Streaming::LineReader.new(io)
+
+ batches = reader.each_batch.to_a
+
+ assert_equal 3, batches.size
+ assert_equal 100, batches[0].size
+ assert_equal 100, batches[1].size
+ assert_equal 50, batches[2].size
+ end
+
+ def test_each_batch_custom_size
+ lines = (1..25).map { |i| "line#{i}" }.join("\n")
+ io = StringIO.new(lines)
+ reader = Classifier::Streaming::LineReader.new(io, batch_size: 10)
+
+ batches = reader.each_batch.to_a
+
+ assert_equal 3, batches.size
+ assert_equal 10, batches[0].size
+ assert_equal 10, batches[1].size
+ assert_equal 5, batches[2].size
+ end
+
+ def test_each_batch_with_block
+ io = StringIO.new("a\nb\nc\nd\ne\n")
+ reader = Classifier::Streaming::LineReader.new(io, batch_size: 2)
+
+ collected = []
+ reader.each_batch { |batch| collected << batch }
+
+ assert_equal [%w[a b], %w[c d], ['e']], collected
+ end
+
+ def test_each_batch_exact_multiple
+ lines = (1..10).map { |i| "line#{i}" }.join("\n")
+ io = StringIO.new(lines)
+ reader = Classifier::Streaming::LineReader.new(io, batch_size: 5)
+
+ batches = reader.each_batch.to_a
+
+ assert_equal 2, batches.size
+ assert_equal 5, batches[0].size
+ assert_equal 5, batches[1].size
+ end
+
+ def test_each_batch_empty_io
+ io = StringIO.new('')
+ reader = Classifier::Streaming::LineReader.new(io)
+
+ batches = reader.each_batch.to_a
+
+ assert_empty batches
+ end
+
+ def test_batch_size_reader
+ reader = Classifier::Streaming::LineReader.new(StringIO.new(''), batch_size: 50)
+
+ assert_equal 50, reader.batch_size
+ end
+
+ def test_estimate_line_count_with_seekable_io
+ # Create a file-like StringIO
+ lines = "short\nmedium line\nlonger line here\n"
+ io = StringIO.new(lines)
+
+ reader = Classifier::Streaming::LineReader.new(io)
+ estimate = reader.estimate_line_count(sample_size: 3)
+
+ # Should be close to 3 lines
+ assert_instance_of Integer, estimate
+ assert_predicate estimate, :positive?
+ end
+
+ def test_estimate_line_count_preserves_position
+ lines = "a\nb\nc\nd\ne\n"
+ io = StringIO.new(lines)
+ io.gets # Read one line, position is now after "a\n"
+ original_pos = io.pos
+
+ reader = Classifier::Streaming::LineReader.new(io)
+ reader.estimate_line_count
+
+ assert_equal original_pos, io.pos
+ end
+end
+
+class StreamingModuleTest < Minitest::Test
+ def test_default_batch_size
+ assert_equal 100, Classifier::Streaming::DEFAULT_BATCH_SIZE
+ end
+
+ class DummyClassifier
+ include Classifier::Streaming
+
+ attr_accessor :storage
+ end
+
+ def test_train_from_stream_raises_not_implemented
+ classifier = DummyClassifier.new
+ io = StringIO.new("test\n")
+
+ assert_raises(NotImplementedError) do
+ classifier.train_from_stream(:category, io)
+ end
+ end
+
+ def test_train_batch_raises_not_implemented
+ classifier = DummyClassifier.new
+
+ assert_raises(NotImplementedError) do
+ classifier.train_batch(:category, %w[doc1 doc2])
+ end
+ end
+
+ def test_save_checkpoint_requires_storage
+ classifier = DummyClassifier.new
+
+ assert_raises(ArgumentError) do
+ classifier.save_checkpoint('test')
+ end
+ end
+
+ def test_list_checkpoints_requires_storage
+ classifier = DummyClassifier.new
+
+ assert_raises(ArgumentError) do
+ classifier.list_checkpoints
+ end
+ end
+
+ def test_delete_checkpoint_requires_storage
+ classifier = DummyClassifier.new
+
+ assert_raises(ArgumentError) do
+ classifier.delete_checkpoint('test')
+ end
+ end
+end
+
+class StreamingEnforcementTest < Minitest::Test
+ # Dynamically discover all classifier/vectorizer classes by looking for
+ # classes that define `classify` (classifiers) or `transform` (vectorizers)
+ CLASSIFIERS = Classifier.constants.filter_map do |const|
+ klass = Classifier.const_get(const)
+ next unless klass.is_a?(Class)
+
+ klass if klass.method_defined?(:classify) || klass.method_defined?(:transform)
+ end.freeze
+
+ STREAMING_METHODS = %i[
+ train_from_stream
+ train_batch
+ save_checkpoint
+ list_checkpoints
+ delete_checkpoint
+ ].freeze
+
+ def test_classifiers_discovered
+ assert_operator CLASSIFIERS.size, :>=, 5, "Expected at least 5 classifiers, found: #{CLASSIFIERS.map(&:name)}"
+ end
+
+ CLASSIFIERS.each do |klass|
+ define_method("test_#{klass.name.split('::').last.downcase}_includes_streaming") do
+ assert_includes klass, Classifier::Streaming,
+ "#{klass} must include Classifier::Streaming"
+ end
+
+ STREAMING_METHODS.each do |method|
+ define_method("test_#{klass.name.split('::').last.downcase}_responds_to_#{method}") do
+ assert klass.method_defined?(method) || klass.private_method_defined?(method),
+ "#{klass} must respond to #{method}"
+ end
+ end
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 220ae07..c6652aa 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,4 +1,17 @@
-$:.unshift(File.dirname(__FILE__) + '/../lib')
+require 'simplecov'
+SimpleCov.start do
+ add_filter '/test/'
+ add_filter '/vendor/'
+ add_group 'Bayes', 'lib/classifier/bayes.rb'
+ add_group 'LSI', 'lib/classifier/lsi'
+ add_group 'Extensions', 'lib/classifier/extensions'
+ enable_coverage :branch
+end
-require 'test/unit'
-require 'classifier' \ No newline at end of file
+$LOAD_PATH.unshift("#{File.dirname(__FILE__)}/../lib")
+
+require 'minitest'
+require 'minitest/autorun'
+require 'tmpdir'
+require 'json'
+require 'classifier'
diff --git a/test/tfidf/tfidf_test.rb b/test/tfidf/tfidf_test.rb
new file mode 100644
index 0000000..23f35f4
--- /dev/null
+++ b/test/tfidf/tfidf_test.rb
@@ -0,0 +1,449 @@
+require_relative '../test_helper'
+
+class TFIDFTest < Minitest::Test
+ def setup
+ @doc1 = 'Dogs are great pets and very loyal'
+ @doc2 = 'Cats are independent and self-sufficient'
+ @doc3 = 'Birds can fly and sing beautiful songs'
+ @doc4 = 'Dogs and cats are popular pets'
+ @corpus = [@doc1, @doc2, @doc3, @doc4]
+ end
+
+ # Initialization tests
+
+ def test_default_initialization
+ tfidf = Classifier::TFIDF.new
+
+ refute_predicate tfidf, :fitted?
+ assert_empty tfidf.vocabulary
+ assert_empty tfidf.idf
+ assert_equal 0, tfidf.num_documents
+ end
+
+ def test_custom_min_df_integer
+ tfidf = Classifier::TFIDF.new(min_df: 2)
+
+ tfidf.fit(@corpus)
+
+ # Terms appearing in only 1 document should be excluded
+ tfidf.vocabulary.each_key do |term|
+ doc_count = @corpus.count { |doc| doc.clean_word_hash.key?(term) }
+
+ assert_operator doc_count, :>=, 2, "Term #{term} should appear in at least 2 documents"
+ end
+ end
+
+ def test_custom_min_df_float
+ tfidf = Classifier::TFIDF.new(min_df: 0.5)
+
+ tfidf.fit(@corpus)
+
+ # Terms appearing in less than 50% of documents should be excluded
+ min_count = (@corpus.size * 0.5).ceil
+ tfidf.vocabulary.each_key do |term|
+ doc_count = @corpus.count { |doc| doc.clean_word_hash.key?(term) }
+
+ assert_operator doc_count, :>=, min_count
+ end
+ end
+
+ def test_custom_max_df_integer
+ tfidf = Classifier::TFIDF.new(max_df: 2)
+
+ tfidf.fit(@corpus)
+
+ # Terms appearing in more than 2 documents should be excluded
+ tfidf.vocabulary.each_key do |term|
+ doc_count = @corpus.count { |doc| doc.clean_word_hash.key?(term) }
+
+ assert_operator doc_count, :<=, 2
+ end
+ end
+
+ def test_custom_max_df_float
+ tfidf = Classifier::TFIDF.new(max_df: 0.5)
+
+ tfidf.fit(@corpus)
+
+ # Terms appearing in more than 50% of documents should be excluded
+ max_count = (@corpus.size * 0.5).floor
+ tfidf.vocabulary.each_key do |term|
+ doc_count = @corpus.count { |doc| doc.clean_word_hash.key?(term) }
+
+ assert_operator doc_count, :<=, max_count
+ end
+ end
+
+ def test_invalid_min_df_raises
+ assert_raises(ArgumentError) { Classifier::TFIDF.new(min_df: -1) }
+ assert_raises(ArgumentError) { Classifier::TFIDF.new(min_df: 1.5) }
+ assert_raises(ArgumentError) { Classifier::TFIDF.new(min_df: 'invalid') }
+ end
+
+ def test_invalid_max_df_raises
+ assert_raises(ArgumentError) { Classifier::TFIDF.new(max_df: -1) }
+ assert_raises(ArgumentError) { Classifier::TFIDF.new(max_df: 1.5) }
+ assert_raises(ArgumentError) { Classifier::TFIDF.new(max_df: 'invalid') }
+ end
+
+ def test_invalid_ngram_range_raises
+ assert_raises(ArgumentError) { Classifier::TFIDF.new(ngram_range: [2, 1]) }
+ assert_raises(ArgumentError) { Classifier::TFIDF.new(ngram_range: [0, 1]) }
+ assert_raises(ArgumentError) { Classifier::TFIDF.new(ngram_range: [1]) }
+ assert_raises(ArgumentError) { Classifier::TFIDF.new(ngram_range: 'invalid') }
+ end
+
+ def test_custom_min_word_length
+ tfidf = Classifier::TFIDF.new(min_word_length: 5)
+ tfidf.fit(@corpus)
+
+ v = tfidf.transform('Dogs are loyal')
+
+ assert_equal(1, v.count)
+ assert_in_delta(1.0, v[:loyal])
+ end
+
+ # Fit tests
+
+ def test_fit_builds_vocabulary
+ tfidf = Classifier::TFIDF.new
+
+ tfidf.fit(@corpus)
+
+ assert_predicate tfidf, :fitted?
+ refute_empty tfidf.vocabulary
+ assert_equal @corpus.size, tfidf.num_documents
+ end
+
+ def test_fit_computes_idf
+ tfidf = Classifier::TFIDF.new
+
+ tfidf.fit(@corpus)
+
+ refute_empty tfidf.idf
+ assert_equal tfidf.vocabulary.size, tfidf.idf.size
+
+ # All IDF values should be positive
+ tfidf.idf.each_value do |idf_value|
+ assert_operator idf_value, :>, 0
+ end
+ end
+
+ def test_fit_idf_ordering
+ # Terms appearing in fewer documents should have higher IDF
+ docs = [
+ 'apple banana cherry',
+ 'apple banana date',
+ 'apple elderberry fig'
+ ]
+ tfidf = Classifier::TFIDF.new
+
+ tfidf.fit(docs)
+
+ # 'appl' appears in all 3 docs, 'banana' in 2, others in 1
+ # IDF should be: rare terms > common terms
+ assert_operator tfidf.idf[:elderberri], :>, tfidf.idf[:banana]
+ assert_operator tfidf.idf[:banana], :>, tfidf.idf[:appl]
+ end
+
+ def test_fit_returns_self
+ tfidf = Classifier::TFIDF.new
+
+ result = tfidf.fit(@corpus)
+
+ assert_same tfidf, result
+ end
+
+ def test_fit_with_empty_array_raises
+ tfidf = Classifier::TFIDF.new
+
+ assert_raises(ArgumentError) { tfidf.fit([]) }
+ end
+
+ def test_fit_with_non_array_raises
+ tfidf = Classifier::TFIDF.new
+
+ assert_raises(ArgumentError) { tfidf.fit('not an array') }
+ end
+
+ # Transform tests
+
+ def test_transform_returns_tfidf_vector
+ tfidf = Classifier::TFIDF.new
+ tfidf.fit(@corpus)
+
+ vector = tfidf.transform('Dogs are loyal pets')
+
+ assert_instance_of Hash, vector
+ refute_empty vector
+ vector.each_value { |v| assert_kind_of Float, v }
+ end
+
+ def test_transform_before_fit_raises
+ tfidf = Classifier::TFIDF.new
+
+ assert_raises(Classifier::NotFittedError) { tfidf.transform('Some text') }
+ end
+
+ def test_transform_normalizes_vector
+ tfidf = Classifier::TFIDF.new
+ tfidf.fit(@corpus)
+
+ vector = tfidf.transform('Dogs are loyal pets')
+
+ # L2 norm should be 1 (or close to it due to floating point)
+ magnitude = Math.sqrt(vector.values.sum { |v| v * v })
+
+ assert_in_delta 1.0, magnitude, 0.0001
+ end
+
+ def test_transform_unknown_terms_ignored
+ tfidf = Classifier::TFIDF.new
+ tfidf.fit(['apple banana', 'cherry date'])
+
+ # 'xyz' is not in vocabulary
+ vector = tfidf.transform('apple xyz')
+
+ refute vector.key?(:xyz)
+ assert vector.key?(:appl)
+ end
+
+ def test_transform_empty_result_for_unknown_text
+ tfidf = Classifier::TFIDF.new
+ tfidf.fit(['apple banana', 'cherry date'])
+
+ vector = tfidf.transform('xyz uvw')
+
+ assert_empty vector
+ end
+
+ # fit_transform tests
+
+ def test_fit_transform
+ tfidf = Classifier::TFIDF.new
+
+ vectors = tfidf.fit_transform(@corpus)
+
+ assert_predicate tfidf, :fitted?
+ assert_equal @corpus.size, vectors.size
+ vectors.each { |v| assert_instance_of Hash, v }
+ end
+
+ # Sublinear TF tests
+
+ def test_sublinear_tf
+ # Create document with repeated term
+ doc_with_repeats = 'dog dog dog dog cat'
+ corpus = [doc_with_repeats, 'bird fish']
+
+ tfidf_linear = Classifier::TFIDF.new(sublinear_tf: false)
+ tfidf_sublinear = Classifier::TFIDF.new(sublinear_tf: true)
+
+ tfidf_linear.fit(corpus)
+ tfidf_sublinear.fit(corpus)
+
+ vec_linear = tfidf_linear.transform(doc_with_repeats)
+ vec_sublinear = tfidf_sublinear.transform(doc_with_repeats)
+
+ # With sublinear TF, the ratio of dog to cat should be smaller
+ # because 1 + log(4) < 4 (relative to 1 + log(1) = 1)
+ ratio_linear = vec_linear[:dog] / vec_linear[:cat]
+ ratio_sublinear = vec_sublinear[:dog] / vec_sublinear[:cat]
+
+ assert_operator ratio_sublinear, :<, ratio_linear
+ end
+
+ # N-gram tests
+
+ def test_bigrams
+ tfidf = Classifier::TFIDF.new(ngram_range: [1, 2])
+
+ tfidf.fit(['quick brown fox', 'lazy brown dog'])
+
+ # Should have bigrams in vocabulary
+ bigram_terms = tfidf.vocabulary.keys.select { |t| t.to_s.include?('_') }
+
+ refute_empty bigram_terms, 'Should have bigram terms'
+ end
+
+ def test_bigrams_only
+ tfidf = Classifier::TFIDF.new(ngram_range: [2, 2])
+
+ tfidf.fit(['quick brown fox', 'lazy brown dog'])
+
+ # Should only have bigrams (terms with underscore)
+ tfidf.vocabulary.each_key do |term|
+ assert_includes term.to_s, '_', "Term #{term} should be a bigram"
+ end
+ end
+
+ def test_trigrams
+ tfidf = Classifier::TFIDF.new(ngram_range: [1, 3])
+
+ tfidf.fit(['quick brown fox jumps', 'lazy brown dog runs'])
+
+ trigram_terms = tfidf.vocabulary.keys.select { |t| t.to_s.count('_') == 2 }
+
+ refute_empty trigram_terms, 'Should have trigram terms'
+ end
+
+ # feature_names tests
+
+ def test_feature_names
+ tfidf = Classifier::TFIDF.new
+ tfidf.fit(@corpus)
+
+ names = tfidf.feature_names
+
+ assert_instance_of Array, names
+ assert_equal tfidf.vocabulary.size, names.size
+ names.each { |n| assert_instance_of Symbol, n }
+ end
+
+ # Serialization tests
+
+ def test_as_json
+ tfidf = Classifier::TFIDF.new(min_df: 2, sublinear_tf: true)
+ tfidf.fit(@corpus)
+
+ data = tfidf.as_json
+
+ assert_equal 1, data[:version]
+ assert_equal 'tfidf', data[:type]
+ assert_equal 2, data[:min_df]
+ assert data[:sublinear_tf]
+ assert data[:fitted]
+ refute_empty data[:vocabulary]
+ refute_empty data[:idf]
+ end
+
+ def test_to_json
+ tfidf = Classifier::TFIDF.new
+ tfidf.fit(@corpus)
+
+ json = tfidf.to_json
+ data = JSON.parse(json)
+
+ assert_equal 'tfidf', data['type']
+ assert data['fitted']
+ end
+
+ def test_from_json_string
+ tfidf = Classifier::TFIDF.new(min_df: 2, sublinear_tf: true)
+ tfidf.fit(@corpus)
+
+ json = tfidf.to_json
+ loaded = Classifier::TFIDF.from_json(json)
+
+ assert_predicate loaded, :fitted?
+ assert_equal tfidf.vocabulary.size, loaded.vocabulary.size
+ assert_equal tfidf.num_documents, loaded.num_documents
+
+ # Transform should produce same results
+ original_vec = tfidf.transform('Dogs are great')
+ loaded_vec = loaded.transform('Dogs are great')
+
+ assert_equal original_vec, loaded_vec
+ end
+
+ def test_from_json_hash
+ tfidf = Classifier::TFIDF.new
+ tfidf.fit(@corpus)
+
+ hash = JSON.parse(tfidf.to_json)
+ loaded = Classifier::TFIDF.from_json(hash)
+
+ assert_predicate loaded, :fitted?
+ assert_equal tfidf.vocabulary.size, loaded.vocabulary.size
+ end
+
+ def test_from_json_invalid_type_raises
+ invalid_json = { version: 1, type: 'invalid' }.to_json
+
+ assert_raises(ArgumentError) { Classifier::TFIDF.from_json(invalid_json) }
+ end
+
+ # Marshal tests
+
+ def test_marshal_dump_load
+ tfidf = Classifier::TFIDF.new(min_df: 2, sublinear_tf: true)
+ tfidf.fit(@corpus)
+
+ dumped = Marshal.dump(tfidf)
+ loaded = Marshal.load(dumped)
+
+ assert_predicate loaded, :fitted?
+ assert_equal tfidf.vocabulary, loaded.vocabulary
+ assert_equal tfidf.idf, loaded.idf
+
+ # Transform should produce same results
+ original_vec = tfidf.transform('Dogs are great')
+ loaded_vec = loaded.transform('Dogs are great')
+
+ assert_equal original_vec, loaded_vec
+ end
+
+ # Edge cases
+
+ def test_single_document_corpus
+ tfidf = Classifier::TFIDF.new
+
+ tfidf.fit(['Single document with words'])
+
+ assert_predicate tfidf, :fitted?
+ refute_empty tfidf.vocabulary
+ end
+
+ def test_document_with_only_stopwords
+ tfidf = Classifier::TFIDF.new
+ tfidf.fit(['the and or but', 'dog cat bird'])
+
+ # Transform a document with only stopwords
+ vector = tfidf.transform('the and or but')
+
+ assert_empty vector
+ end
+
+ def test_repeated_fit_overwrites
+ tfidf = Classifier::TFIDF.new
+
+ tfidf.fit(['apple banana'])
+ first_vocab = tfidf.vocabulary.dup
+
+ tfidf.fit(['cherry date elderberry'])
+
+ refute_equal first_vocab, tfidf.vocabulary
+ end
+
+ def test_unicode_text
+ tfidf = Classifier::TFIDF.new
+
+ tfidf.fit(['Caf manger boire', 'chteau jardin maison'])
+ vector = tfidf.transform('Caf jardin')
+
+ refute_empty vector
+ end
+
+ def test_very_long_document
+ long_doc = (['word'] * 1000).join(' ')
+ tfidf = Classifier::TFIDF.new
+
+ tfidf.fit([long_doc, 'short document'])
+ vector = tfidf.transform(long_doc)
+
+ refute_empty vector
+ # Should still be normalized
+ magnitude = Math.sqrt(vector.values.sum { |v| v * v })
+ assert_in_delta 1.0, magnitude, 0.0001 unless vector.empty?
+ end
+
+ def test_empty_document_in_corpus
+ # Empty strings should not cause issues
+ tfidf = Classifier::TFIDF.new
+
+ tfidf.fit(['dog cat', '', 'bird fish'])
+
+ assert_predicate tfidf, :fitted?
+ assert_equal 3, tfidf.num_documents
+ end
+end