truffler 0.1.5 → 0.1.6

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.
@@ -0,0 +1,54 @@
1
+ module Truffler
2
+ module QueryEncoding
3
+ # With `config.skip_empty_options`, the choice options a tenant actually
4
+ # has: storage keys with at least one label row at or above
5
+ # `config.choice_min_probability` (above 0.0 when that is nil). Query
6
+ # encoding offers Jev only these, so it cannot pick "Source: email" in a
7
+ # tenant with no email sources.
8
+ #
9
+ # The set is digested into the encoding cache key, so encodings refresh
10
+ # when an option appears. It is read through the cache store for TTL, so
11
+ # a keystroke stays one SELECT; a new option reaches query encoding (and
12
+ # the cache key) within TTL.
13
+ class PresentOptions
14
+ TTL = 5.minutes
15
+
16
+ def initialize(store: Truffler.config.cache_store)
17
+ @store = store
18
+ end
19
+
20
+ def self.enabled?
21
+ Truffler.config.skip_empty_options == true
22
+ end
23
+
24
+ # The present storage keys among `labels`' choice options, or nil when
25
+ # skip_empty_options is off.
26
+ def keys(model, labels, tenant_key:)
27
+ return unless self.class.enabled?
28
+
29
+ candidates = labels.values.select { |label| label.type == :choice }.flat_map { |label| label.storage_keys(tenant_key) }.sort
30
+ return Set.new if candidates.empty?
31
+
32
+ min = Truffler.config.choice_min_probability
33
+ cache_key = "truffler/present_options/#{Canonical.digest(record_type: model.polymorphic_name, tenant_key: tenant_key&.to_s,
34
+ keys: candidates, min: min)}"
35
+ Set.new(@store.fetch(cache_key, expires_in: TTL) { present(model, candidates, tenant_key, min) })
36
+ end
37
+
38
+ # A label's options narrowed to `present` (all of them when nil).
39
+ def self.options(label, tenant_key, present)
40
+ options = label.options(tenant_key)
41
+ present ? options.select { |option, _| present.include?("#{label.key}:#{option}") } : options
42
+ end
43
+
44
+ private
45
+
46
+ def present(model, candidates, tenant_key, min)
47
+ rows = Records::Label.where(record_type: model.polymorphic_name, label_key: candidates)
48
+ rows = rows.where(tenant_key: tenant_key.to_s) if model.truffler_definition.scoped?
49
+ rows = min ? rows.where(value: min..) : rows.where(Records::Label.arel_table[:value].gt(0.0))
50
+ rows.distinct.pluck(:label_key).sort
51
+ end
52
+ end
53
+ end
54
+ end
@@ -6,6 +6,7 @@ module Truffler
6
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
+ TENANT_KEY_RECHECK = 1.minute
9
10
 
10
11
  scope :for_model, ->(model) { where(record_type: model.polymorphic_name) }
11
12
 
@@ -23,20 +24,49 @@ module Truffler
23
24
  end
24
25
 
25
26
  # 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)
27
+ # shares the app-wide row. Before 0.1.6 rows were keyed by the whole
28
+ # vocabulary version; pass it as `legacy_version:` and the first lookup
29
+ # takes that row over instead of starting from zero.
30
+ def self.ledger(model, version, tenant_key: nil, legacy_version: nil)
28
31
  attributes = { record_type: model.polymorphic_name, vocabulary_version: version }
29
32
  attributes[:tenant_key] = tenant_key if tenant_ledgers?
33
+ adopt(attributes, legacy_version) if legacy_version && legacy_version != version
30
34
  create_or_find_by!(attributes)
31
35
  end
32
36
 
37
+ def self.adopt(attributes, legacy_version)
38
+ return if exists?(attributes)
39
+
40
+ where(attributes.merge(vocabulary_version: legacy_version)).update_all(vocabulary_version: attributes[:vocabulary_version])
41
+ rescue ActiveRecord::RecordNotUnique
42
+ nil
43
+ end
44
+ private_class_method :adopt
45
+
46
+ # A worker booted before `db:migrate` added tenant_key has the old
47
+ # columns cached, so a miss reloads them at most once per
48
+ # TENANT_KEY_RECHECK and the worker moves to tenant ledgers without a
49
+ # restart.
33
50
  def self.tenant_ledgers?
34
51
  return true if column_names.include?("tenant_key")
35
52
 
53
+ if recheck_tenant_key?
54
+ reset_column_information
55
+ return true if column_names.include?("tenant_key")
56
+ end
36
57
  warn_missing_tenant_key
37
58
  false
38
59
  end
39
60
 
61
+ def self.recheck_tenant_key?
62
+ now = Time.current
63
+ return false if @tenant_key_checked_at && now - @tenant_key_checked_at < TENANT_KEY_RECHECK
64
+
65
+ @tenant_key_checked_at = now
66
+ true
67
+ end
68
+ private_class_method :recheck_tenant_key?
69
+
40
70
  def self.warn_missing_tenant_key
41
71
  return if @missing_tenant_warned
42
72
 
@@ -65,15 +65,30 @@ module Truffler
65
65
  keep = ->(key, _) { !suppressed.include?(key) && !suppressed.include?(self.class.split_key(key).first) }
66
66
  kept = { filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep) }
67
67
  applied = kept.values.flat_map(&:keys).to_set
68
- freed = label_term_sources.select { |_, keys| keys.any? && keys.none? { |key| applied.include?(key) } }.keys
68
+ freed = freed_words(applied)
69
69
  kept_time = (time unless suppressed.include?(TimeRange.key))
70
70
  keywords = keyword_tokens && (keyword_tokens + freed).uniq
71
71
  if keywords && applied.empty? && kept_time.nil?
72
72
  keywords = Filler.keywords(keywords + filler_tokens, anchored: false, keep: keep_words.respond_to?(:call) ? keep_words.call : keep_words)
73
73
  end
74
- with(**kept, time: kept_time, label_term_tokens: label_term_tokens - freed,
75
- label_term_sources: label_term_sources.except(*freed), soft_keyword_tokens: soft_keyword_tokens - freed,
76
- keyword_tokens: keywords)
74
+ with(**kept, time: kept_time, **free(freed, keywords))
75
+ end
76
+
77
+ # The encoding with the filters `keys` demoted to soft boosts (zero-result
78
+ # relaxation, see Relaxation): they stay in the intent vector, at their
79
+ # intent weight or DEFAULT_BOOST when filtering gave them none, so records
80
+ # that match still rank first, but nothing requires them. A label term
81
+ # whose every applied source was relaxed becomes a keyword again, as in
82
+ # `without`.
83
+ def relax(keys)
84
+ relaxed = filters.slice(*Array(keys).map(&:to_s))
85
+ return self if relaxed.empty?
86
+
87
+ soft = relaxed.to_h { |key, _| [ key, intent_vector[key] || boosts[key] || QueryEncoding::DEFAULT_BOOST ] }
88
+ kept_filters = filters.except(*relaxed.keys)
89
+ freed = freed_words((kept_filters.keys + boosts.keys + intent_vector.keys).to_set - relaxed.keys)
90
+ with(filters: kept_filters, boosts: boosts.merge(soft), intent_vector: intent_vector.merge(soft),
91
+ **free(freed, keyword_tokens && (keyword_tokens + freed).uniq))
77
92
  end
78
93
 
79
94
  # Splits a storage key into its label key and choice option. Lens keys
@@ -113,6 +128,16 @@ module Truffler
113
128
 
114
129
  private
115
130
 
131
+ # Label terms none of whose source keys is still applied.
132
+ def freed_words(applied)
133
+ label_term_sources.select { |_, keys| keys.any? && keys.none? { |key| applied.include?(key) } }.keys
134
+ end
135
+
136
+ def free(freed, keywords)
137
+ { label_term_tokens: label_term_tokens - freed, label_term_sources: label_term_sources.except(*freed),
138
+ soft_keyword_tokens: soft_keyword_tokens - freed, keyword_tokens: keywords }
139
+ end
140
+
116
141
  def weights(hash)
117
142
  hash.to_h.to_h { |key, weight| [ key.to_s, Float(weight) ] }
118
143
  end
@@ -62,7 +62,18 @@ module Truffler
62
62
  definition = model.truffler_definition
63
63
  tenant = tenant_key&.to_s if definition.per_tenant_vocabulary?
64
64
  version = definition.vocabulary.encoding_version(tenant_key: tenant_key&.to_s, user_key: user_key)
65
- Canonical.digest(record_type: model.polymorphic_name, query: query.normalized, vocabulary_version: version, tenant_key: tenant)
65
+ parts = { record_type: model.polymorphic_name, query: query.normalized, vocabulary_version: version, tenant_key: tenant }
66
+ present = present_options(model, tenant_key, user_key)
67
+ Canonical.digest(present ? parts.merge(present_options: Canonical.digest(present.to_a.sort)) : parts)
68
+ end
69
+
70
+ # With skip_empty_options, the tenant's present choice options (see
71
+ # QueryEncoding::PresentOptions), so an encoding refreshes when one appears.
72
+ def present_options(model, tenant_key, user_key)
73
+ return unless QueryEncoding::PresentOptions.enabled?
74
+
75
+ labels = model.truffler_definition.vocabulary.labels_for(tenant_key: tenant_key&.to_s, user_key: user_key)
76
+ QueryEncoding::PresentOptions.new.keys(model, labels, tenant_key: tenant_key&.to_s)
66
77
  end
67
78
  end
68
79
  end
@@ -36,26 +36,36 @@ module Truffler
36
36
  end
37
37
 
38
38
  def call
39
- started = Instrumentation.monotonic_ms
40
- watermark = Time.current
41
- explicit_action = surface_action
42
- cached = read_encoding
43
- status = encoding_status(cached)
44
- encoding = visible_lenses_only(with_time(cached)&.without(suppressed, keep_words: label_words), record_usage: true)
45
- sql = sql(encoding)
46
- records = sql.relation(scope, limit: limit).to_a
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, status),
49
- local_weak: local_weak?(records, cached), weights: @weights, recount: ->(since) { count(since: since) })
50
- instrument(result, started)
51
- result
39
+ Current.scope do
40
+ started = Instrumentation.monotonic_ms
41
+ watermark = Time.current
42
+ explicit_action = surface_action
43
+ cached = read_encoding
44
+ status = encoding_status(cached)
45
+ encoding = visible_lenses_only(with_time(cached)&.without(suppressed, keep_words: label_words), record_usage: true)
46
+ sql = sql(encoding)
47
+ records = sql.relation(scope, limit: limit).to_a
48
+ relaxed = relax(encoding, records)
49
+ records, encoding, sql = relaxed.records, relaxed.encoding, sql(relaxed.encoding) if relaxed
50
+ relaxed_labels = relaxed&.relaxed_labels.to_a
51
+ result = Result.new(records: records, query: query, encoding: encoding, encoding_status: status, watermark: watermark,
52
+ explicit_action: explicit_action, sources: sql.sources, invite_row: invite_row(records, cached, status),
53
+ local_weak: local_weak?(records, cached), weights: @weights, relaxed_labels: relaxed_labels,
54
+ recount: ->(since) { count(since: since, relaxed: relaxed_labels) })
55
+ instrument(result, started)
56
+ result
57
+ end
52
58
  end
53
59
 
54
60
  # How many records the same search would return that arrived after
55
- # `since` (R25). Reads the cache only and never prefetches.
56
- def count(since:)
57
- sql(visible_lenses_only(with_time(read_encoding)&.without(suppressed, keep_words: label_words))).candidates(scope)
58
- .where(model.arel_table[@definition.arrived_at_column].gt(since)).count
61
+ # `since` (R25), with the filters `relaxed` demoted as the search did.
62
+ # Reads the cache only and never prefetches.
63
+ def count(since:, relaxed: [])
64
+ Current.scope do
65
+ encoding = visible_lenses_only(with_time(read_encoding)&.without(suppressed, keep_words: label_words))
66
+ sql(relaxed.any? ? encoding&.relax(relaxed) : encoding).candidates(scope)
67
+ .where(model.arel_table[@definition.arrived_at_column].gt(since)).count
68
+ end
59
69
  end
60
70
 
61
71
  private
@@ -138,6 +148,13 @@ module Truffler
138
148
  Sql.new(model, tenant_key: tenant_key, query: query, encoding: encoding, vector: read_vector, weights: @weights)
139
149
  end
140
150
 
151
+ # Only an empty result under filters pays for relaxation.
152
+ def relax(encoding, records)
153
+ return unless records.empty? && encoding&.filters&.any?
154
+
155
+ Relaxation.new(model, encoding, sql: method(:sql)).call(scope, limit: limit)
156
+ end
157
+
141
158
  # R21: the Smart search row. A query whose encoding is not cached yet
142
159
  # invites the action even when a blind index or keyword matched,
143
160
  # because a first-time intent query resolves on the action (AE10): on a
@@ -163,7 +180,7 @@ module Truffler
163
180
 
164
181
  def instrument(result, started)
165
182
  payload = { record_type: model.polymorphic_name, tenant_key: tenant_key, surface: surface, outcome: result.encoding_status,
166
- result_count: result.records.size, filter_count: result.encoding&.filters&.size.to_i,
183
+ result_count: result.records.size, filter_count: result.encoding&.filters&.size.to_i, relaxed_count: result.relaxed_labels.size,
167
184
  boost_count: result.encoding&.intent_vector&.size.to_i, sources: result.sources.map(&:to_s),
168
185
  reason: result.invite_row&.dig(:reason), latency_ms: Instrumentation.elapsed_ms(started) }
169
186
  payload[:query_digest] = Misses.digest(:query, query.normalized) if Misses.encrypted_model?(model)
@@ -0,0 +1,95 @@
1
+ module Truffler
2
+ module Search
3
+ # Zero-result relaxation (0.1.6). When a search under the encoding's
4
+ # filters returns nothing, the filters are demoted to soft boosts
5
+ # (Encoding#relax) and the words they consumed become keywords again.
6
+ # It relaxes as little as it can: first only the filters no record in
7
+ # the tenant carries at their threshold ("Source: email" in a tenant with
8
+ # no email sources), keeping the rest hard; if that still finds nothing,
9
+ # every filter, but only when the words given back (or an exact or vector
10
+ # match) still narrow the search: relaxing into "every record in the
11
+ # tenant" would show unrelated results, so that stays empty.
12
+ #
13
+ # Both attempts run as one UNION ALL query, so relaxation costs a single
14
+ # extra SELECT and only on an empty result. Each "these filters are
15
+ # missing" branch is gated on label presence in the tenant, so at most
16
+ # one of them can return rows; the relax-everything branch ranks after
17
+ # it. With more than MAX_PARTIAL_FILTERS filters only the
18
+ # relax-everything branch runs, to bound the branch count (2^n - 1).
19
+ class Relaxation
20
+ MAX_PARTIAL_FILTERS = 3
21
+ TIER = "truffler_relaxed_tier".freeze
22
+ BRANCH = "truffler_relaxed_branch".freeze
23
+ SCORE_COLUMNS = %i[truffler_score truffler_label_score truffler_text_score truffler_keyword_score truffler_exact_score].freeze
24
+
25
+ Outcome = Data.define(:records, :encoding, :relaxed_labels)
26
+
27
+ # `sql` builds the search's Sql for an encoding.
28
+ def initialize(model, encoding, sql:)
29
+ @model = model
30
+ @encoding = encoding
31
+ @sql = sql
32
+ end
33
+
34
+ # The relaxed search's outcome, or nil when there is no filter to relax
35
+ # or the relaxed search is still empty.
36
+ def call(scope, limit: nil)
37
+ filters = @encoding&.filters.to_h
38
+ return if filters.empty?
39
+
40
+ branches = partial_branches(filters.keys)
41
+ branches << filters.keys unless @sql.call(@encoding.relax(filters.keys)).every_base_record?
42
+ return if branches.empty?
43
+
44
+ rows = @model.find_by_sql(union_sql(scope, filters, branches, limit))
45
+ return if rows.empty?
46
+
47
+ partial, full = rows.partition { |row| Integer(row[TIER]) == 1 }
48
+ records = partial.presence || full
49
+ relaxed = branches.fetch(Integer(records.first[BRANCH]))
50
+ Outcome.new(records: records, encoding: @encoding.relax(relaxed), relaxed_labels: relaxed)
51
+ end
52
+
53
+ private
54
+
55
+ # Every nonempty proper subset of the filters, as a "missing" set.
56
+ def partial_branches(keys)
57
+ return [] if keys.size > MAX_PARTIAL_FILTERS
58
+
59
+ (1...keys.size).flat_map { |size| keys.combination(size).to_a }
60
+ end
61
+
62
+ def union_sql(scope, filters, branches, limit)
63
+ present = ->(key) { @sql.call(@encoding).label_present_sql(key, filters.fetch(key)) }
64
+ selects = branches.each_with_index.map do |missing, index|
65
+ full = missing.size == filters.size
66
+ guard = (filters.keys - missing).map { |key| present.call(key) } + missing.map { |key| "NOT #{present.call(key)}" }
67
+ branch_sql(scope, missing, index, tier: full ? 0 : 1, guard: full ? [] : guard, limit: limit)
68
+ end
69
+ sql = "SELECT * FROM (#{selects.join(' UNION ALL ')}) truffler_relaxed ORDER BY #{ordering.join(', ')}"
70
+ limit ? "#{sql} LIMIT #{Integer(limit)}" : sql
71
+ end
72
+
73
+ def branch_sql(scope, missing, index, tier:, guard:, limit:)
74
+ search = @sql.call(@encoding.relax(missing))
75
+ alias_name = "truffler_relaxed_#{index}"
76
+ scores = search.score_column_names
77
+ columns = @model.column_names.map { |name| "#{alias_name}.#{quote_column(name)}" } +
78
+ SCORE_COLUMNS.map { |name| scores.include?(name) ? "#{alias_name}.#{name}" : "0.0 AS #{name}" } +
79
+ [ "#{tier} AS #{TIER}", "#{index} AS #{BRANCH}" ]
80
+ where = guard.any? ? " WHERE #{guard.join(' AND ')}" : ""
81
+ "SELECT #{columns.join(', ')} FROM (#{search.relation(scope, limit: limit).to_sql}) #{alias_name}#{where}"
82
+ end
83
+
84
+ def ordering
85
+ order_column, direction = @model.truffler_definition.order
86
+ [ "#{TIER} DESC", "truffler_score DESC", ("#{quote_column(order_column)} #{direction == :asc ? 'ASC' : 'DESC'}" if order_column),
87
+ "#{quote_column(@model.primary_key)} DESC" ].compact
88
+ end
89
+
90
+ def quote_column(name)
91
+ @model.connection.quote_column_name(name)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -10,11 +10,12 @@ module Truffler
10
10
  SCORE_COLUMNS = { label: "truffler_label_score", text: "truffler_text_score", keyword: "truffler_keyword_score",
11
11
  exact: "truffler_exact_score" }.freeze
12
12
 
13
- attr_reader :records, :query, :encoding, :encoding_status, :watermark, :explicit_action, :sources, :invite_row
13
+ attr_reader :records, :query, :encoding, :encoding_status, :watermark, :explicit_action, :sources, :invite_row, :relaxed_labels
14
14
 
15
15
  def initialize(records:, query:, encoding:, encoding_status:, watermark:, explicit_action:, sources:, invite_row:, weights:, recount:,
16
- local_weak: nil)
16
+ local_weak: nil, relaxed_labels: [])
17
17
  @local_weak = local_weak
18
+ @relaxed_labels = relaxed_labels
18
19
  @records = records
19
20
  @query = query
20
21
  @encoding = encoding
@@ -39,12 +40,17 @@ module Truffler
39
40
  @local_weak.nil? ? invite_row.present? : @local_weak
40
41
  end
41
42
 
42
- # Applied filters, then boosts, then the time range, as `{key:, label:, kind:, name:}`.
43
+ # Applied filters, then relaxed filters, then boosts, then the time range,
44
+ # as `{key:, label:, kind:, name:}`. A filter relaxed because it left
45
+ # nothing to show (`relaxed_labels`) keeps its filter chip with
46
+ # `relaxed: true`, so the host can say "No email sources; showing
47
+ # keyword matches".
43
48
  def chips
44
49
  return [] unless encoding
45
50
 
46
- filters = encoding.filters.keys.map { |key| chip(key, :filter) }
47
- chips = filters + (encoding.boosts.keys - encoding.filters.keys).map { |key| chip(key, :boost) }
51
+ filters = encoding.filters.keys.map { |key| chip(key, :filter) } +
52
+ relaxed_labels.map { |key| chip(key, :filter).merge(relaxed: true) }
53
+ chips = filters + (encoding.boosts.keys - encoding.filters.keys - relaxed_labels).map { |key| chip(key, :boost) }
48
54
  time = encoding.time
49
55
  time ? chips + [ { key: TimeRange.key, label: TimeRange.key, kind: :time, name: time.name } ] : chips
50
56
  end
@@ -82,6 +82,22 @@ module Truffler
82
82
  @keywords ||= encoding.keywords(query, keep: -> { Filler.label_words(definition, tenant_key) })
83
83
  end
84
84
 
85
+ # Whether any record in the tenant carries `key` at or above `threshold`.
86
+ def label_present_sql(key, threshold)
87
+ tenant = definition.scoped? ? " AND #{label_column('tenant_key')} = #{quote(tenant_key)}" : ""
88
+ "EXISTS (SELECT 1 FROM #{quoted_labels} WHERE #{label_column('record_type')} = #{quote(model.polymorphic_name)}#{tenant} " \
89
+ "AND #{label_column('label_key')} = #{quote(key)} AND #{label_column('value')} >= #{Float(threshold)})"
90
+ end
91
+
92
+ def score_column_names
93
+ score_columns.keys
94
+ end
95
+
96
+ # Every record past the tenant, time, and filters is a candidate.
97
+ def every_base_record?
98
+ label_only? || encoding.filters.any?
99
+ end
100
+
85
101
  private
86
102
 
87
103
  def definition
@@ -136,11 +152,6 @@ module Truffler
136
152
  "#{label_column('label_key')} IN (#{encoding.intent_vector.keys.map { |key| quote(key) }.join(', ')})"
137
153
  end
138
154
 
139
- # Every record past the tenant, time, and filters is a candidate.
140
- def every_base_record?
141
- label_only? || encoding.filters.any?
142
- end
143
-
144
155
  def grouped_label_scores?
145
156
  encoding.intent_vector.any? && every_base_record?
146
157
  end
@@ -21,23 +21,26 @@ module Truffler
21
21
  end
22
22
 
23
23
  def call(run)
24
- return unless run.status == :pending && run.model.try(:truffler_definition)
24
+ Current.scope do
25
+ return unless run.status == :pending && run.model.try(:truffler_definition)
25
26
 
26
- start_provider(run)
27
- decision = @budget.admit(priority: :rerank, user_key: run.user_key)
28
- if decision.denied?
29
- run.pause!(decision.reason)
30
- return
31
- end
27
+ start_provider(run)
28
+ decision = @budget.admit(priority: :rerank, user_key: run.user_key)
29
+ if decision.denied?
30
+ run.pause!(decision.reason)
31
+ return
32
+ end
32
33
 
33
- encoding = await_encoding(run)
34
- candidate_ids = filter(run, encoding)
35
- return if run.cancelled?
34
+ encoding = await_encoding(run)
35
+ candidate_ids, encoding, relaxed = filter(run, encoding)
36
+ return if run.cancelled?
36
37
 
37
- run.plan!(candidate_ids: candidate_ids, chunk_size: @config.rerank_chunk_size, filters: encoding&.filters&.keys.to_a)
38
- run.chunk_count.times { |index| @enqueue.call(run, index) }
39
- run.ping(SMART) if candidate_ids.empty?
40
- candidate_ids
38
+ run.plan!(candidate_ids: candidate_ids, chunk_size: @config.rerank_chunk_size, filters: encoding&.filters&.keys.to_a,
39
+ relaxed_labels: relaxed)
40
+ run.chunk_count.times { |index| @enqueue.call(run, index) }
41
+ run.ping(SMART) if candidate_ids.empty?
42
+ candidate_ids
43
+ end
41
44
  end
42
45
 
43
46
  private
@@ -67,21 +70,30 @@ module Truffler
67
70
  encoding&.without(run.suppressed, keep_words: -> { Search::Filler.label_words(model.truffler_definition, run.tenant_key) })
68
71
  end
69
72
 
70
- # The snapshot narrowed to what the encoding allows, in the tenant, in
71
- # the keystroke ranking the encoding gives (or snapshot order without
72
- # one), capped at `rerank_depth`.
73
+ # `[candidate_ids, encoding, relaxed_labels]`: the snapshot narrowed to
74
+ # what the encoding allows, in the tenant, in the keystroke ranking the
75
+ # encoding gives (or snapshot order without one), capped at
76
+ # `rerank_depth`. When the filters leave nothing, they relax as on the
77
+ # keystroke (Search::Relaxation) rather than rerank an empty set.
73
78
  def filter(run, encoding)
74
79
  model = run.model
75
80
  pool = model.where(model.primary_key => run.pool_ids)
76
- sql = Search::Sql.new(model, tenant_key: run.tenant_key, query: run.search_query, encoding: encoding)
77
- allowed = sql.base(pool).pluck(model.primary_key)
81
+ sql = ->(for_encoding) { Search::Sql.new(model, tenant_key: run.tenant_key, query: run.search_query, encoding: for_encoding) }
78
82
  ids = if encoding.nil? || encoding.empty?
79
- run.pool_ids & allowed
83
+ run.pool_ids & sql.call(encoding).base(pool).pluck(model.primary_key)
80
84
  else
81
- ranked = sql.relation(pool).map(&:id)
82
- ranked + ((run.pool_ids & allowed) - ranked)
85
+ ranked(run, pool, sql.call(encoding).relation(pool).map(&:id), sql.call(encoding))
83
86
  end
84
- ids.first(@config.rerank_depth)
87
+ relaxed = (Search::Relaxation.new(model, encoding, sql: sql).call(pool) if ids.empty? && encoding&.filters&.any?)
88
+ if relaxed
89
+ encoding = relaxed.encoding
90
+ ids = ranked(run, pool, relaxed.records.map(&:id), sql.call(encoding))
91
+ end
92
+ [ ids.first(@config.rerank_depth), encoding, relaxed&.relaxed_labels.to_a ]
93
+ end
94
+
95
+ def ranked(run, pool, ranked_ids, sql)
96
+ ranked_ids + ((run.pool_ids & sql.base(pool).pluck(run.model.primary_key)) - ranked_ids)
85
97
  end
86
98
  end
87
99
  end
@@ -24,12 +24,14 @@ module Truffler
24
24
  # Scores chunk `index` of the run and appends it to the buckets.
25
25
  # Returns :done, :cancelled, :paused, :failed, or :skipped.
26
26
  def call(run, index)
27
- return :skipped unless run.active? && run.chunk_ids(index) && !run.chunk_resolved?(index)
27
+ Current.scope do
28
+ return :skipped unless run.active? && run.chunk_ids(index) && !run.chunk_resolved?(index)
28
29
 
29
- started = Instrumentation.monotonic_ms
30
- outcome = rerank(run, index)
31
- instrument(run, index, outcome, started)
32
- outcome
30
+ started = Instrumentation.monotonic_ms
31
+ outcome = rerank(run, index)
32
+ instrument(run, index, outcome, started)
33
+ outcome
34
+ end
33
35
  end
34
36
 
35
37
  def request(run, records)
@@ -145,6 +145,12 @@ module Truffler
145
145
  Array(plan&.fetch("filters", nil))
146
146
  end
147
147
 
148
+ # Filters the awaited encoding applied that were relaxed because they
149
+ # left no candidate (see Search::Relaxation).
150
+ def relaxed_labels
151
+ Array(plan&.fetch("relaxed_labels", nil))
152
+ end
153
+
148
154
  def chunk(index)
149
155
  store.read(id, "chunk/#{index}") unless expired?
150
156
  end
@@ -212,9 +218,10 @@ module Truffler
212
218
  true
213
219
  end
214
220
 
215
- def plan!(candidate_ids:, chunk_size:, filters:)
221
+ def plan!(candidate_ids:, chunk_size:, filters:, relaxed_labels: [])
216
222
  store.write(id, "plan", { "candidate_ids" => candidate_ids, "chunks" => candidate_ids.each_slice(chunk_size).to_a,
217
- "filters" => filters, "thresholds" => Truffler.config.smart_thresholds.transform_keys(&:to_s) })
223
+ "filters" => filters, "relaxed_labels" => relaxed_labels,
224
+ "thresholds" => Truffler.config.smart_thresholds.transform_keys(&:to_s) })
218
225
  end
219
226
 
220
227
  # Appends one chunk's `[[id, score], ...]`, sorted by score within the
@@ -281,7 +288,7 @@ module Truffler
281
288
  explicit_action: explicit_action, buckets: found, pending: BUCKETS.index_with { active? },
282
289
  collapsed: collapsed_by_default, no_strong_matches: current == :complete && found[:strong].empty?,
283
290
  promoted_ids: (found[:strong] + found[:possible]).map { |entry| entry[:id] }, applied_filters: applied_filters,
284
- sections: { provider: provider_section.to_h.symbolize_keys } }
291
+ relaxed_labels: relaxed_labels, sections: { provider: provider_section.to_h.symbolize_keys } }
285
292
  end
286
293
 
287
294
  def as_json(*)
@@ -26,17 +26,19 @@ module Truffler
26
26
  end
27
27
 
28
28
  def call
29
- result = keystroke.call
30
- local_ids = result.ids
31
- run = Run.create(@model, query: @query.raw.strip, tenant_key: keystroke.tenant_key, user_key: keystroke.user_key,
32
- surface: keystroke.surface, suppressed: @suppressed, pool_ids: pool(result, local_ids), local_ids: local_ids,
33
- local_weak: result.local_weak?, explicit_action: result.explicit_action, store: @store)
34
- previous = @store.supersede(run.record_type, run.tenant_key, run.user_key, run.surface, run.id)
35
- Run.load(previous, store: @store).cancel! if previous
36
- Instrumentation.instrument(:smart_search, run_id: run.id, record_type: run.record_type, tenant_key: run.tenant_key,
37
- surface: run.surface, candidate_count: run.pool_ids.size, local_count: local_ids.size)
38
- @dispatch.call(run)
39
- run
29
+ Current.scope do
30
+ result = keystroke.call
31
+ local_ids = result.ids
32
+ run = Run.create(@model, query: @query.raw.strip, tenant_key: keystroke.tenant_key, user_key: keystroke.user_key,
33
+ surface: keystroke.surface, suppressed: @suppressed, pool_ids: pool(result, local_ids), local_ids: local_ids,
34
+ local_weak: result.local_weak?, explicit_action: result.explicit_action, store: @store)
35
+ previous = @store.supersede(run.record_type, run.tenant_key, run.user_key, run.surface, run.id)
36
+ Run.load(previous, store: @store).cancel! if previous
37
+ Instrumentation.instrument(:smart_search, run_id: run.id, record_type: run.record_type, tenant_key: run.tenant_key,
38
+ surface: run.surface, candidate_count: run.pool_ids.size, local_count: local_ids.size)
39
+ @dispatch.call(run)
40
+ run
41
+ end
40
42
  end
41
43
 
42
44
  private
@@ -1,3 +1,3 @@
1
1
  module Truffler
2
- VERSION = "0.1.5"
2
+ VERSION = "0.1.6"
3
3
  end
@@ -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 backfill spend ledgers are keyed by: the fingerprints of the
43
+ # labels Jev is asked only. Supplied labels cost nothing, so changing one
44
+ # (an option added to a `from:` choice included) never starts a new
45
+ # ledger. Without supplied labels it equals `version`, so ledger rows
46
+ # written before 0.1.6 keep their key.
47
+ def ledger_version(tenant_key: nil, user_key: nil, all_users: false)
48
+ asked = labels_for(tenant_key: tenant_key, user_key: user_key, all_users: all_users).reject { |_, label| label.supplied? }
49
+ Canonical.digest(asked.transform_values { |label| fingerprint_of(label, tenant_key) })
50
+ end
51
+
42
52
  # The version query encodings are cached under: the labeling version plus
43
53
  # a digest of the wording only query encoding reads (label descriptions,
44
54
  # option descriptions and search texts), which labeling never sees for