Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .claude/skills/audit-docs/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
name: audit-docs
description: Audit README.md and docs/ for accuracy. Runs every documented code example, checks links, anchors, and prose rules, and verifies the CLI examples against the built gem. Use when documentation changes, before a release, or when the user asks to check the docs, verify code samples, or confirm the examples still work.
---

# Audit the documentation

The docs make claims about how this gem behaves. This audit proves each claim or
finds the ones that are false.

A documented example that no longer runs is a bug. Fix the code or fix the docs.
Never weaken an assertion to make a check pass.

## 1. Run the executable examples

`test/docs/documentation_test.rb` holds one assertion per value printed in
README.md and docs/*.md.

```bash
bundle exec ruby -Ilib -Itest test/docs/documentation_test.rb
```

Every failure means the docs and the code disagree. Read the failure, decide
which side is wrong, and correct that side.

## 2. Run the static checks

```bash
ruby .claude/skills/audit-docs/check_docs.rb
```

This covers what a unit test cannot:

- em dashes and en dashes, which this repo's prose rules forbid
- relative markdown links that point at a missing file
- link fragments that point at a missing heading
- documented executables that do not exist

## 3. Run the full suite

```bash
bundle exec rake test
bundle exec rubocop
```

The documentation test is part of `rake test`, so a red suite blocks a release.

## 4. Verify the CLI examples against a real install

The shell examples are not covered by the unit test. Build the gem, install it
into a throwaway `GEM_HOME`, and run the commands as a new user would.

```bash
gem build classifier.gemspec
export GEM_HOME=$(mktemp -d) GEM_PATH=$GEM_HOME PATH=$GEM_HOME/bin:$PATH
gem install classifier-*.gem --no-document
```

Then walk the examples in `docs/cli.md` and `docs/keywords.md` in order, from an
empty directory. Order matters. A reader runs the commands top to bottom, so a
command that needs a model must come after the command that builds one.

Check that:

- the first example a new user meets actually succeeds
- printed output matches what the page shows
- exit codes match the documented table
- an error path prints the documented message

Delete the temporary `GEM_HOME` and the built `.gem` when finished.

## 5. Cross-check new public API

List what the code exposes and confirm the docs cover it:

```bash
grep -rn "^\s*def \(self\.\)\?[a-z_]" lib/classifier/*.rb | grep -v "def _"
```

Anything public and undocumented is a gap. Add it to the right page in `docs/`
and add an assertion to `test/docs/documentation_test.rb`.

## Reporting

Report every finding with the file, the claim, and the observed behavior. Say
plainly which side you changed. If the audit finds nothing, say the docs are
accurate and name what you verified, so the result is checkable.
77 changes: 77 additions & 0 deletions .claude/skills/audit-docs/check_docs.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

# Static checks for the documentation set.
#
# Run from the repository root:
# ruby .claude/skills/audit-docs/check_docs.rb
#
# Checks that need no Ruby runtime behavior live here. The executable examples
# live in test/docs/documentation_test.rb, which `rake test` runs.

DASHES = { '—' => 'em dash', '–' => 'en dash', '‒' => 'figure dash', '―' => 'horizontal bar' }.freeze

def markdown_files
(Dir['*.md'] + Dir['docs/**/*.md']).reject { |f| f == 'CLAUDE.md' }.sort
end

def report(findings)
findings.each { |f| puts " #{f}" }
puts ' none' if findings.empty?
findings.size
end

total = 0

puts 'Dash characters (docs must use plain prose, not em or en dashes):'
total += report(markdown_files.flat_map do |file|
File.readlines(file).each_with_index.filter_map do |line, index|
hit = DASHES.keys.find { |d| line.include?(d) }
"#{file}:#{index + 1}: #{DASHES[hit]} in #{line.strip[0, 70]}" if hit
end
end)

puts
puts 'Internal links (every relative markdown link must resolve):'
total += report(markdown_files.flat_map do |file|
dir = File.dirname(file)
File.read(file).scan(/\[[^\]]*\]\(([^)#][^)]*)\)/).flatten.filter_map do |target|
next if target.start_with?('http://', 'https://', 'mailto:')

path = target.split('#').first
next if path.nil? || path.empty?

resolved = File.expand_path(path, dir)
"#{file}: broken link to #{target}" unless File.exist?(resolved)
end
end)

puts
puts 'Anchors (every relative link with a fragment must hit a real heading):'
total += report(markdown_files.flat_map do |file|
dir = File.dirname(file)
File.read(file).scan(/\[[^\]]*\]\(([^)]*#[^)]+)\)/).flatten.filter_map do |target|
next if target.start_with?('http://', 'https://')

path, fragment = target.split('#', 2)
resolved = path.empty? ? file : File.expand_path(path, dir)
next unless File.exist?(resolved)

slugs = File.read(resolved).scan(/^#+\s+(.+)$/).flatten.map do |heading|
heading.downcase.gsub(/[^\w\s-]/, '').strip.gsub(/\s+/, '-')
end
"#{file}: no heading ##{fragment} in #{path.empty? ? File.basename(file) : path}" unless slugs.include?(fragment)
end
end)

puts
puts 'Documented commands exist:'
total += report(
%w[classifier keywords].filter_map do |exe|
"exe/#{exe} is documented but missing" unless File.exist?("exe/#{exe}")
end
)

puts
puts total.zero? ? 'PASS: no static documentation problems.' : "FAIL: #{total} problem(s)."
exit(total.zero? ? 0 : 1)
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ mkmf.log
# OS files
.DS_Store

# Claude Code local settings
.claude/
# Claude Code local settings, except tracked project skills
.claude/*
!.claude/skills/
sig/generated/
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@
frequent original word in the text.
- Add the `min_df` and `max_df` readers to `Classifier::TFIDF`.
- Accept a `MultiIO` in `TFIDF#fit_from_stream` and `Streaming::LineReader`.
- Fix `Marshal` support in `Classifier::Bayes`. The dump left out
`min_word_length`, so the restored classifier raised
`ArgumentError: comparison of Integer with nil failed` on its first
`classify`. A dump written by an older version still loads, and takes the
configured default.
- Fix `LSI#highest_relative_content`, which returned an Enumerator rather than
the documented array of documents.
- Fix `LSI#highest_ranked_stems`, which repeated one stem when the document
vector held equal weights. It looked up each weight by value, so tied weights
all resolved to the same index.
- Add a `docs/` reference for the command line tools, each classifier,
persistence, streaming, and configuration.

## 2.6.0 - 2026-06-25

Expand Down
84 changes: 53 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

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)**
**[Reference](docs/)** · **[Documentation](https://rubyclassifier.com/docs)** · **[Tutorials](https://rubyclassifier.com/docs/tutorials)** · **[API Reference](https://rubydoc.info/gems/classifier)**

## Why This Library?

Expand All @@ -32,7 +32,7 @@ brew install cardmagic/tap/classifier

## Command Line

Classify text instantly with pre-trained models—no coding required:
Classify text instantly with pre-trained models. No code required:

```bash
# Detect spam
Expand Down Expand Up @@ -63,50 +63,61 @@ classifier "Great product, highly recommend"
# => positive
```

Extract keywords and analyze term importance using TF-IDF instantly:
The `keywords` command scores term importance with TF-IDF. It has no
pre-trained models, so build a vocabulary first. Every later command reads
that model:

```bash
# Extract from a raw string
# Fit from multiple files. Each line becomes a separate document.
keywords fit corpus/*.txt
# => Saved to "/path/to/keywords.json"

# Fit from stdin
cat documents.txt | keywords fit

# Tune the vocabulary filters during the fit
keywords fit --min-df 2 --max-df 0.85 --ngram 1,2 corpus/*.txt
```

Then score any text against that vocabulary:

```bash
# Score a raw string
keywords "Ruby is a programming language"
# => ruby:0.52 programming:0.41 language:0.38
# => language:0.58 programming:0.58 ruby:0.58

# Extract from a file
# Score a file
keywords extract article.txt
# => machine:0.61 learning:0.58 neural:0.45 network:0.42
# => machine:0.58 network:0.47 neural:0.47 learning:0.47

# Pipeline with stdin and web data
curl -s https://example.com/article | keywords extract

# Get top 5 terms only
# Get the top 5 terms only
keywords -n 5 "long document with many terms..."

# Use a custom model file
# Use a different model file
keywords -m custom_model.json "Ruby is a programming language"
```

Build your own vocabulary (fit data):
```bash
# Fit from multiple files
keywords fit corpus/*.txt

# Fit from stdin (each line is treated as a separate document)
cat documents.txt | keywords fit
Inspect the model:

# Tune vocabulary filters during fitting
keywords fit --min-df 2 --max-df 0.85 --ngram 1,2 corpus/*.txt
```

Inspect your model:
```bash
# Check model statistics and parameters
keywords info
# => Documents: 1,234
# => Vocabulary: 5,678
# => Min DF: 1
# => Max DF: 1.0
```

[CLI Guide →](https://rubyclassifier.com/docs/guides/cli/basics)
The output maps stems back to whole words, so a model built from `programming`
prints `programming`, not `program`. An n-gram label joins its parts with a
space, as in `machine learning:0.35`.

Run `keywords --help` for the full option list. A usage error exits 2 and any
other error exits 1, so scripts can tell the two apart.

[keywords reference →](docs/keywords.md) · [CLI Guide →](https://rubyclassifier.com/docs/guides/cli/basics)

### Claude Code Plugin

Expand Down Expand Up @@ -143,6 +154,7 @@ classifier.classify("Cheap pills!") # => "Spam"
classifier = Classifier::LogisticRegression.new(:positive, :negative)
classifier.train(positive: "love amazing great wonderful")
classifier.train(negative: "hate terrible awful bad")
classifier.fit # required before the first classify
classifier.classify("I love it!") # => "Positive"
```
[Logistic Regression Guide →](https://rubyclassifier.com/docs/guides/logisticregression/basics)
Expand Down Expand Up @@ -171,27 +183,37 @@ knn.classify("programming code") # => "tech"
```ruby
tfidf = Classifier::TFIDF.new
tfidf.fit(["Ruby is great", "Python is great", "Ruby on Rails"])
tfidf.transform("Ruby programming") # => {:rubi => 1.0}
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:
Add documents without a rebuild of the whole index. Turn `auto_rebuild` off, add
the starting corpus, then build once:

```ruby
lsi = Classifier::LSI.new(incremental: true)
lsi.add(tech: ["Ruby is elegant", "Python is popular"])
lsi = Classifier::LSI.new(incremental: true, auto_rebuild: false)
lsi.add(tech: [
"Ruby is an elegant programming language for web development",
"Python is a popular programming language for data science",
"JavaScript runs in browsers and powers modern web applications",
"Java is a compiled language used for enterprise backend systems",
"Rust provides memory safety without a garbage collector runtime"
])
lsi.build_index

# These use Brand's algorithm—no full rebuild
lsi.add(tech: "Go is fast")
lsi.add(tech: "Rust is safe")
# This uses Brand's algorithm. No full rebuild.
lsi.add(tech: "Go is a fast compiled language for backend systems")
lsi.incremental_enabled? # => true
```

[Learn more →](https://rubyclassifier.com/docs/guides/lsi/basics)
Incremental mode needs the starting corpus in place before the first build, and
it falls back to a full rebuild when one document grows the vocabulary too far.

[Incremental LSI →](docs/lsi.md#incremental-mode) · [Learn more →](https://rubyclassifier.com/docs/guides/lsi/basics)

### Persistence

Expand Down
2 changes: 1 addition & 1 deletion classifier.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Gem::Specification.new do |s|
}
s.required_ruby_version = '>= 3.1'
s.files = Dir['{lib,sig,exe}/**/*.{rb,rbs}', 'ext/**/*.{c,h,rb}', 'exe/*', 'bin/*', 'LICENSE',
'README.md', 'CHANGELOG.md', 'test/*']
'README.md', 'CHANGELOG.md', 'docs/**/*.md', 'test/*']
s.bindir = 'exe'
s.executables = %w[classifier keywords]
s.extensions = ['ext/classifier/extconf.rb']
Expand Down
Loading
Loading