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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -1
- data/README.md +159 -78
- data/lib/spica/version.rb +1 -1
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 455f7fc3f7147d6d59a3e1a640930d8599f078b8819aa465224a27022480e5e8
|
|
4
|
+
data.tar.gz: 1dfa01ddc8f1a5afda5eb554091e18a9096f630df332b50dc9962009cdfb2337
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2eba4b4ccf8b945eef7a2456607af4deb934944ea8bb33d802e6d306b799089cbb2d9f209b0a722a30cd0f9d5ace8d05af134d058b6c22b93a7f645e3c5bc0b9
|
|
7
|
+
data.tar.gz: 29ec465710dd68cdd5e50f2e71b3a634cac39a446929e98a046d96db2308aa91dff52f4bc5fc8f9d550df9e81c2d0f3e76977f3438f03df2bb94a9b629fde5af
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -1,132 +1,213 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">Spica</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>Pure Ruby fuzzy subsequence matching with scoring, highlights, and incremental filtering</strong>
|
|
5
|
+
</p>
|
|
4
6
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
+

|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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.
|
|
29
|
-
match.score
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
session.query = "
|
|
41
|
-
session.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
session.
|
|
47
|
-
session.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
+
## Configuration
|
|
57
124
|
|
|
58
|
-
|
|
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,
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
|
76
|
-
|
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
|
83
|
-
|
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
205
|
+
## Contributing
|
|
125
206
|
|
|
126
|
-
|
|
207
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/noxdea/spica).
|
|
127
208
|
|
|
128
|
-
|
|
209
|
+
## License
|
|
129
210
|
|
|
130
|
-
|
|
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
|
-
|
|
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
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.
|
|
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:
|
|
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:
|
|
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: []
|