truffler 0.1.2 → 0.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99d7f61714bf653e012b5cea1f9d57cfbde9d390aae048ba0d52387292a37159
4
- data.tar.gz: b9826342d2552dae2b1cd9d671ac4d4d448f5fcbe3822b21e5788b7d272365b0
3
+ metadata.gz: 3be74707295fff06a13ad0b12bbfb2d876f851893f0fff5f3f49b14c368bdb9b
4
+ data.tar.gz: 59a2e7d7e57669e8ffa35eadf656cc4e52fbae6cc47798f9ae4d01a43f870109
5
5
  SHA512:
6
- metadata.gz: 414ddacdd2e195a077792a36b3c1e8050a8e086a99ccb7ffed065776826f6af2c83da47835ce36d663db166b664e2559a01f2f92dcc6c493e120bbcd60ae89f8
7
- data.tar.gz: 272d2d762fdcd5e0f6a6d0cec08ca332619a14e6ab8b3b94a5f2670e16091bf0c75395866a5a77ede42e7d22483a95252cbf6b0cee3565cacfb91d2ae5c17569
6
+ metadata.gz: e71fd94ea16d17b9ca2e2f219591b51dfc94ab82f749bd21d5d277389039e6fa30f3c29130dd07c2b8aecd588abe0e1d91e8c6e70b66d199b485a77a90cc0a59
7
+ data.tar.gz: b3ea709b2d26f377b3c7731e7cc020d5249780bfbc756ad1f01dde3506a542f80b34bc7e8846387527d9311cbbe0015c826ea90916fdd251fdaa596efef123c6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.4]
4
+
5
+ Fixes from happyhappy production.
6
+
7
+ - Generic filler nouns are no longer required keywords. "customers in the last 3 hours" returned nothing while "in the last 3 hours" returned the records. New `config.filler_words` (default: customer, customers, people, person, user, users, message, messages, email, emails, item, items, stuff, thing, things; replace or extend it, matched ignoring plurals). One rule now serves both the encoder's word reconciliation and the cold-cache keywords: stopwords and filler words are dropped unless that would leave the search with no keyword, no applied label, and no time phrase, so a lone "customers" still searches text and "customers refund this week" requires only "refund".
8
+ - `rake truffler:status` with no model prints the status of every registered Truffler model instead of " is not a Truffler model". An unknown model name still aborts with a message.
9
+ - A choice option may be `{ description:, search: }`: the long description stays what Jev labels with, and the short search text drives query-word matching and the request state's `option_names`. Plain descriptions and per-tenant callables keep working, and callables may return the new shape. Labeling fingerprints now cover only what Jev labels with: asked labels are unchanged (search texts are not part of the question), and a supplied label's fingerprint digests its type, option keys, legend, and `version:` but no longer its descriptions. Descriptions and search texts go into a separate encoding version that keys the query-encoding cache, so rewording re-encodes queries without staling labels.
10
+ - Removing a chip gives its words back to the keywords. The cached encoding records which applied labels each label-term word named (by token position, no query text); once every one of them is removed, the word is a keyword again, so "urgent refunds" without the urgent chip matches both words. A word that named a label or option key only by shared prefix adds a soft keyword score (a quarter of the keyword weight) and is never required.
11
+ - Upgrading from 0.1.3: supplied (`from:`) labels get a new fingerprint, so their stored rows and record states read stale once. `rake truffler:backfill` (or the next watched change) rewrites them at no Jev cost; the new vocabulary version also starts a fresh backfill spend ledger. Query encodings cached by 0.1.3 miss once and are re-encoded.
12
+ - Removing the chip that justified dropping a filler word (the time range, or the last applied label) brings the filler noun back as a keyword, cached or cold. When nothing anchors a search and only droppable words are left, filler nouns stay keywords and only pure stopwords are dropped ("the customers" searches "customers").
13
+
14
+ ## [0.1.3]
15
+
16
+ - Display-name and description words of an applied choice option now match query words exactly (ignoring case and plurals) instead of by shared three-letter prefix. Under a filter, ordinary search words such as "email", "inbox" and "summary" (with Cora applied) or "chat" and "change" (with billing applied) no longer become label terms and keep ranking results. Label keys and option keys still match by shared prefix, so "angry" names `anger`.
17
+
3
18
  ## [0.1.2]
4
19
 
5
20
  Fixes from happyhappy's production `churn_risk` backfill and its 0.1.1 upgrade.
data/README.md CHANGED
@@ -92,6 +92,7 @@ Here is what each option does:
92
92
 
93
93
  - `filter_at` makes a label a hard filter at that probability when the query asks for it. `boost` is the label's weight when the query prefers it. `filter_weight:` (default 0) is how much intent weight a filter adds on top.
94
94
  - Choice `options:` may be a callable of the tenant key, which gives each tenant its own vocabulary. A tenant it gives no options (`{}` or nil) simply lacks the label: it is neither asked nor encoded there. The option name `truffler:none` is reserved (see `Truffler::NO_OPTION` below).
95
+ - An option's value may be `{ description: "...", search: "..." }` instead of a plain description, e.g. `options: { "prod-a1" => { description: "Cora, the AI email assistant that drafts replies...", search: "Cora email assistant" } }`. Jev labels records with the description; query words are matched against the short search text (else the description), and the request state's `option_names` carries it. A per-tenant callable may return the same shape. Editing a search text never stales labels or triggers a backfill; it only changes the query-encoding cache key, so queries are re-encoded.
95
96
  - `watch :column, ...` relabels every label when one of those columns changes. Saving a record only relabels on columns that changed, so a `reads` field backed by a method (a conversation built from messages, say) needs the columns it is built from in `watch`.
96
97
  - `keyword` also accepts a single callable, `->(scope, tokens) { relation }`.
97
98
  - `embeddings column: :my_vector` searches a vector column you maintain yourself. Truffler never writes it.
@@ -103,7 +104,7 @@ Here is what each option does:
103
104
  |---|---|---|
104
105
  | `question:` | asked labels (required); optional with `from:` | What Jev is asked about each record. |
105
106
  | `criteria:` | `:noul` | `{ true => "...", false => "..." }` guidance for Jev. |
106
- | `options:` | `:choice` (required) | Option names, `{ option => description }`, or a callable of the tenant key. |
107
+ | `options:` | `:choice` (required) | Option names, `{ option => description }`, `{ option => { description:, search: } }`, or a callable of the tenant key returning any of these. |
107
108
  | `legend:` | `:score` (required) | Two or more ordered levels, as an array or `{ index => description }`. |
108
109
  | `filter_at:`, `boost:`, `filter_weight:` | all | Filter threshold, boost weight, and the intent weight a filter adds. |
109
110
  | `description:` | all | The label's wording in query encoding. Defaults to the question, then to the key. |
@@ -158,7 +159,7 @@ To keep them fresh:
158
159
  - Call `record.truffler_refresh_labels!` when the answers change somewhere truffler cannot see, such as the job that runs your classifier after insert. It rewrites the record's supplied labels immediately, with no Jev call.
159
160
  - Bump `version:` when the logic behind `from:` changes. That changes the vocabulary version, so `rake truffler:backfill` rewrites the label for every record, again at no cost.
160
161
 
161
- A supplied label's fingerprint digests its type, options, legend, description, and `version:`, never the Jev model, so changing `config.model` does not rewrite supplied labels.
162
+ A supplied label's fingerprint digests its type, option keys, legend, and `version:`, never the Jev model or its wording, so changing `config.model`, `description:`, or an option's description or search text does not rewrite supplied labels. Wording changes only re-key the query-encoding cache.
162
163
 
163
164
  ## Clients
164
165
 
@@ -183,7 +184,9 @@ result = Email.truffler(params[:q], tenant: Current.account.id, scope: Current.a
183
184
 
184
185
  A keystroke search makes no network call. It reads the query encoding and query vector from the cache. When the cache misses, it enqueues `EncodeQueryJob`, and the next keystroke or reload picks up the result.
185
186
 
186
- Query encoding sends Jev the label vocabulary (each label's description and a choice label's option names) next to the query, and asks for each label whether the query filters on it, prefers it, or ignores it, plus the role of each word. A choice label's option question also offers `Truffler::NO_OPTION` (`"truffler:none"`), meaning the query names none of its options, so a host option literally called `none` stays filterable. Word roles are then checked locally: a word that names a label the query applies (its key, a word of its key, the chosen option, or a word of that option's display name or description, ignoring case and plurals, or a word of four letters or more sharing its first three letters, so "angry" names `anger`) counts as naming the label, and common stopwords are dropped. When the encoding applies a label filter, the filter decides which records match and keyword hits only rank them; without one, the remaining keywords must match.
187
+ Query encoding sends Jev the label vocabulary (each label's description and a choice label's option names) next to the query, and asks for each label whether the query filters on it, prefers it, or ignores it, plus the role of each word. A choice label's option question also offers `Truffler::NO_OPTION` (`"truffler:none"`), meaning the query names none of its options, so a host option literally called `none` stays filterable. Word roles are then checked locally: a word that names a label the query applies (its key, a word of its key, or the chosen option key, ignoring case and plurals or sharing their first three letters when both words have four letters or more, so "angry" names `anger`; or, matched exactly, a word of that option's display name or description) counts as naming the label, and common stopwords and `config.filler_words` (generic nouns such as "customers", "users", "emails") are dropped. They are kept only when dropping them would leave no keyword, no applied label, and no time phrase, so "customers in the last 3 hours" lists the window's records while a lone "customers" still searches text; the same rule applies before the encoding is cached. When the encoding applies a label filter, the filter decides which records match and keyword hits only rank them; without one, the remaining keywords must match.
188
+
189
+ When the searcher removes a chip, the words that named only that label (or only labels that are now all removed) become keywords again, so removing the `urgent` chip from "urgent refunds" searches for both words. A word that named a label or option key only by shared prefix ("urgently" for `urgent`) also adds a small keyword score (a quarter of the keyword weight) while its label applies, ranking records whose text contains it higher without requiring it.
187
190
 
188
191
  Time phrases are handled in Ruby and never asked of Jev: `today`, `yesterday`, `this week`, `last week`, `this month`, `last month`, `past|last N hours|days|weeks|months`, `last hour`, `past hour`, `past week`, `past month`, and `since monday` through `since sunday`. "Past/last N units" is a rolling window ending now; "this/last week" and "this/last month" are calendar windows. The first one in a query limits results to records whose `arrived_at` column falls in that window, its words are not keywords, and it shows as a chip `{key: "time", label: "time", kind: :time, name: "This week"}`. Pass `"time"` in `suppressed:` to drop it. Weeks start on `Date.beginning_of_week`, and the window is computed from `Time.current` (or a `clock:` callable passed to the search, for tests).
189
192
 
@@ -343,6 +346,7 @@ Set these in `Truffler.configure do |config| ... end`.
343
346
  | `embedder` | `Embeddings::RubyLLMEmbedder.new` | Any `Embeddings::Embedder` subclass. The default calls `RubyLLM.embed`, which works on ruby_llm 1.x and 2. |
344
347
  | `embedding_cost_per_million_tokens` | 0.02 | Embedding price. |
345
348
  | `vector_store` | `:auto` | `:neighbor` (pgvector `<=>`, or sqlite-vec `vec_distance_cosine` when you load the extension), `:ruby` (exact cosine in Ruby), or `:auto` (neighbor when available, otherwise Ruby). A model declaring `embeddings column:` always reads its own column. |
349
+ | `filler_words` | `customer(s) people person user(s) message(s) email(s) item(s) stuff thing(s)` | Generic nouns never required as keywords on their own (matched ignoring plurals). Replace the list or extend it (`config.filler_words += %w[ticket]`). |
346
350
  | `encoding_prefetch` | `QueryEncoding::Prefetch.new` | Cache-miss hook, called as `call(model, query, cache_key:, tenant_key:, user_key:)`. |
347
351
  | `encoding_deadline` | 1.0 | Seconds a Smart run waits for an in-flight query encoding. |
348
352
  | `rerank_depth`, `rerank_chunk_size`, `rerank_max_field_chars` | 30, 10, 1,200 | Candidates reranked, candidates per Jev request, characters per field sent. |
@@ -367,7 +371,7 @@ Truffler enqueues most of its own jobs. Run a worker for `config.queue_name` and
367
371
  | `bin/rails "truffler:backfill[Email]"` (see Backfill) or `Truffler::Jobs::BackfillJob.perform_later("Email")` | After adopting Truffler, changing a declaration, or changing the model pin. |
368
372
  | `Truffler::Embeddings::Backfill.new(Email).enqueue` | After enabling embeddings or changing the embedding model, width, or fields, to re-embed everything now instead of through the `ResumeJob` sweep. It enqueues 1,000 jobs at a time; pass `limit:` to cap the total. |
369
373
 
370
- `bin/rails "truffler:status[Email]"` prints labeling counts and the backfill spend for the current vocabulary version. `bin/rails "truffler:suggestions[Email]"` prints candidate questions drawn from logged query misses.
374
+ `bin/rails "truffler:status[Email]"` prints labeling counts and the backfill spend for the current vocabulary version; `bin/rails truffler:status` prints them for every registered Truffler model. `bin/rails "truffler:suggestions[Email]"` prints candidate questions drawn from logged query misses.
371
375
 
372
376
  With Solid Queue, for example:
373
377
 
@@ -0,0 +1 @@
1
+ {"answers":{"c001__relevance":{"noul":0.0,"type":"noul"},"c002__relevance":{"noul":0.0,"type":"noul"},"c003__relevance":{"noul":0.0,"type":"noul"},"c004__relevance":{"noul":0.0,"type":"noul"},"c005__relevance":{"noul":0.0,"type":"noul"}},"model":"synthetic-jev","request_hash":"1037e0b63ba790c02d14d0ddfc18b68151c11d03ce88d764658044b6bab2e55b","usage":{"input_tokens":873}}
@@ -0,0 +1 @@
1
+ {"answers":{"c001__relevance":{"noul":0.0,"type":"noul"},"c002__relevance":{"noul":0.0,"type":"noul"},"c003__relevance":{"noul":0.0,"type":"noul"},"c004__relevance":{"noul":0.0,"type":"noul"},"c005__relevance":{"noul":0.0,"type":"noul"},"c006__relevance":{"noul":0.0,"type":"noul"},"c007__relevance":{"noul":0.0,"type":"noul"}},"model":"synthetic-jev","request_hash":"19bba849a846d30a69dc385a337d67704fbe5194506fd3c914d3270d86495fde","usage":{"input_tokens":1164}}
@@ -0,0 +1 @@
1
+ {"answers":{"c001__relevance":{"noul":0.0,"type":"noul"},"c002__relevance":{"noul":0.0,"type":"noul"}},"model":"synthetic-jev","request_hash":"3768847dc57df8bf2328771e7763c0b8bd013a167244e7849967dac144420601","usage":{"input_tokens":417}}
@@ -47,9 +47,7 @@ namespace :truffler do
47
47
  puts summary
48
48
  end
49
49
 
50
- desc "Print a model's labeling counts by status and staleness"
51
- task :status, [ :model ] => :setup do |_, args|
52
- model = resolve_model.call(args[:model])
50
+ print_status = lambda do |model|
53
51
  puts model.name
54
52
  Truffler::Labeling::Backfill.status(model).each { |key, count| puts format(" %-9s %d", key, count) }
55
53
  if Truffler::Records::BackfillSpend.available?
@@ -61,4 +59,16 @@ namespace :truffler do
61
59
  puts format(" %-9s %s", "spent", "not tracked across runs; run bin/rails g truffler:upgrade && bin/rails db:migrate")
62
60
  end
63
61
  end
62
+
63
+ desc "Print a model's labeling counts by status and staleness (every registered Truffler model when none is named)"
64
+ task :status, [ :model ] => :setup do |_, args|
65
+ if args[:model].to_s.strip.empty?
66
+ Rails.application.eager_load! if defined?(Rails.application) && Rails.application
67
+ models = Truffler.registry.models.select { |model| model.try(:truffler_definition) }.sort_by(&:name)
68
+ abort "No Truffler models are registered" if models.empty?
69
+ models.each(&print_status)
70
+ else
71
+ print_status.call(resolve_model.call(args[:model]))
72
+ end
73
+ end
64
74
  end
@@ -17,6 +17,8 @@ module Truffler
17
17
  attr_reader :lenses
18
18
  attr_accessor :encoding_deadline, :rerank_depth, :rerank_chunk_size, :rerank_max_field_chars, :smart_thresholds,
19
19
  :smart_run_ttl, :smart_candidate_pool, :broadcaster
20
+ # Generic nouns that are never required keywords on their own (Search::Filler).
21
+ attr_accessor :filler_words
20
22
 
21
23
  def initialize(env: ENV)
22
24
  @model = "jev-latest"
@@ -50,6 +52,7 @@ module Truffler
50
52
  @smart_run_ttl = 15.minutes
51
53
  @smart_candidate_pool = 200
52
54
  @broadcaster = nil
55
+ @filler_words = Search::Filler::DEFAULT_WORDS.dup
53
56
  end
54
57
 
55
58
  def client
@@ -3,7 +3,9 @@ module Truffler
3
3
  # normalized position, and choices one row per option ("label:option") with
4
4
  # that option's probability. Choice options may be a callable of the tenant
5
5
  # key, which makes the vocabulary per-tenant; a tenant it gives no options
6
- # (`{}` or nil) simply does not have the label.
6
+ # (`{}` or nil) simply does not have the label. An option's value is its
7
+ # description, or `{ description:, search: }` to give query encoding a
8
+ # short search text apart from the classifier description.
7
9
  #
8
10
  # A label with `from:` is supplied by the host: its answer is read from the
9
11
  # record in the shape Jev answers normalize to and Jev is never asked. Its
@@ -13,6 +15,7 @@ module Truffler
13
15
  class LabelDefinition
14
16
  TYPES = %i[noul choice score].freeze
15
17
  KEY = /\A[a-z][a-z0-9_]*\z/
18
+ OPTION_FIELDS = %i[description search].freeze
16
19
 
17
20
  attr_reader :key, :type, :instructions, :filter_at, :boost, :watch, :version
18
21
  # Intent weight a filter decision adds to the KTD20 query vector (default 0).
@@ -49,13 +52,23 @@ module Truffler
49
52
  @options.respond_to?(:call)
50
53
  end
51
54
 
55
+ # {option => description}: the classifier wording Jev labels with. An
56
+ # option given as `{ description:, search: }` contributes its description.
52
57
  def options(tenant_key = nil)
53
- options = per_tenant? ? @options.call(tenant_key) : @options
54
- options = Array(options).to_h { |option| [ option, nil ] } unless options.is_a?(Hash)
55
- options = options.to_h { |option, description| [ option.to_s, description ] }
56
- raise DefinitionError, "#{key}: the option name #{NO_OPTION} is reserved" if options.key?(NO_OPTION)
58
+ option_entries(tenant_key).transform_values { |entry| entry[:description] }
59
+ end
57
60
 
58
- options
61
+ # {option => name} for query words: the option's short search text, else
62
+ # its description; options with neither are left out.
63
+ def option_names(tenant_key = nil)
64
+ option_entries(tenant_key).transform_values { |entry| entry[:search].presence || entry[:description] }.compact
65
+ end
66
+
67
+ # What query encoding reads beyond the labeling fingerprint: descriptions
68
+ # and search texts. Digested into the encoding cache key only, so
69
+ # rewording never stales stored labels.
70
+ def encoding_wording(tenant_key = nil)
71
+ { description: description, options: (option_entries(tenant_key) if type == :choice) }
59
72
  end
60
73
 
61
74
  # False for a per-tenant choice with no options for this tenant.
@@ -75,11 +88,11 @@ module Truffler
75
88
  end
76
89
 
77
90
  # Stored supplied values change with their shape and version, never with
78
- # the Jev model. The description and option wording are digested too
79
- # because query encoding asks about them.
91
+ # the Jev model or the wording: descriptions and search texts reach only
92
+ # query encoding (see encoding_wording).
80
93
  def supplied_fingerprint(tenant_key = nil)
81
- Canonical.digest(supplied: true, type: type, options: (options(tenant_key) if type == :choice),
82
- levels: (levels if type == :score), description: description, version: version)
94
+ Canonical.digest(supplied: true, type: type, options: (options(tenant_key).keys if type == :choice),
95
+ levels: (levels if type == :score), version: version)
83
96
  end
84
97
 
85
98
  # {storage_key => value} read from the record, or nil when the host has
@@ -106,6 +119,25 @@ module Truffler
106
119
 
107
120
  private
108
121
 
122
+ def option_entries(tenant_key)
123
+ options = per_tenant? ? @options.call(tenant_key) : @options
124
+ options = Array(options).to_h { |option| [ option, nil ] } unless options.is_a?(Hash)
125
+ options = options.to_h { |option, value| [ option.to_s, option_entry(option, value) ] }
126
+ raise DefinitionError, "#{key}: the option name #{NO_OPTION} is reserved" if options.key?(NO_OPTION)
127
+
128
+ options
129
+ end
130
+
131
+ def option_entry(option, value)
132
+ return { description: value, search: nil } unless value.is_a?(Hash)
133
+
134
+ entry = value.to_h.symbolize_keys
135
+ unknown = entry.keys - OPTION_FIELDS
136
+ raise DefinitionError, "#{key}: option #{option} takes description: and search:, not #{unknown.join(', ')}" if unknown.any?
137
+
138
+ { description: entry[:description], search: entry[:search] }
139
+ end
140
+
109
141
  def levels
110
142
  Array(@legend).size
111
143
  end
@@ -29,6 +29,13 @@ module Truffler
29
29
  @question["criteria"].to_h.transform_keys(&:to_s)
30
30
  end
31
31
 
32
+ def option_names(tenant_key = nil)
33
+ options(tenant_key).compact
34
+ end
35
+
36
+ # The lens question, wording included, is already in its fingerprint.
37
+ def encoding_wording(_tenant_key = nil) = nil
38
+
32
39
  def storage_keys(tenant_key = nil)
33
40
  type == :choice ? options(tenant_key).keys.map { |option| "#{key}:#{option}" } : [ key ]
34
41
  end
@@ -16,7 +16,7 @@ module Truffler
16
16
  # label the query applies (its key, a word of its key, the chosen option,
17
17
  # or a word of that option's display name, ignoring case and plurals, or
18
18
  # sharing its first three letters) becomes a label term, and a common
19
- # stopword becomes filler.
19
+ # stopword or `config.filler_words` noun becomes filler.
20
20
  #
21
21
  # Answers become a `Search::Encoding` with the KTD20 intent vector: boost
22
22
  # gives the declared boost, filter narrows and adds `filter_weight`
@@ -35,10 +35,7 @@ module Truffler
35
35
  "filler" => "A word that carries no meaning for the search"
36
36
  }.freeze
37
37
  NO_OPTION = Truffler::NO_OPTION
38
- STOPWORDS = %w[
39
- a about all an and any are at be by for from have i in is it me my now of on or our please so some that the their
40
- them there they this to up us was we what when where which who why with you your
41
- ].to_set.freeze
38
+ STOPWORDS = Search::Filler::STOPWORDS
42
39
 
43
40
  Request = Data.define(:state, :questions, :token_ids, :exact_tokens, :unasked_tokens)
44
41
 
@@ -120,7 +117,7 @@ module Truffler
120
117
  filters = {}
121
118
  boosts = {}
122
119
  intent = {}
123
- terms = []
120
+ names = {}
124
121
  labels(model, tenant_key, user_key).each_value do |label|
125
122
  key = storage_key(label, answers, tenant_key)
126
123
  next unless key
@@ -133,15 +130,17 @@ module Truffler
133
130
  boosts[key] = intent[key] = label.boost || DEFAULT_BOOST
134
131
  else next
135
132
  end
136
- terms.concat(label_terms(key, option_name(label, key, tenant_key)))
133
+ names[key] = [ label_terms(key), name_terms(option_name(label, key, tenant_key)) ]
137
134
  end
138
135
 
139
136
  query = Search::Query.new(request.state["query"])
140
- roles = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, terms.uniq)
141
- keyword_tokens = query.tokens.each_index.filter_map { |position| query.tokens[position] if roles[position] == "keyword" }
142
- label_term_tokens = query.tokens.each_index.filter_map { |position| query.tokens[position] if roles[position] == "label_term" }
143
- Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent, keyword_tokens: keyword_tokens,
144
- label_term_tokens: label_term_tokens)
137
+ roles, sources, soft = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, names)
138
+ tokens = ->(positions) { positions.map { |position| query.tokens[position] } }
139
+ Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent,
140
+ keyword_tokens: tokens.call(roles.keys.select { |position| roles[position] == "keyword" }),
141
+ label_term_tokens: tokens.call(sources.keys), soft_keyword_tokens: tokens.call(soft).uniq,
142
+ filler_tokens: tokens.call(roles.keys.select { |position| roles[position] == "filler" }).uniq,
143
+ label_term_sources: sources.group_by { |position, _| query.tokens[position] }.transform_values { |pairs| pairs.flat_map(&:last).uniq })
145
144
  end
146
145
 
147
146
  private
@@ -192,57 +191,80 @@ module Truffler
192
191
  labels.transform_values do |label|
193
192
  entry = { "description" => label.description }
194
193
  if label.type == :choice
195
- options = label.options(tenant_key)
196
- entry["options"] = options.keys
197
- names = options.compact
194
+ entry["options"] = label.options(tenant_key).keys
195
+ names = label.option_names(tenant_key)
198
196
  entry["option_names"] = names if names.any?
199
197
  end
200
198
  entry
201
199
  end
202
200
  end
203
201
 
204
- # {position => role} for every token. Time phrase words are "time";
205
- # exact tokens and unasked words are keywords; a keyword naming an
206
- # applied label becomes a label term; stopwords become filler unless
207
- # they are all that would be left of an encoding that applies nothing.
208
- def reconcile(query, answered, terms)
202
+ # Returns `[roles, sources, soft]`: {position => role} for every token,
203
+ # {label-term position => applied storage keys it names}, and the
204
+ # label-term positions that named a key only by prefix. Time phrase
205
+ # words are "time"; exact tokens and unasked words are keywords; a
206
+ # keyword naming an applied label becomes a label term; stopwords and
207
+ # filler words become filler unless they are all that would be left of
208
+ # an encoding that applies no label and no time range (Search::Filler).
209
+ # A word Jev called a label term that names no applied label locally
210
+ # is sourced to every applied label.
211
+ def reconcile(query, answered, names)
209
212
  roles = query.tokens.each_index.to_h do |position|
210
213
  [ position, query.time_position?(position) ? "time" : answered.fetch(position, "keyword") ]
211
214
  end
215
+ matches = roles.keys.to_h { |position| [ position, roles[position] == "time" ? {} : label_matches(query.tokens[position], names) ] }
212
216
  words = roles.keys.select { |position| roles[position] == "keyword" && !query.exact_tokens.include?(query.tokens[position]) }
213
- words.each { |position| roles[position] = "label_term" if names_label?(query.tokens[position], terms) }
214
- stopwords = words.select { |position| roles[position] == "keyword" && STOPWORDS.include?(query.tokens[position]) }
215
- return roles if terms.empty? && roles.values.count("keyword") == stopwords.size
216
-
217
- stopwords.each { |position| roles[position] = "filler" }
218
- roles
217
+ words.each { |position| roles[position] = "label_term" if matches[position].any? }
218
+ filler = words.select { |position| roles[position] == "keyword" && Search::Filler.word?(query.tokens[position]) }
219
+ Search::Filler.drop(filler, keyword_count: roles.values.count("keyword"), anchored: names.any? || !query.time_phrase.nil?,
220
+ stopword: ->(position) { Search::Filler.stopword?(query.tokens[position]) })
221
+ .each { |position| roles[position] = "filler" }
222
+ label_terms = roles.keys.select { |position| roles[position] == "label_term" }
223
+ sources = label_terms.to_h { |position| [ position, matches[position].keys.presence || names.keys ] }
224
+ soft = (words & label_terms).select { |position| matches[position].values.all?(:prefix) }
225
+ [ roles, sources, soft ]
219
226
  end
220
227
 
221
228
  # "category:billing" names "category" and "billing"; "needs_action"
222
- # names "needs_action", "needs", and "action". An option's display
223
- # name adds its words minus stopwords: "p_17" shown as "Spiral writing
224
- # tool" also names "spiral", "writing", and "tool".
229
+ # names "needs_action", "needs", and "action". These key terms also
230
+ # match by shared stem (see label_matches).
225
231
  STEM = 3
226
232
 
227
- def label_terms(storage_key, option_name = nil)
233
+ def label_terms(storage_key)
228
234
  label, option = Search::Encoding.split_key(storage_key)
229
- keys = [ label.split(":").last, option ].compact.flat_map { |name| [ name.downcase, *name.downcase.split(/[^\p{Alnum}]+/) ] }
230
- words = option_name.to_s.downcase.split(/[^\p{Alnum}]+/).reject { |word| STOPWORDS.include?(word) }
231
- (keys + words).reject(&:empty?).map(&:singularize)
235
+ [ label.split(":").last, option ].compact.flat_map { |name| [ name.downcase, *name.downcase.split(/[^\p{Alnum}]+/) ] }
236
+ .reject(&:empty?).map(&:singularize)
237
+ end
238
+
239
+ # An option's display name (its search text, else its description) adds
240
+ # its words minus stopwords: "p_17" shown as "Spiral writing tool" names
241
+ # "spiral", "writing", and "tool". These
242
+ # match exactly only: descriptions are prose, and a stem match on them
243
+ # would swallow ordinary search words ("chat" against "charge").
244
+ def name_terms(option_name)
245
+ option_name.to_s.downcase.split(/[^\p{Alnum}]+/).reject { |word| word.empty? || STOPWORDS.include?(word) }.map(&:singularize)
232
246
  end
233
247
 
234
248
  def option_name(label, storage_key, tenant_key)
235
249
  return unless label.type == :choice
236
250
 
237
- label.options(tenant_key)[Search::Encoding.split_key(storage_key).last]
251
+ label.option_names(tenant_key)[Search::Encoding.split_key(storage_key).last]
238
252
  end
239
253
 
240
- # "angry" names "anger": the same word ignoring plurals, or two words of
241
- # four letters or more that share their first three letters. Only the
242
- # applied labels' terms are compared, so the loose match stays safe.
243
- def names_label?(word, terms)
254
+ # {storage key => :exact or :prefix} for the applied labels `word`
255
+ # names. "angry" names "anger": the same word ignoring plurals is
256
+ # :exact; against a label or option key, two words of four letters or
257
+ # more that share their first three letters are :prefix. Display-name
258
+ # words match exactly only.
259
+ def label_matches(word, names)
244
260
  word = word.singularize
245
- terms.any? { |term| term == word || (word.length > STEM && term.length > STEM && word[0, STEM] == term[0, STEM]) }
261
+ names.each_with_object({}) do |(key, (stems, terms)), matches|
262
+ if terms.include?(word) || stems.include?(word)
263
+ matches[key] = :exact
264
+ elsif word.length > STEM && stems.any? { |term| term.length > STEM && word[0, STEM] == term[0, STEM] }
265
+ matches[key] = :prefix
266
+ end
267
+ end
246
268
  end
247
269
 
248
270
  def intent_instructions(label)
@@ -10,11 +10,20 @@ module Truffler
10
10
  # for the label term SUM(weight * value). Defaults to `boosts`; the
11
11
  # encoder decides whether filtered labels also carry weight here.
12
12
  # - `keyword_tokens`: query tokens for the keyword source; nil means
13
- # every token that is not a label term.
13
+ # every token that is not a label term or filler.
14
14
  # - `label_term_tokens`: tokens that named a label rather than a keyword.
15
+ # - `label_term_sources`: label-term token => the applied storage keys it
16
+ # named. Once the searcher removes every one of them, the token is a
17
+ # keyword again.
18
+ # - `soft_keyword_tokens`: label terms that named a label or option key
19
+ # only by shared prefix ("urgently" for urgent). They add a small
20
+ # keyword score and are never required.
21
+ # - `filler_tokens`: stopwords and filler words the encoder dropped.
22
+ # Removing the chips that justified the drop brings filler nouns back.
15
23
  # - `time`: the query's `TimeRange`, resolved at search time and never
16
24
  # cached, since "today" moves.
17
- Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens, :time) do
25
+ Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens, :label_term_sources,
26
+ :soft_keyword_tokens, :filler_tokens, :time) do
18
27
  def self.load(value, query)
19
28
  return value if value.is_a?(self)
20
29
  return if value.nil?
@@ -22,15 +31,22 @@ module Truffler
22
31
  tokens = query.tokens
23
32
  new(filters: value["filters"], boosts: value["boosts"], intent_vector: value["intent_vector"],
24
33
  keyword_tokens: value["keyword_positions"]&.map { |position| tokens[position] }&.compact,
25
- label_term_tokens: Array(value["label_term_positions"]).filter_map { |position| tokens[position] })
34
+ label_term_tokens: Array(value["label_term_positions"]).filter_map { |position| tokens[position] },
35
+ label_term_sources: Array(value["label_term_sources"]).filter_map { |position, keys| [ tokens[position], keys ] if tokens[position] },
36
+ soft_keyword_tokens: Array(value["soft_keyword_positions"]).filter_map { |position| tokens[position] },
37
+ filler_tokens: Array(value["filler_positions"]).filter_map { |position| tokens[position] })
26
38
  end
27
39
 
28
- def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [], time: nil)
40
+ def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [], label_term_sources: {},
41
+ soft_keyword_tokens: [], filler_tokens: [], time: nil)
29
42
  filters = weights(filters)
30
43
  boosts = weights(boosts)
31
44
  intent_vector = weights(intent_vector || boosts).reject { |_, weight| weight.zero? }
45
+ sources = label_term_sources.to_h { |token, keys| [ token.to_s, Array(keys).map(&:to_s).freeze ] }
32
46
  super(filters: filters.freeze, boosts: boosts.freeze, intent_vector: intent_vector.freeze,
33
- keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze, time: time)
47
+ keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze,
48
+ label_term_sources: sources.freeze, soft_keyword_tokens: Array(soft_keyword_tokens).map(&:to_s).freeze,
49
+ filler_tokens: Array(filler_tokens).map(&:to_s).freeze, time: time)
34
50
  end
35
51
 
36
52
  def empty?
@@ -38,14 +54,22 @@ module Truffler
38
54
  end
39
55
 
40
56
  # The encoding minus the chips the searcher removed (R20), matched by
41
- # storage key or by label key; "time" removes the time range.
57
+ # storage key or by label key; "time" removes the time range. A label
58
+ # term whose every source label is gone becomes a keyword again.
42
59
  def without(suppressed)
43
60
  suppressed = Array(suppressed).map(&:to_s).to_set
44
61
  return self if suppressed.empty?
45
62
 
46
63
  keep = ->(key, _) { !suppressed.include?(key) && !suppressed.include?(self.class.split_key(key).first) }
47
- with(filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep),
48
- time: (time unless suppressed.include?(TimeRange.key)))
64
+ kept = { filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep) }
65
+ applied = kept.values.flat_map(&:keys).to_set
66
+ freed = label_term_sources.select { |_, keys| keys.any? && keys.none? { |key| applied.include?(key) } }.keys
67
+ kept_time = (time unless suppressed.include?(TimeRange.key))
68
+ keywords = keyword_tokens && (keyword_tokens + freed).uniq
69
+ keywords = Filler.keywords(keywords + filler_tokens, anchored: false) if keywords && applied.empty? && kept_time.nil?
70
+ with(**kept, time: kept_time, label_term_tokens: label_term_tokens - freed,
71
+ label_term_sources: label_term_sources.except(*freed), soft_keyword_tokens: soft_keyword_tokens - freed,
72
+ keyword_tokens: keywords)
49
73
  end
50
74
 
51
75
  # Splits a storage key into its label key and choice option. Lens keys
@@ -60,8 +84,11 @@ module Truffler
60
84
  end
61
85
  end
62
86
 
87
+ # Without encoder decisions (a cold cache), every search token that is
88
+ # not a label term, minus filler words (see Filler).
63
89
  def keywords(query)
64
- keyword_tokens || (query.search_tokens - label_term_tokens)
90
+ keyword_tokens ||
91
+ Filler.keywords(query.search_tokens - label_term_tokens, anchored: !empty? || !time.nil?, exact: query.exact_tokens)
65
92
  end
66
93
 
67
94
  # The cache form: decisions plus token positions in the normalized
@@ -70,7 +97,12 @@ module Truffler
70
97
  def dump(query)
71
98
  { "filters" => filters, "boosts" => boosts, "intent_vector" => intent_vector,
72
99
  "keyword_positions" => keyword_tokens && positions(query, keyword_tokens),
73
- "label_term_positions" => positions(query, label_term_tokens) }
100
+ "label_term_positions" => positions(query, label_term_tokens),
101
+ "label_term_sources" => query.tokens.each_index.filter_map do |position|
102
+ [ position, label_term_sources[query.tokens[position]] ] if label_term_sources.key?(query.tokens[position])
103
+ end,
104
+ "soft_keyword_positions" => positions(query, soft_keyword_tokens),
105
+ "filler_positions" => positions(query, filler_tokens) }
74
106
  end
75
107
 
76
108
  private
@@ -1,8 +1,9 @@
1
1
  module Truffler
2
2
  module Search
3
3
  # Where keystroke search finds query encodings and query vectors (R12,
4
- # R15). Keys digest the model, the normalized query, and the vocabulary
5
- # version, plus the tenant when the vocabulary has per-tenant choices.
4
+ # R15). Keys digest the model, the normalized query, and the vocabulary's
5
+ # encoding version (labeling fingerprints plus descriptions and option
6
+ # search texts), plus the tenant when the vocabulary has per-tenant choices.
6
7
  # Values hold decisions and floats, never query text. Encoding keys use
7
8
  # the searcher's vocabulary version, which holds the lenses that searcher
8
9
  # can see (KTD21), so a personal lens's encoding is never shared; query
@@ -60,7 +61,7 @@ module Truffler
60
61
  def digest(model, query, tenant_key, user_key)
61
62
  definition = model.truffler_definition
62
63
  tenant = tenant_key&.to_s if definition.per_tenant_vocabulary?
63
- version = definition.vocabulary.version(tenant_key: tenant_key&.to_s, user_key: user_key)
64
+ version = definition.vocabulary.encoding_version(tenant_key: tenant_key&.to_s, user_key: user_key)
64
65
  Canonical.digest(record_type: model.polymorphic_name, query: query.normalized, vocabulary_version: version, tenant_key: tenant)
65
66
  end
66
67
  end
@@ -0,0 +1,48 @@
1
+ module Truffler
2
+ module Search
3
+ # Words that carry no search meaning on their own: common stopwords and
4
+ # `config.filler_words` (generic nouns such as "customers" or "emails",
5
+ # matched ignoring plurals). One rule serves the encoder's reconcile and
6
+ # the cold-cache keywords: filler is dropped as a keyword unless dropping
7
+ # it would leave the search with no keyword, no applied label, and no
8
+ # time range, so a lone "customers" still searches text.
9
+ module Filler
10
+ STOPWORDS = %w[
11
+ a about all an and any are at be by for from have i in is it me my now of on or our please so some that the their
12
+ them there they this to up us was we what when where which who why with you your
13
+ ].to_set.freeze
14
+
15
+ DEFAULT_WORDS = %w[customer customers people person user users message messages email emails item items stuff thing things].freeze
16
+
17
+ module_function
18
+
19
+ def stopword?(word)
20
+ STOPWORDS.include?(word.to_s.downcase)
21
+ end
22
+
23
+ def word?(word, filler_words: Truffler.config.filler_words)
24
+ word = word.to_s.downcase
25
+ stopword?(word) || Array(filler_words).any? { |filler| filler.to_s.downcase.singularize == word.singularize }
26
+ end
27
+
28
+ # The subset of `candidates` (droppable keywords) to drop, given how many
29
+ # keywords there are and whether a label or time range is applied. When
30
+ # nothing anchors the search and only droppable words are left, the
31
+ # filler nouns stay as keywords and only pure stopwords go ("customers
32
+ # in the" searches "customers"); if every word is a stopword, all stay.
33
+ def drop(candidates, keyword_count:, anchored:, stopword: ->(candidate) { stopword?(candidate) })
34
+ return candidates if anchored || candidates.size < keyword_count
35
+
36
+ stopwords = candidates.select(&stopword)
37
+ stopwords.size == candidates.size ? [] : stopwords
38
+ end
39
+
40
+ # `tokens` minus filler words, or only minus stopwords when nothing else
41
+ # would anchor the search. Exact tokens (a quoted "the") are never filler.
42
+ def keywords(tokens, anchored:, exact: [])
43
+ dropped = drop(tokens.select { |token| !exact.include?(token) && word?(token) }, keyword_count: tokens.size, anchored: anchored)
44
+ tokens - dropped
45
+ end
46
+ end
47
+ end
48
+ end
@@ -5,12 +5,15 @@ module Truffler
5
5
  # w_label * SUM(weight * value) over the intent's nonzero label keys
6
6
  # + w_text * text similarity (inline SQL, or the store's top-K CASE)
7
7
  # + w_keyword * keyword hit + w_exact * exact-source hit
8
+ # + SOFT_KEYWORD * w_keyword * soft keyword hit
8
9
  #
9
10
  # A weighted dot product, not cosine: cosine would divide out magnitude
10
11
  # and let a record high on unrelated labels outrank the one the query
11
12
  # asked for. Hard filters are EXISTS subqueries that run before scoring.
12
13
  class Sql
13
14
  LABELS = "truffler_labels".freeze
15
+ # Share of the keyword weight a soft keyword hit adds (see Encoding).
16
+ SOFT_KEYWORD = 0.25
14
17
 
15
18
  attr_reader :model, :tenant_key, :query, :encoding, :vector, :weights
16
19
 
@@ -130,20 +133,28 @@ module Truffler
130
133
  def keyword_sql
131
134
  return @keyword_sql if defined?(@keyword_sql)
132
135
 
133
- @keyword_sql = keywords.empty? ? nil : keyword_condition(definition.keyword)
136
+ @keyword_sql = keywords.empty? ? nil : keyword_condition(definition.keyword, keywords)
134
137
  end
135
138
 
136
- def keyword_condition(source)
139
+ # Any soft keyword hit; soft keywords rank and never narrow.
140
+ def soft_keyword_sql
141
+ return @soft_keyword_sql if defined?(@soft_keyword_sql)
142
+
143
+ conditions = (encoding.soft_keyword_tokens - keywords).uniq.filter_map { |token| keyword_condition(definition.keyword, [ token ]) }
144
+ @soft_keyword_sql = conditions.empty? ? nil : conditions.map { |condition| "(#{condition})" }.join(" OR ")
145
+ end
146
+
147
+ def keyword_condition(source, tokens)
137
148
  case source
138
149
  when Array
139
150
  return if source.empty?
140
151
 
141
- keywords.map do |token|
152
+ tokens.map do |token|
142
153
  pattern = quote("%#{model.sanitize_sql_like(token)}%")
143
154
  "(#{source.map { |name| "LOWER(#{column(name)}) LIKE #{pattern} ESCAPE '\\'" }.join(' OR ')})"
144
155
  end.join(" AND ")
145
156
  when nil then nil
146
- else membership_sql(source.call(tenant_scope, keywords))
157
+ else membership_sql(source.call(tenant_scope, tokens))
147
158
  end
148
159
  end
149
160
 
@@ -189,12 +200,19 @@ module Truffler
189
200
  terms = {
190
201
  truffler_label_score: (label_score_sql && "#{Float(weights[:label])} * #{label_score_sql}"),
191
202
  truffler_text_score: (text_score_sql && "#{Float(weights[:text])} * #{text_score_sql}"),
192
- truffler_keyword_score: (keyword_sql && "#{Float(weights[:keyword])} * (CASE WHEN #{keyword_sql} THEN 1.0 ELSE 0.0 END)"),
203
+ truffler_keyword_score: keyword_score_sql,
193
204
  truffler_exact_score: (exact_sql && "#{Float(weights[:exact])} * (CASE WHEN #{exact_sql} THEN 1.0 ELSE 0.0 END)")
194
205
  }.compact
195
206
  { truffler_score: terms.any? ? terms.values.map { |term| "(#{term})" }.join(" + ") : "0.0", **terms }
196
207
  end
197
208
 
209
+ def keyword_score_sql
210
+ weight = Float(weights[:keyword])
211
+ terms = [ (keyword_sql && "#{weight} * (CASE WHEN #{keyword_sql} THEN 1.0 ELSE 0.0 END)"),
212
+ (soft_keyword_sql && "#{weight * SOFT_KEYWORD} * (CASE WHEN #{soft_keyword_sql} THEN 1.0 ELSE 0.0 END)") ].compact
213
+ terms.map { |term| "(#{term})" }.join(" + ") if terms.any?
214
+ end
215
+
198
216
  def ordering
199
217
  order_column, direction = definition.order
200
218
  [ Arel.sql("truffler_score DESC"), (Arel.sql("#{column(order_column)} #{direction == :asc ? 'ASC' : 'DESC'}") if order_column),
@@ -1,3 +1,3 @@
1
1
  module Truffler
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.4"
3
3
  end
@@ -1,9 +1,9 @@
1
1
  module Truffler
2
2
  # A label's fingerprint is the SHA-256 of its canonical question plus the
3
3
  # pinned Jev model; a host-supplied label's is its supplied fingerprint
4
- # (type, options, version), which no Jev model change touches. The
5
- # vocabulary version digests every fingerprint. A stored label is stale
6
- # when its fingerprint differs from the current one.
4
+ # (type, option keys, version), which no Jev model change or rewording
5
+ # touches. The vocabulary version digests every fingerprint. A stored
6
+ # label is stale when its fingerprint differs from the current one.
7
7
  #
8
8
  # A scope's vocabulary also holds its active lens labels (KTD21), so a lens
9
9
  # changes the version only where it applies. user_key is the searcher key
@@ -39,6 +39,16 @@ module Truffler
39
39
  Canonical.digest(fingerprints(tenant_key: tenant_key, user_key: user_key, all_users: all_users))
40
40
  end
41
41
 
42
+ # The version query encodings are cached under: the labeling version plus
43
+ # a digest of the wording only query encoding reads (label descriptions,
44
+ # option descriptions and search texts), which labeling never sees for
45
+ # supplied labels and never sees for search texts.
46
+ def encoding_version(tenant_key: nil, user_key: nil)
47
+ labels = labels_for(tenant_key: tenant_key, user_key: user_key)
48
+ Canonical.digest(version: Canonical.digest(labels.transform_values { |label| fingerprint_of(label, tenant_key) }),
49
+ wording: labels.transform_values { |label| label.encoding_wording(tenant_key) }.compact)
50
+ end
51
+
42
52
  private
43
53
 
44
54
  def fingerprint_of(label, tenant_key)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: truffler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kieran Klaassen
@@ -102,12 +102,14 @@ files:
102
102
  - bench/cassettes/0858c5006c0bfac3e970ddaff3c139f6c110dbe3d8539fd95263706c637cdc8e.json
103
103
  - bench/cassettes/0c0eb7c27d76bd0b9263690a63cd92d6b68c305826318555fd5b084a09970b78.json
104
104
  - bench/cassettes/0fada0c0a103882e2895f7f455d73d2cbe2b4a81bf743bb19b2b4f6e0130126b.json
105
+ - bench/cassettes/1037e0b63ba790c02d14d0ddfc18b68151c11d03ce88d764658044b6bab2e55b.json
105
106
  - bench/cassettes/1117d79ac7c43b21beaf75e510461f91cd4ecc3cdbbe461f1794644af4aaf8f3.json
106
107
  - bench/cassettes/11b7444a074cca4d0ef1535db0cf0edefe4c34eac4be3c593c9c2f33d3b038b8.json
107
108
  - bench/cassettes/11d00371d1b993675be00463159164536f1f8ed90a48652b0935eb9c36f8b684.json
108
109
  - bench/cassettes/1456ebe12616ce961d68a14eada62778a7271ebd3d1b4cb6eefd6857651c7afc.json
109
110
  - bench/cassettes/14c3693f64b0e9ea7e470277fccae6ab501d404185eec5bb9074ef4b75c572c0.json
110
111
  - bench/cassettes/14fe9f6d8055d41f5d970dc32ab640c213ea8782d365c82d32b9884ba4f28c67.json
112
+ - bench/cassettes/19bba849a846d30a69dc385a337d67704fbe5194506fd3c914d3270d86495fde.json
111
113
  - bench/cassettes/1a1d8557e4698426c2467aa573a240357715d3d9e3fa77bf066672a8b6bf9a86.json
112
114
  - bench/cassettes/1b5a8aaaf98305560588246cd51f53c6c185a951f8018c26bf588954d9de1cea.json
113
115
  - bench/cassettes/1fe596be747e33e9c7e4d7c50de32613957ac66bee43679b4bed96c551b1fd0a.json
@@ -116,6 +118,7 @@ files:
116
118
  - bench/cassettes/2f6576325dfab696cec8d8b35fa89c29960bee15d10dab31c91cef507f4842be.json
117
119
  - bench/cassettes/33ec27d22c0f63dbbe93008f50af07fa8667a5c806a9fbfc7897683915412b40.json
118
120
  - bench/cassettes/351f745b0c47e0cf3272b0fea7fc4941b6aa733f013c55a9a19707b542543200.json
121
+ - bench/cassettes/3768847dc57df8bf2328771e7763c0b8bd013a167244e7849967dac144420601.json
119
122
  - bench/cassettes/37d974fdc0c1876413111c244d363c8d2306e3229fec668c158b5c924ffdb983.json
120
123
  - bench/cassettes/39524467463dd612cbf03e314b9cfd8cbe1c5f06969da2289359cf7cf83de5da.json
121
124
  - bench/cassettes/3f2c758c111ab7f1163ba579f2329718afe15620507d356d3ebe2a053df237ea.json
@@ -294,6 +297,7 @@ files:
294
297
  - lib/truffler/registry.rb
295
298
  - lib/truffler/search/encoding.rb
296
299
  - lib/truffler/search/encoding_cache.rb
300
+ - lib/truffler/search/filler.rb
297
301
  - lib/truffler/search/keystroke.rb
298
302
  - lib/truffler/search/query.rb
299
303
  - lib/truffler/search/result.rb