spica 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -1
  3. data/README.md +159 -78
  4. data/lib/spica/version.rb +1 -1
  5. metadata +7 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a579cb27fdbf3fc9b7fb3424f3f27b4a5eeac40c40ef41138859d8045c6391b
4
- data.tar.gz: ca38a0b1d70c243fc92e5afd392fd91da6ac2683e4207586e2c0ea1c75e0c067
3
+ metadata.gz: 455f7fc3f7147d6d59a3e1a640930d8599f078b8819aa465224a27022480e5e8
4
+ data.tar.gz: 1dfa01ddc8f1a5afda5eb554091e18a9096f630df332b50dc9962009cdfb2337
5
5
  SHA512:
6
- metadata.gz: a7ca15fc297224b006f3d07c76e29da2e5904bff478c4382e1025b83c5de93a317e19e6ba8e85b216b3f6ea9137063a7d7e4a9336cb7d24aa9e303a6c8bed4a7
7
- data.tar.gz: 2fac9201df2b43d04e0f60c5ad6644ed3c01e4302983eb6cbc5d1c032a76e0148254f821c6c02c1fd8620b961b67041ef47d0fe5d6d245078ec87dc8f70556b7
6
+ metadata.gz: 2eba4b4ccf8b945eef7a2456607af4deb934944ea8bb33d802e6d306b799089cbb2d9f209b0a722a30cd0f9d5ace8d05af134d058b6c22b93a7f645e3c5bc0b9
7
+ data.tar.gz: 29ec465710dd68cdd5e50f2e71b3a634cac39a446929e98a046d96db2308aa91dff52f4bc5fc8f9d550df9e81c2d0f3e76977f3438f03df2bb94a9b629fde5af
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0Unreleased
3
+ ## 0.1.12026-09-21
4
+
5
+ - Add a deterministic fuzzy-matching demo image and regeneration task.
6
+
7
+ ## 0.1.0 — 2026-09-11
4
8
 
5
9
  - Initial release.
data/README.md CHANGED
@@ -1,132 +1,213 @@
1
- # Spica
1
+ <h1 align="center">Spica</h1>
2
2
 
3
- A pure Ruby fuzzy subsequence matcher with scores, highlight positions and incremental filtering for command palettes and quick-open lists.
3
+ <p align="center">
4
+ <strong>Pure Ruby fuzzy subsequence matching with scoring, highlights, and incremental filtering</strong>
5
+ </p>
4
6
 
5
- Edit-distance similarity is not enough for a palette: `amu` should find `app/models/user.rb`, rank its alignment, and tell the UI which characters to highlight. Spica combines that contract with reusable candidate preprocessing and query history. It requires Ruby 3.1+ and has no runtime gems or native extensions.
7
+ <p align="center">
8
+ <a href="https://rubygems.org/gems/spica"><img src="https://img.shields.io/gem/v/spica.svg?colorB=319e8c" alt="Gem version"></a>
9
+ <a href="https://rubygems.org/gems/spica"><img src="https://img.shields.io/gem/dt/spica.svg" alt="Gem downloads"></a>
10
+ <a href="https://github.com/noxdea/spica/actions/workflows/main.yml"><img src="https://github.com/noxdea/spica/actions/workflows/main.yml/badge.svg" alt="CI"></a>
11
+ <img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D.svg" alt="Ruby 3.1 or newer">
12
+ <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License"></a>
13
+ </p>
6
14
 
7
- ## Five-line example
15
+ <p align="center">
16
+ <a href="#features">Features</a> ·
17
+ <a href="#installation">Installation</a> ·
18
+ <a href="#quick-start">Quick Start</a> ·
19
+ <a href="#api">API</a> ·
20
+ <a href="#configuration">Configuration</a> ·
21
+ <a href="#performance">Performance</a>
22
+ </p>
23
+
24
+ ---
25
+
26
+ Spica is a fuzzy matcher for command palettes and quick-open lists. It ranks subsequence matches, returns the character positions to highlight, and reuses previous results as a query grows. It has no runtime dependencies or native extensions.
27
+
28
+ ![Spica fuzzy matching](docs/media/screenshot.png)
29
+
30
+ ## Features
31
+
32
+ - fzy-style scoring with optimal alignments inside configurable length limits
33
+ - Highlight positions as Unicode character offsets
34
+ - Incremental filtering, prefix history, and cached backspace results
35
+ - Deterministic ranking with configurable tie-breaking and path bonuses
36
+ - Smart-case, case-sensitive, and case-insensitive matching
37
+ - Mutable indexes with immutable candidate records and results
38
+ - RBS type signatures
39
+
40
+ ## Installation
41
+
42
+ Add Spica to your Gemfile:
8
43
 
9
44
  ```ruby
10
- require "spica"
11
- index = Spica::Index.new(["app/models/user.rb", "app/models/order.rb", "README.md"])
12
- session = index.session
13
- session.query = "amu"
14
- p session.matches(50).map { |match| [match.candidate, match.score, match.positions] }
45
+ gem "spica"
15
46
  ```
16
47
 
17
- ## Installation
48
+ Then run:
49
+
50
+ ```sh
51
+ bundle install
52
+ ```
53
+
54
+ Or install it directly:
18
55
 
19
56
  ```sh
20
57
  gem install spica
21
58
  ```
22
59
 
23
- ## Stateless or incremental
60
+ Spica requires Ruby 3.1 or newer. A C compiler is only used by an optional development oracle.
61
+
62
+ ## Quick start
63
+
64
+ ```ruby
65
+ require "spica"
66
+
67
+ candidates = ["app/models/user.rb", "app/models/order.rb", "README.md"]
68
+
69
+ Spica.filter("amu", candidates, limit: 3).each do |match|
70
+ p [match.candidate, match.positions]
71
+ end
72
+
73
+ # ["app/models/user.rb", [0, 4, 11]]
74
+ ```
75
+
76
+ ## API
77
+
78
+ ### One-shot matching
24
79
 
25
80
  ```ruby
26
- Spica.score("amf", "app/models/foo.rb") # Float; -Float::INFINITY if absent
81
+ Spica.score("amf", "app/models/foo.rb")
82
+ # => a Float, or -Float::INFINITY when there is no match
83
+
27
84
  match = Spica.match("amu", "app/models/user.rb")
28
- match.positions # [0, 4, 11]
29
- match.score # optimal weighted alignment score
30
- Spica.match("xyz", "README.md") # nil
31
- Spica.filter("srcm", candidates, limit: 50) # Array<Match>
85
+ match.candidate # => "app/models/user.rb"
86
+ match.score # => weighted alignment score
87
+ match.positions # => [0, 4, 11]
88
+
89
+ Spica.match("xyz", "README.md")
90
+ # => nil
32
91
  ```
33
92
 
34
- Positions are Unicode **character offsets**, not bytes or grapheme-cluster indices. Results, their strings/positions, and returned top arrays are immutable. `Match#text` aliases `candidate`; `index` is the stable registration ID.
93
+ `Spica.filter` ranks a collection in one call. For repeated queries over the same candidates, use an index and session instead.
94
+
95
+ ### Incremental filtering
35
96
 
36
97
  ```ruby
37
98
  index = Spica::Index.new(paths)
38
99
  session = index.session
39
- session.query = "c"; session.matches(50)
40
- session.query = "co"; session.matches(50) # only previous matches are rescored
41
- session.query = "con"; session.matches(50)
42
- session.query = "co"; session.matches(50) # cached match set and results
43
-
44
- index.add(["new/file.rb"])
45
- index.remove(["deleted/file.rb"])
46
- session.matches(50) # index generation invalidates old history
47
- session.total_matches # all matches, not only the first 50
100
+
101
+ session.query = "c"
102
+ session.matches(50)
103
+
104
+ session.query = "co"
105
+ session.matches(50) # rescans only the previous matches
106
+
107
+ session.query = "c"
108
+ session.matches(50) # reuses the cached result
109
+
110
+ index.add("new/file.rb")
111
+ index.remove("deleted/file.rb")
112
+ session.matches(50) # index changes invalidate the old history
113
+
114
+ session.total_matches # total before the display limit
48
115
  ```
49
116
 
50
- Indices deduplicate identical text; removing and re-adding a path gives it a new registration ID. Original input strings can be edited without changing indexed records. A session retains the active prefix chain; unrelated pasted queries restart from the full index. Limiting the displayed top list never discards candidates needed for the next query.
117
+ Build one index per candidate collection and one session per independent palette. Serialize index mutations, and do not share a session between concurrent callers.
51
118
 
52
- A larger `matches(limit)` recomputes partial selection; a repeated or smaller limit reuses cached results. Build one Index per candidate collection and one Session per independent palette. Candidate records and Options are frozen, while Index mutations and Session operations are explicitly stateful: serialize mutations, and do not share a Session between concurrent callers.
119
+ Indexes deduplicate identical text. Removing and re-adding a candidate assigns it a new registration ID. Input strings are copied when needed, and returned matches, positions, and result arrays are frozen.
53
120
 
54
- ## Scoring and options
121
+ `Match#text` aliases `candidate`; `Match#index` is the stable registration ID. A session keeps the active prefix chain, while an unrelated query restarts from the full index. Display limits never discard candidates needed by the next query.
55
122
 
56
- The score is an optimal fzy-style subsequence alignment for inputs within the configured ceilings. A sparse D/M recurrence visits matching positions, carrying the best intervening-gap score instead of materializing every matrix cell. Score-only scans reuse two rows; highlight backtracking runs only for selected results. One-/two-character queries have equivalent scalar recurrences, and a contiguous match can stop early only when it reaches a proven global score upper bound.
123
+ ## Configuration
57
124
 
58
- The subsequence precheck also scores an alignment directly when every remaining query character has exactly one occurrence. Ambiguous alignments still use the full recurrence. Membership masks and DP scratch rows are prepared only when needed, avoiding unused preprocessing in stateless calls.
125
+ Pass settings directly or reuse an immutable `Spica::Options` instance:
59
126
 
60
127
  ```ruby
61
128
  options = Spica::Options.new(
62
- case_sensitivity: :smart, # :smart, :insensitive, :sensitive
129
+ case_sensitivity: :smart,
63
130
  path_mode: true,
64
131
  limit: 100,
65
132
  tie_break: :shorter
66
133
  )
134
+
67
135
  index = Spica::Index.new(paths, options:)
68
136
  Spica.match("cf", "core/File.rb", options:)
69
137
  ```
70
138
 
71
- Smart case is case-insensitive unless the query contains an uppercase character. Default ties are resolved by score descending, candidate character length ascending, then registration ID ascending. `tie_break: :index` skips the length criterion. Exact matches score positive infinity; an empty query matches every candidate with score zero.
72
-
73
- Default weights preserve the upstream public fzy ranking cases:
74
-
75
- | Option | Default |
76
- | --- | ---: |
77
- | `consecutive` | 1.0 |
78
- | `slash` (also start of candidate / Windows separator) | 0.9 |
79
- | `boundary` (after dash, underscore or space) | 0.8 |
80
- | `camel` | 0.7 |
81
- | `dot` | 0.6 |
82
- | `leading_gap` / `trailing_gap` | -0.005 |
83
- | `inner_gap` | -0.01 |
84
- | `case_bonus` (exact-case micro-bonus, opt-in) | 0.0 |
85
- | `basename_bonus` (used when `path_mode: true`) | 0.2 |
139
+ | Option | Default | Description |
140
+ | --- | --- | --- |
141
+ | `case_sensitivity` | `:smart` | `:smart`, `:sensitive`, or `:insensitive` |
142
+ | `path_mode` | `false` | Adds `basename_bonus` to nonconsecutive basename matches |
143
+ | `limit` | `100` | Default number of session results |
144
+ | `tie_break` | `:shorter` | Prefer shorter candidates, or use `:index` for registration order |
145
+ | `max_length` | `1024` | Longest candidate guaranteed to use optimal alignment |
146
+ | `max_query` | `256` | Longest query guaranteed to use optimal alignment |
147
+
148
+ The scoring weights are also configurable:
149
+
150
+ | Option | Default | Description |
151
+ | --- | ---: | --- |
152
+ | `consecutive` | 1.0 | Consecutive-character bonus |
153
+ | `slash` | 0.9 | Bonus at the candidate start and after a path separator |
154
+ | `boundary` | 0.8 | Bonus after a dash, underscore, or space |
155
+ | `camel` | 0.7 | Lowercase-to-uppercase boundary bonus |
156
+ | `dot` | 0.6 | Bonus after a dot |
157
+ | `leading_gap` | -0.005 | Gap penalty before the match |
158
+ | `inner_gap` | -0.01 | Gap penalty inside the match |
159
+ | `trailing_gap` | -0.005 | Gap penalty after the match |
160
+ | `case_bonus` | 0.0 | Exact-case bonus |
161
+ | `basename_bonus` | 0.2 | Path-mode basename bonus |
162
+
163
+ All weights must be finite real numbers. Default weights preserve the public fzy ranking cases; exact-case and basename bonuses are opt-in.
164
+
165
+ ### Matching behavior
166
+
167
+ - Smart case is insensitive unless the query contains an uppercase character.
168
+ - Default ties use score descending, candidate character length ascending, then registration ID ascending.
169
+ - Exact matches score positive infinity; an empty query matches every candidate with score zero.
170
+ - Positions are Unicode character offsets, not byte or grapheme-cluster indices.
171
+ - Unicode text preserves positions through expanding lowercase mappings, but Spica does not normalize text or perform full Unicode case folding. For example, `ss` does not match `ß`.
172
+ - Candidates longer than `max_length` or queries longer than `max_query` use bounded-memory greedy alignment when an exact fast path is unavailable. They remain valid subsequence matches, but their score and positions may not be optimal.
173
+ - Spica does not provide edit distance, token rearrangement, transliteration, or phonetic matching.
174
+ - Invalid text and options raise `ArgumentError`.
175
+
176
+ ## Performance
177
+
178
+ Measured on Ruby 4.0.0 with YJIT on arm64 macOS. Values are medians of five warmed runs. The representative corpus contains 100,000 paths; the first query retains 10,000 candidates, and the second scans those 10,000.
86
179
 
87
- Path mode adds a configurable bonus to nonconsecutive matches within the basename. Its default is off for fzy-compatible ranking. All weights are configurable finite real numbers; millipoint-exact weights use integer internal arithmetic so mathematically equal scores do not flicker from floating-point accumulation order.
88
-
89
- The index precomputes folded ASCII strings, a 128-bit membership mask plus Unicode membership, boundary codes and basename offsets. Non-ASCII text keeps one lowercase mapping per original character. This preserves highlight positions through expanding lowercase mappings, but deliberately does not perform full Unicode case folding, normalization or grapheme matching: `ss` is not a substitute for `ß`, and composed/decomposed text is not silently normalized.
180
+ | Workload | Measured | Goal |
181
+ | --- | ---: | ---: |
182
+ | Build a 100,000-candidate index | 87.83ms | <400ms |
183
+ | First key, 100,000 → 10,000 matches | 4.93ms | <40ms |
184
+ | Second key `co`, 10,000 candidates | 2.39ms | <5ms |
185
+ | Noncontiguous `cm`, 10,000 candidates | 3.13ms | — |
186
+ | Longer `component_123` query | 2.82ms | — |
187
+ | Cached backspace | 0.002ms | <1ms |
188
+ | Stateless `score("amf", "app/models/foo.rb")` | 2.18µs | <3µs |
189
+ | First key matching all 100,000 candidates | 15.23ms | — |
190
+ | Second key still matching all 100,000 candidates | 11.54ms | — |
90
191
 
91
- Candidates longer than `max_length: 1024` or queries longer than `max_query: 256` use bounded-memory greedy alignment when an exact fast path is unavailable. They remain valid subsequence matches, but their score/positions are not promised optimal. No edit distance, token rearrangement, transliteration or phonetic matching is performed. Invalid text/options raise `ArgumentError`.
192
+ Performance depends on the candidate distribution and hardware; these are not worst-case guarantees. Retained candidate records measured 34.33MiB, excluding the index hash and input array. This exceeds the original roughly 10MiB design estimate and is not a passed memory target. Run `BUDGET=1 bundle exec rake bench` to reproduce the benchmark and its timing gates.
92
193
 
93
- ## Verification
194
+ ## Development
94
195
 
95
196
  ```sh
96
197
  bundle install
97
198
  bundle exec rake
98
199
  bundle exec rake test:oracle
99
200
  BUDGET=1 bundle exec rake bench
100
- rbs -I sig validate
101
- yard doc
102
201
  ```
103
202
 
104
- Tests include 2,000 Unicode position properties, 3,000 comparisons against an independent dense recurrence with varied weights, randomized heap/top and session consistency, encoding/case behavior, deterministic ties, cache invalidation and malformed options.
105
-
106
- The optional native oracle compiles the pinned, unmodified [upstream fzy scorer](test/vendor/fzy/README.md) in a temporary directory. It checks **all eight public ranking assertions** plus 500 seeded ASCII score comparisons. A compiler is only needed for this development oracle; the library works with `ruby --disable-gems`. `FZY_REQUIRED=1` makes a missing compiler fail instead of skip. Linux CI requires the oracle; macOS/Windows run it when a compiler is available. Isolation checks build/install the gem into a temporary GEM_HOME and emit results without development/application dependencies.
107
-
108
- ## Measured performance
109
-
110
- Ruby 4.0.0 + YJIT, arm64 macOS; median of five warmed runs. The representative corpus has 100,000 paths, of which the first query `c` retains 10,000; the next query scans those 10,000. Timings include `query=` and `matches(50)`.
111
-
112
- | Workload | Measured | Goal |
113
- | --- | ---: | ---: |
114
- | Build 100,000-candidate index | 87.83ms | <400ms |
115
- | First key, 100,000 → 10,000 matches | 4.93ms | <40ms |
116
- | Second key `co`, 10,000 candidates | 2.39ms | <5ms |
117
- | Noncontiguous `cm`, 10,000 candidates | 3.13ms | — |
118
- | Longer `component_123` query | 2.82ms | — |
119
- | Cached backspace | 0.002ms | <1ms |
120
- | Stateless `score("amf", "app/models/foo.rb")` | 2.18µs | <3µs |
121
- | Adversarial first key matching all 100,000 | 15.23ms | — |
122
- | Adversarial second key still matching all 100,000 | 11.54ms | — |
203
+ The test suite covers Unicode positions, randomized session and ranking behavior, deterministic ties, cache invalidation, malformed options, and comparisons with an independent dense recurrence. The native oracle uses the pinned [upstream fzy scorer](test/vendor/fzy/README.md); it is optional without a compiler and required in Linux CI.
123
204
 
124
- The 5ms second-key target applies to the specified **10,000 remaining candidates**, not 100,000 matches. The benchmark retains and reports the all-hit stress case separately. Performance depends on candidate/query distribution and hardware; these measurements are not worst-case guarantees.
205
+ ## Contributing
125
206
 
126
- A subsequent stateless-score CI regression check compared `4af16c3` with the unique-alignment/lazy-preparation fix on Ruby 4.0.6 + YJIT, Linux arm64, Bundler 4.0.19. Three alternating `BUDGET=1 bundle exec rake bench` pairs (each reporting five warmed runs) reduced the median stateless call from 2.62µs to 1.20µs; all gates passed in all three corrected runs. The same-call allocation count fell from 15 to 6 objects. The original failing GitHub x86_64 runner measured 5.17µs; these local measurements are not a rerun on that hardware. Neither the 3µs limit nor the benchmark workload was changed.
207
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/noxdea/spica).
127
208
 
128
- Retained candidate records measured 34.33MiB, about 360 bytes per candidate, excluding the Index hash and input array. This exceeds the design estimate of roughly 100 bytes per candidate / 10MB for 100,000; it is a known Ruby object-overhead tradeoff, not a passed memory target. `bench/search.rb` reports both timing gates and retained-size measurements.
209
+ ## License
129
210
 
130
- ## Name and license
211
+ Spica is released under the [MIT License](LICENSE.txt). The test-only fzy sources retain their [upstream MIT license](test/vendor/fzy/LICENSE).
131
212
 
132
- A spica separates grain from chaff; this library separates useful palette matches from a large candidate list. MIT, see [LICENSE.txt](LICENSE.txt). Test-only fzy sources retain their [upstream MIT license](test/vendor/fzy/LICENSE).
213
+ The name Spica comes from the part of a wheat ear that separates grain from chaff—much like this library separates useful matches from a large candidate list.
data/lib/spica/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Spica
4
4
  # Library semantic version.
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.1"
6
6
  end
metadata CHANGED
@@ -1,14 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spica
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-21 00:00:00.000000000 Z
11
12
  dependencies: []
13
+ description:
12
14
  email:
13
15
  - t.yudai92@gmail.com
14
16
  executables: []
@@ -32,6 +34,7 @@ metadata:
32
34
  changelog_uri: https://github.com/noxdea/spica/blob/main/CHANGELOG.md
33
35
  allowed_push_host: https://rubygems.org
34
36
  rubygems_mfa_required: 'true'
37
+ post_install_message:
35
38
  rdoc_options: []
36
39
  require_paths:
37
40
  - lib
@@ -46,7 +49,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
46
49
  - !ruby/object:Gem::Version
47
50
  version: '0'
48
51
  requirements: []
49
- rubygems_version: 4.0.19
52
+ rubygems_version: 3.3.27
53
+ signing_key:
50
54
  specification_version: 4
51
55
  summary: Deterministic fuzzy subsequence matching with incremental filtering
52
56
  test_files: []