truffler 0.1.4 → 0.1.5

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +71 -8
  4. data/lib/generators/truffler/install/templates/migration.rb.tt +6 -3
  5. data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +25 -0
  6. data/lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt +32 -0
  7. data/lib/generators/truffler/upgrade/upgrade_generator.rb +49 -3
  8. data/lib/tasks/truffler.rake +28 -13
  9. data/lib/truffler/benchmark/runner.rb +1 -1
  10. data/lib/truffler/clients/evaluator.rb +44 -0
  11. data/lib/truffler/configuration.rb +9 -0
  12. data/lib/truffler/definition.rb +52 -0
  13. data/lib/truffler/embeddings/backfill.rb +40 -11
  14. data/lib/truffler/embeddings/label_vector.rb +1 -1
  15. data/lib/truffler/embeddings/neighbor_store.rb +33 -7
  16. data/lib/truffler/embeddings/vector_store.rb +9 -3
  17. data/lib/truffler/jobs/backfill_job.rb +14 -5
  18. data/lib/truffler/jobs/embed_job.rb +2 -0
  19. data/lib/truffler/jobs/label_flush_job.rb +4 -2
  20. data/lib/truffler/jobs/resume_job.rb +22 -9
  21. data/lib/truffler/label_definition.rb +13 -2
  22. data/lib/truffler/labeling/backfill.rb +100 -37
  23. data/lib/truffler/labeling/labeler.rb +20 -5
  24. data/lib/truffler/labeling/queue.rb +14 -9
  25. data/lib/truffler/labeling/supplied.rb +16 -5
  26. data/lib/truffler/lenses/backfill.rb +28 -10
  27. data/lib/truffler/model.rb +5 -0
  28. data/lib/truffler/providers/backup.rb +1 -1
  29. data/lib/truffler/query_encoding/encoder.rb +10 -6
  30. data/lib/truffler/records/backfill_spend.rb +30 -6
  31. data/lib/truffler/redaction.rb +1 -1
  32. data/lib/truffler/search/encoding.rb +12 -6
  33. data/lib/truffler/search/filler.rb +31 -6
  34. data/lib/truffler/search/keystroke.rb +29 -15
  35. data/lib/truffler/search/result.rb +11 -1
  36. data/lib/truffler/search/sql.rb +70 -10
  37. data/lib/truffler/smart_search/dispatcher.rb +1 -1
  38. data/lib/truffler/smart_search/starter.rb +1 -1
  39. data/lib/truffler/version.rb +1 -1
  40. metadata +4 -1
@@ -23,7 +23,7 @@ module Truffler
23
23
  return Result.new(labeled: 0, requests: 0, cost: 0.0, demoted: false) if states.empty?
24
24
 
25
25
  tenant_key = tenant_key_of(states)
26
- records = load_records(states)
26
+ records = load_records(states, tenant_key)
27
27
  vocabulary = definition.vocabulary
28
28
  @labels = vocabulary.labels_for(tenant_key: tenant_key, all_users: true)
29
29
  fingerprints = vocabulary.fingerprints(tenant_key: tenant_key, all_users: true)
@@ -87,17 +87,31 @@ module Truffler
87
87
  tenants.first
88
88
  end
89
89
 
90
- def load_records(states)
91
- records = model.where(model.primary_key => states.map(&:record_id)).to_a
90
+ # Records deleted or outside index_scope / index_if lose their state
91
+ # rows instead of being labeled. A disabled tenant keeps its state rows:
92
+ # they go back to pending at backfill priority, so re-enabling it
93
+ # relabels only what is stale instead of every record.
94
+ def load_records(states, tenant_key)
95
+ unless definition.tenant_enabled?(tenant_key)
96
+ Records::RecordState.where(id: states.map(&:id))
97
+ .update_all(status: "pending", priority: "backfill", claimed_at: nil, updated_at: Time.current)
98
+ return []
99
+ end
100
+
101
+ records = definition.index_relation(model.where(model.primary_key => states.map(&:record_id))).to_a
102
+ .select { |record| definition.index_if.nil? || definition.index_if.call(record) }
92
103
  found = records.map { |record| record.id.to_s }
93
104
  gone = states.reject { |state| found.include?(state.record_id.to_s) }
94
105
  Records::RecordState.where(id: gone.map(&:id)).delete_all if gone.any?
95
106
  records
96
107
  end
97
108
 
109
+ # A label is current when it has stored rows and all carry its current
110
+ # fingerprint; choice labels store only some options (see sparse_choice).
98
111
  def stale_keys(stored, fingerprints, tenant_key, askable)
99
112
  askable.reject do |label|
100
- label.storage_keys(tenant_key).all? { |key| stored[key] == fingerprints[label.key] }
113
+ present = label.storage_keys(tenant_key).select { |key| stored.key?(key) }
114
+ present.any? && present.all? { |key| stored[key] == fingerprints[label.key] }
101
115
  end.map(&:key)
102
116
  end
103
117
 
@@ -154,7 +168,8 @@ module Truffler
154
168
  case label.type
155
169
  when :noul then [ [ label.key, answers.noul(id) ] ]
156
170
  when :score then [ [ label.key, answers.score(id) ] ]
157
- when :choice then label.options(tenant_key).keys.map { |option| [ "#{label.key}:#{option}", answers.probability(id, option) ] }
171
+ when :choice
172
+ LabelDefinition.sparse_choice(label.options(tenant_key).keys.to_h { |option| [ "#{label.key}:#{option}", answers.probability(id, option) ] }).to_a
158
173
  end
159
174
  end
160
175
  end
@@ -3,6 +3,8 @@ module Truffler
3
3
  # State transitions on truffler_record_states for one model, plus the
4
4
  # deduplicated scheduling of LabelFlushJob per tenant and of BackfillJob
5
5
  # for demoted rows. Jobs carry only the record type and tenant key.
6
+ # Records the definition does not index (a disabled tenant, `index_if`)
7
+ # are never enqueued.
6
8
  class Queue
7
9
  BACKFILL_WAIT = 1.minute
8
10
 
@@ -15,13 +17,15 @@ module Truffler
15
17
  end
16
18
 
17
19
  def enqueue(record, priority: :live)
20
+ return unless definition.indexable?(record)
21
+
18
22
  tenant_key = definition.tenant_key_for(record)
19
23
  now = Time.current
20
24
  Records::RecordState.upsert(
21
25
  { record_type: record_type, record_id: record.id, tenant_key: tenant_key, status: "pending",
22
26
  priority: priority.to_s, attempts: 0, created_at: now, updated_at: now },
23
27
  unique_by: %i[record_type record_id],
24
- update_only: %i[tenant_key status priority attempts updated_at]
28
+ update_only: %i[tenant_key status priority attempts]
25
29
  )
26
30
  schedule(tenant_key)
27
31
  end
@@ -41,13 +45,14 @@ module Truffler
41
45
  job.perform_later(record_type, tenant_key)
42
46
  end
43
47
 
44
- # Starts a backfill shortly after live rows are demoted over the tenant
45
- # cap. One per model per marker lifetime; ResumeJob still catches rows a
46
- # dropped job leaves behind.
47
- def schedule_backfill
48
- return unless config.cache_store.write(backfill_marker, true, unless_exist: true, expires_in: BACKFILL_WAIT + 300)
48
+ # Starts a backfill of the tenant shortly after its live rows are
49
+ # demoted over the tenant cap. One per tenant per marker lifetime;
50
+ # ResumeJob still catches rows a dropped job leaves behind.
51
+ def schedule_backfill(tenant_key = nil)
52
+ return unless definition.tenant_enabled?(tenant_key)
53
+ return unless config.cache_store.write(backfill_marker(tenant_key), true, unless_exist: true, expires_in: BACKFILL_WAIT + 300)
49
54
 
50
- Jobs::BackfillJob.set(wait: BACKFILL_WAIT).perform_later(record_type)
55
+ Jobs::BackfillJob.set(wait: BACKFILL_WAIT).perform_later(record_type, **Jobs::BackfillJob.tenant_argument(model, tenant_key))
51
56
  end
52
57
 
53
58
  def clear_marker(tenant_key)
@@ -118,8 +123,8 @@ module Truffler
118
123
  "truffler/flush/#{record_type}/#{tenant_key}"
119
124
  end
120
125
 
121
- def backfill_marker
122
- "truffler/backfill/#{record_type}"
126
+ def backfill_marker(tenant_key)
127
+ "truffler/backfill/#{record_type}/#{tenant_key}"
123
128
  end
124
129
  end
125
130
  end
@@ -4,9 +4,13 @@ module Truffler
4
4
  # of one tenant into truffler_labels, under the same storage keys as asked
5
5
  # labels, and rewrites those records' label vectors. No Jev request, no
6
6
  # budget slot, no spend. A nil answer stores nothing, so the label reads
7
- # as missing rather than 0. A `from` that raises or answers out of shape
8
- # is instrumented as `supplied_label_failed`, skipped, and listed in
9
- # `failed_ids` so the caller can retry it; its stored rows serve meanwhile.
7
+ # as missing rather than 0. Both failures are instrumented as
8
+ # `supplied_label_failed`. An answer out of shape (InvalidSuppliedAnswer)
9
+ # is permanent (`permanent: true`): it stores nothing for that label,
10
+ # like a nil answer, and the record settles, since retrying the same
11
+ # answer cannot succeed. A `from` that raises anything else is transient:
12
+ # the label is skipped and listed in `failed_ids` so the caller retries
13
+ # it (failed after max_attempts); its stored rows serve meanwhile.
10
14
  class Supplied
11
15
  attr_reader :model, :failed_ids
12
16
 
@@ -61,11 +65,18 @@ module Truffler
61
65
 
62
66
  def answer(label, record, tenant_key)
63
67
  label.supplied_values(record, tenant_key)
68
+ rescue InvalidSuppliedAnswer => error
69
+ instrument_failure(label, record, tenant_key, error, permanent: true)
70
+ nil
64
71
  rescue StandardError => error
65
- Instrumentation.instrument(:supplied_label_failed, record_type: record_type, tenant_key: tenant_key, record_id: record.id,
66
- label_key: label.key, error_class: error.class.name)
72
+ instrument_failure(label, record, tenant_key, error, permanent: false)
67
73
  :failed
68
74
  end
75
+
76
+ def instrument_failure(label, record, tenant_key, error, permanent:)
77
+ Instrumentation.instrument(:supplied_label_failed, record_type: record_type, tenant_key: tenant_key, record_id: record.id,
78
+ label_key: label.key, error_class: error.class.name, permanent: permanent)
79
+ end
69
80
  end
70
81
  end
71
82
  end
@@ -37,6 +37,8 @@ module Truffler
37
37
 
38
38
  attempted.concat(records.map(&:id))
39
39
  records.group_by { |record| definition.tenant_key_for(record) }.each do |tenant_key, slice|
40
+ next unless definition.tenant_enabled?(tenant_key)
41
+
40
42
  stop = label(slice, tenant_key)
41
43
  return result(stop) if stop
42
44
  end
@@ -70,22 +72,38 @@ module Truffler
70
72
  # lens row.
71
73
  def page(attempted)
72
74
  pk = model.primary_key
73
- scope = model.all
74
- scope = scope.where(definition.tenant_column => lens.tenant_key) if definition.scoped? && lens.tenant_key
75
+ scope = definition.index_relation(model.all)
76
+ if definition.scoped? && lens.tenant_key
77
+ return [] unless definition.tenant_enabled?(lens.tenant_key)
78
+
79
+ scope = scope.where(definition.tenant_column => lens.tenant_key)
80
+ elsif definition.scoped? && Truffler.config.tenant_enabled
81
+ scope = scope.where(definition.tenant_column => enabled_tenants)
82
+ end
75
83
  scope = scope.where.not(pk => attempted) if attempted.any?
76
84
  scope.where(Arel.sql(stale_sql)).reorder(definition.arrival_order).limit(batch_size).to_a
77
85
  end
78
86
 
87
+ # Tenants in index_scope that config.tenant_enabled allows, looked up
88
+ # once per run, so disabled tenants are never paged (and never refetched
89
+ # by the next LensBackfillJob).
90
+ def enabled_tenants
91
+ @enabled_tenants ||= definition.index_relation(model.all).reorder(nil).distinct.pluck(definition.tenant_column)
92
+ .select { |tenant_key| definition.tenant_enabled?(tenant_key.to_s) }
93
+ end
94
+
95
+ # Stale unless every lens label has a row under its current fingerprint;
96
+ # a choice label stores only some of its options.
79
97
  def stale_sql
80
- expected = lens.labels.values.flat_map do |label|
81
- print = Lenses.fingerprint(label.question)
82
- label.storage_keys.map { |key| ActiveRecord::Base.sanitize_sql_array([ "(label_key = ? AND fingerprint = ?)", key, print ]) }
83
- end
84
98
  pk = "#{model.quoted_table_name}.#{model.connection.quote_column_name(model.primary_key)}"
85
- ActiveRecord::Base.sanitize_sql_array([
86
- "(SELECT COUNT(*) FROM #{LABELS} WHERE #{LABELS}.record_type = ? AND #{LABELS}.record_id = #{pk} " \
87
- "AND (#{expected.join(' OR ')})) < ?", model.polymorphic_name, expected.size
88
- ])
99
+ current = lens.labels.values.map do |label|
100
+ ActiveRecord::Base.sanitize_sql_array([
101
+ "EXISTS (SELECT 1 FROM #{LABELS} WHERE #{LABELS}.record_type = ? AND #{LABELS}.record_id = #{pk} " \
102
+ "AND #{LABELS}.label_key IN (?) AND #{LABELS}.fingerprint = ?)",
103
+ model.polymorphic_name, label.storage_keys, Lenses.fingerprint(label.question)
104
+ ])
105
+ end
106
+ "NOT (#{current.join(' AND ')})"
89
107
  end
90
108
 
91
109
  # Labels one tenant's records through the labeler, which asks only
@@ -62,6 +62,8 @@ module Truffler
62
62
  # outside the watched columns, e.g. from the job that classified it.
63
63
  def truffler_refresh_labels!
64
64
  definition = self.class.truffler_definition
65
+ return self unless definition.indexable?(self)
66
+
65
67
  tenant_key = definition.tenant_key_for(self)
66
68
  keys = definition.supplied_labels.select { |label| label.available?(tenant_key) }.map(&:key)
67
69
  Labeling::Supplied.new(self.class).write([ [ self, keys ] ], tenant_key: tenant_key) if keys.any?
@@ -72,6 +74,8 @@ module Truffler
72
74
 
73
75
  def truffler_enqueue_labeling
74
76
  definition = self.class.truffler_definition
77
+ return unless definition.indexable?(self)
78
+
75
79
  if previously_new_record?
76
80
  Labeling::Queue.new(self.class).enqueue(self)
77
81
  return
@@ -105,6 +109,7 @@ module Truffler
105
109
  definition = self.class.truffler_definition
106
110
  return unless Embeddings.managed?(definition)
107
111
  return unless previously_new_record? || saved_changes.keys.intersect?(definition.relabel_columns)
112
+ return unless definition.indexable?(self)
108
113
 
109
114
  Jobs::EmbedJob.perform_later(self.class.polymorphic_name, id)
110
115
  end
@@ -39,7 +39,7 @@ module Truffler
39
39
  end
40
40
 
41
41
  def weak_local?
42
- return @local_result.invite_row.present? if @local_result
42
+ return @local_result.local_weak? if @local_result
43
43
  return @run.local_weak? if @run.respond_to?(:local_weak?)
44
44
  unless @run.respond_to?(:candidate_ids)
45
45
  raise ArgumentError, "Providers.start needs local_result: or a run that responds to local_weak? or candidate_ids"
@@ -134,7 +134,8 @@ module Truffler
134
134
  end
135
135
 
136
136
  query = Search::Query.new(request.state["query"])
137
- roles, sources, soft = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, names)
137
+ roles, sources, soft = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, names,
138
+ Search::Filler.label_words(model.truffler_definition, tenant_key))
138
139
  tokens = ->(positions) { positions.map { |position| query.tokens[position] } }
139
140
  Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent,
140
141
  keyword_tokens: tokens.call(roles.keys.select { |position| roles[position] == "keyword" }),
@@ -206,16 +207,19 @@ module Truffler
206
207
  # keyword naming an applied label becomes a label term; stopwords and
207
208
  # filler words become filler unless they are all that would be left of
208
209
  # 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)
210
+ # A word naming any declared label (`keep`, see Filler.label_words) is
211
+ # never filler, even when Jev calls it that. A word Jev called a label
212
+ # term that names no applied label locally is sourced to every applied
213
+ # label.
214
+ def reconcile(query, answered, names, keep)
212
215
  roles = query.tokens.each_index.to_h do |position|
213
- [ position, query.time_position?(position) ? "time" : answered.fetch(position, "keyword") ]
216
+ role = query.time_position?(position) ? "time" : answered.fetch(position, "keyword")
217
+ [ position, role == "filler" && keep.include?(query.tokens[position].singularize) ? "keyword" : role ]
214
218
  end
215
219
  matches = roles.keys.to_h { |position| [ position, roles[position] == "time" ? {} : label_matches(query.tokens[position], names) ] }
216
220
  words = roles.keys.select { |position| roles[position] == "keyword" && !query.exact_tokens.include?(query.tokens[position]) }
217
221
  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]) }
222
+ filler = words.select { |position| roles[position] == "keyword" && Search::Filler.word?(query.tokens[position], keep: keep) }
219
223
  Search::Filler.drop(filler, keyword_count: roles.values.count("keyword"), anchored: names.any? || !query.time_phrase.nil?,
220
224
  stopword: ->(position) { Search::Filler.stopword?(query.tokens[position]) })
221
225
  .each { |position| roles[position] = "filler" }
@@ -1,9 +1,9 @@
1
1
  module Truffler
2
2
  module Records
3
- # The backfill spend ledger: one row per model and app-wide vocabulary
4
- # version, so a spend cap holds across runs, reruns, and overlapping
5
- # BackfillJob chains. Spend is reserved and settled in SQL, never read,
6
- # added to, and written back.
3
+ # The backfill spend ledger: one row per model, tenant (nil for the
4
+ # app-wide ledger), and vocabulary version, so a spend cap holds across
5
+ # runs, reruns, and overlapping BackfillJob chains. Spend is reserved and
6
+ # settled in SQL, never read, added to, and written back.
7
7
  class BackfillSpend < ActiveRecord::Base
8
8
  self.table_name = "truffler_backfill_spends"
9
9
 
@@ -18,10 +18,34 @@ module Truffler
18
18
  false
19
19
  end
20
20
 
21
- def self.ledger(model, version)
22
- create_or_find_by!(record_type: model.polymorphic_name, vocabulary_version: version)
21
+ def self.for_ledger(model, tenant_key)
22
+ tenant_ledgers? ? for_model(model).where(tenant_key: tenant_key) : for_model(model)
23
23
  end
24
24
 
25
+ # Before `rails g truffler:upgrade` adds tenant_key, every tenant
26
+ # shares the app-wide row.
27
+ def self.ledger(model, version, tenant_key: nil)
28
+ attributes = { record_type: model.polymorphic_name, vocabulary_version: version }
29
+ attributes[:tenant_key] = tenant_key if tenant_ledgers?
30
+ create_or_find_by!(attributes)
31
+ end
32
+
33
+ def self.tenant_ledgers?
34
+ return true if column_names.include?("tenant_key")
35
+
36
+ warn_missing_tenant_key
37
+ false
38
+ end
39
+
40
+ def self.warn_missing_tenant_key
41
+ return if @missing_tenant_warned
42
+
43
+ @missing_tenant_warned = true
44
+ Truffler.config.logger.warn("[truffler] #{table_name}.tenant_key is missing, so backfill spend caps are app-wide. " \
45
+ "Run `bin/rails g truffler:upgrade && bin/rails db:migrate`.")
46
+ end
47
+ private_class_method :warn_missing_tenant_key
48
+
25
49
  def self.warn_missing
26
50
  return if @missing_warned
27
51
 
@@ -6,7 +6,7 @@ module Truffler
6
6
  module Redaction
7
7
  KEYS = %i[
8
8
  priority model cost input_tokens tokens_estimated latency_ms error_class status outcome reason
9
- record_type tenant_key user_key surface section sources vocabulary_version label_key
9
+ record_type tenant_key user_key surface section sources vocabulary_version label_key permanent
10
10
  ].to_set.freeze
11
11
  SUFFIXES = %w[_id _ids _count _digest _ms].freeze
12
12
 
@@ -55,8 +55,10 @@ module Truffler
55
55
 
56
56
  # The encoding minus the chips the searcher removed (R20), matched by
57
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.
59
- def without(suppressed)
58
+ # term whose every source label is gone becomes a keyword again. `keep_words`
59
+ # (a set, or a callable returning one) holds the words that are never
60
+ # filler, as in `keywords`.
61
+ def without(suppressed, keep_words: nil)
60
62
  suppressed = Array(suppressed).map(&:to_s).to_set
61
63
  return self if suppressed.empty?
62
64
 
@@ -66,7 +68,9 @@ module Truffler
66
68
  freed = label_term_sources.select { |_, keys| keys.any? && keys.none? { |key| applied.include?(key) } }.keys
67
69
  kept_time = (time unless suppressed.include?(TimeRange.key))
68
70
  keywords = keyword_tokens && (keyword_tokens + freed).uniq
69
- keywords = Filler.keywords(keywords + filler_tokens, anchored: false) if keywords && applied.empty? && kept_time.nil?
71
+ if keywords && applied.empty? && kept_time.nil?
72
+ keywords = Filler.keywords(keywords + filler_tokens, anchored: false, keep: keep_words.respond_to?(:call) ? keep_words.call : keep_words)
73
+ end
70
74
  with(**kept, time: kept_time, label_term_tokens: label_term_tokens - freed,
71
75
  label_term_sources: label_term_sources.except(*freed), soft_keyword_tokens: soft_keyword_tokens - freed,
72
76
  keyword_tokens: keywords)
@@ -85,10 +89,12 @@ module Truffler
85
89
  end
86
90
 
87
91
  # Without encoder decisions (a cold cache), every search token that is
88
- # not a label term, minus filler words (see Filler).
89
- def keywords(query)
92
+ # not a label term, minus filler words (see Filler). `keep` is called
93
+ # only then, for the words that are never filler.
94
+ def keywords(query, keep: nil)
90
95
  keyword_tokens ||
91
- Filler.keywords(query.search_tokens - label_term_tokens, anchored: !empty? || !time.nil?, exact: query.exact_tokens)
96
+ Filler.keywords(query.search_tokens - label_term_tokens, anchored: !empty? || !time.nil?, exact: query.exact_tokens,
97
+ keep: keep&.call)
92
98
  end
93
99
 
94
100
  # The cache form: decisions plus token positions in the normalized
@@ -1,7 +1,7 @@
1
1
  module Truffler
2
2
  module Search
3
3
  # Words that carry no search meaning on their own: common stopwords and
4
- # `config.filler_words` (generic nouns such as "customers" or "emails",
4
+ # `config.filler_words` (generic nouns such as "customers" or "items",
5
5
  # matched ignoring plurals). One rule serves the encoder's reconcile and
6
6
  # the cold-cache keywords: filler is dropped as a keyword unless dropping
7
7
  # it would leave the search with no keyword, no applied label, and no
@@ -12,7 +12,7 @@ module Truffler
12
12
  them there they this to up us was we what when where which who why with you your
13
13
  ].to_set.freeze
14
14
 
15
- DEFAULT_WORDS = %w[customer customers people person user users message messages email emails item items stuff thing things].freeze
15
+ DEFAULT_WORDS = %w[customer customers people person user users message messages item items stuff thing things].freeze
16
16
 
17
17
  module_function
18
18
 
@@ -20,11 +20,34 @@ module Truffler
20
20
  STOPWORDS.include?(word.to_s.downcase)
21
21
  end
22
22
 
23
- def word?(word, filler_words: Truffler.config.filler_words)
23
+ # `keep` holds singular words that are never filler (see label_words).
24
+ def word?(word, filler_words: Truffler.config.filler_words, keep: nil)
24
25
  word = word.to_s.downcase
26
+ return false if keep&.include?(word.singularize)
27
+
25
28
  stopword?(word) || Array(filler_words).any? { |filler| filler.to_s.downcase.singularize == word.singularize }
26
29
  end
27
30
 
31
+ # Singular words that name one of the model's declared labels for this
32
+ # tenant, applied or not: words of a label key, of a choice option key,
33
+ # and of an option's search text (not its description, which is prose).
34
+ # Such a word is never filler, so "text messages" still searches
35
+ # "messages" when an option's search text is "text message".
36
+ def label_words(definition, tenant_key = nil)
37
+ definition.labels.each_value.with_object(Set.new) do |label, words|
38
+ next unless label.available?(tenant_key)
39
+
40
+ names = [ label.key ]
41
+ if label.type == :choice
42
+ options = label.encoding_wording(tenant_key)[:options]
43
+ names.concat(options.keys, options.values.filter_map { |entry| entry[:search] })
44
+ end
45
+ names.each do |name|
46
+ name.to_s.downcase.split(/[^\p{Alnum}]+/).each { |word| words << word.singularize unless word.empty? || stopword?(word) }
47
+ end
48
+ end
49
+ end
50
+
28
51
  # The subset of `candidates` (droppable keywords) to drop, given how many
29
52
  # keywords there are and whether a label or time range is applied. When
30
53
  # nothing anchors the search and only droppable words are left, the
@@ -38,9 +61,11 @@ module Truffler
38
61
  end
39
62
 
40
63
  # `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)
64
+ # would anchor the search. Exact tokens (a quoted "the") and `keep`
65
+ # words are never filler.
66
+ def keywords(tokens, anchored:, exact: [], keep: nil)
67
+ dropped = drop(tokens.select { |token| !exact.include?(token) && word?(token, keep: keep) }, keyword_count: tokens.size,
68
+ anchored: anchored)
44
69
  tokens - dropped
45
70
  end
46
71
  end
@@ -41,12 +41,12 @@ module Truffler
41
41
  explicit_action = surface_action
42
42
  cached = read_encoding
43
43
  status = encoding_status(cached)
44
- encoding = with_time(visible_lenses_only(cached&.without(suppressed), record_usage: true))
44
+ encoding = visible_lenses_only(with_time(cached)&.without(suppressed, keep_words: label_words), record_usage: true)
45
45
  sql = sql(encoding)
46
46
  records = sql.relation(scope, limit: limit).to_a
47
47
  result = Result.new(records: records, query: query, encoding: encoding, encoding_status: status, watermark: watermark,
48
- explicit_action: explicit_action, sources: sql.sources, invite_row: invite_row(records, cached),
49
- weights: @weights, recount: ->(since) { count(since: since) })
48
+ explicit_action: explicit_action, sources: sql.sources, invite_row: invite_row(records, cached, status),
49
+ local_weak: local_weak?(records, cached), weights: @weights, recount: ->(since) { count(since: since) })
50
50
  instrument(result, started)
51
51
  result
52
52
  end
@@ -54,7 +54,7 @@ module Truffler
54
54
  # How many records the same search would return that arrived after
55
55
  # `since` (R25). Reads the cache only and never prefetches.
56
56
  def count(since:)
57
- sql(with_time(visible_lenses_only(read_encoding&.without(suppressed)))).candidates(scope)
57
+ sql(visible_lenses_only(with_time(read_encoding)&.without(suppressed, keep_words: label_words))).candidates(scope)
58
58
  .where(model.arel_table[@definition.arrived_at_column].gt(since)).count
59
59
  end
60
60
 
@@ -83,6 +83,10 @@ module Truffler
83
83
 
84
84
  # Drops lens keys this searcher cannot see (another user's personal
85
85
  # lens, an expired lens) and counts a use of the rest (R42, R43).
86
+ def label_words
87
+ -> { Filler.label_words(@definition, tenant_key) }
88
+ end
89
+
86
90
  def visible_lenses_only(encoding, record_usage: false)
87
91
  lens_keys = encoding ? (encoding.intent_vector.keys | encoding.filters.keys | encoding.boosts.keys).select { |key| lens_id(key) } : []
88
92
  return encoding if lens_keys.empty?
@@ -90,11 +94,12 @@ module Truffler
90
94
  visible = Lenses.labels(model, tenant_key: tenant_key, user_key: user_key).values.map(&:lens_id).uniq
91
95
  hidden, shown = lens_keys.partition { |key| !visible.include?(lens_id(key)) }
92
96
  Lenses.record_usage(shown.map { |key| lens_id(key) }.uniq) if record_usage
93
- encoding.without(hidden)
97
+ encoding.without(hidden, keep_words: label_words)
94
98
  end
95
99
 
96
100
  # The query's time phrase, resolved on this search's clock, unless the
97
- # searcher removed its chip.
101
+ # searcher removed its chip. Attached before `without`, which keeps
102
+ # filler dropped while a time range still anchors the search.
98
103
  def with_time(encoding)
99
104
  phrase = query.time_phrase
100
105
  return encoding if phrase.nil? || suppressed.include?(TimeRange.key)
@@ -133,20 +138,29 @@ module Truffler
133
138
  Sql.new(model, tenant_key: tenant_key, query: query, encoding: encoding, vector: read_vector, weights: @weights)
134
139
  end
135
140
 
136
- # R21: the Smart search row. A model with no local text search whose
137
- # encoding is not cached yet invites the action even when a blind index
138
- # matched, because intent queries resolve on the action there (AE10).
139
- def invite_row(records, cached)
141
+ # R21: the Smart search row. A query whose encoding is not cached yet
142
+ # invites the action even when a blind index or keyword matched,
143
+ # because a first-time intent query resolves on the action (AE10): on a
144
+ # model with no local text search always, and with a `keyword` source
145
+ # while the encoding is in flight unless `invite_on_pending_encoding false`.
146
+ def invite_row(records, cached, status)
140
147
  return if query.blank?
141
148
 
142
- reason =
143
- if @definition.keyword.blank? && cached.nil? then :encoding_pending
144
- elsif records.empty? then :empty
145
- elsif records.size < @definition.weak_below then :weak
146
- end
149
+ pending = cached.nil? && (@definition.keyword.blank? || (@definition.invite_on_pending_encoding && status == :pending))
150
+ reason = pending ? :encoding_pending : weak_reason(records)
147
151
  { query: query.raw.strip, reason: reason } if reason
148
152
  end
149
153
 
154
+ def weak_reason(records)
155
+ if records.empty? then :empty
156
+ elsif records.size < @definition.weak_below then :weak
157
+ end
158
+ end
159
+
160
+ def local_weak?(records, cached)
161
+ !query.blank? && (weak_reason(records).present? || (@definition.keyword.blank? && cached.nil?))
162
+ end
163
+
150
164
  def instrument(result, started)
151
165
  payload = { record_type: model.polymorphic_name, tenant_key: tenant_key, surface: surface, outcome: result.encoding_status,
152
166
  result_count: result.records.size, filter_count: result.encoding&.filters&.size.to_i,
@@ -12,7 +12,9 @@ module Truffler
12
12
 
13
13
  attr_reader :records, :query, :encoding, :encoding_status, :watermark, :explicit_action, :sources, :invite_row
14
14
 
15
- def initialize(records:, query:, encoding:, encoding_status:, watermark:, explicit_action:, sources:, invite_row:, weights:, recount:)
15
+ def initialize(records:, query:, encoding:, encoding_status:, watermark:, explicit_action:, sources:, invite_row:, weights:, recount:,
16
+ local_weak: nil)
17
+ @local_weak = local_weak
16
18
  @records = records
17
19
  @query = query
18
20
  @encoding = encoding
@@ -29,6 +31,14 @@ module Truffler
29
31
  records.map(&:id)
30
32
  end
31
33
 
34
+ # Whether the local list is too weak to stand alone, which starts the
35
+ # backup provider on the explicit action. An `:encoding_pending` row on
36
+ # a model with a `keyword` source invites Smart search without making
37
+ # the list weak.
38
+ def local_weak?
39
+ @local_weak.nil? ? invite_row.present? : @local_weak
40
+ end
41
+
32
42
  # Applied filters, then boosts, then the time range, as `{key:, label:, kind:, name:}`.
33
43
  def chips
34
44
  return [] unless encoding