truffler 0.1.4 → 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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +79 -12
  4. data/lib/generators/truffler/install/templates/migration.rb.tt +14 -11
  5. data/lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt +1 -1
  6. data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +25 -0
  7. data/lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt +32 -0
  8. data/lib/generators/truffler/upgrade/upgrade_generator.rb +49 -3
  9. data/lib/tasks/truffler.rake +28 -13
  10. data/lib/truffler/benchmark/runner.rb +1 -1
  11. data/lib/truffler/clients/evaluator.rb +44 -0
  12. data/lib/truffler/configuration.rb +12 -0
  13. data/lib/truffler/current.rb +30 -0
  14. data/lib/truffler/definition.rb +52 -0
  15. data/lib/truffler/embeddings/backfill.rb +40 -11
  16. data/lib/truffler/embeddings/label_vector.rb +1 -1
  17. data/lib/truffler/embeddings/neighbor_store.rb +33 -7
  18. data/lib/truffler/embeddings/vector_store.rb +9 -3
  19. data/lib/truffler/jobs/backfill_job.rb +14 -5
  20. data/lib/truffler/jobs/embed_job.rb +2 -0
  21. data/lib/truffler/jobs/label_flush_job.rb +4 -2
  22. data/lib/truffler/jobs/resume_job.rb +22 -9
  23. data/lib/truffler/label_definition.rb +17 -3
  24. data/lib/truffler/labeling/backfill.rb +115 -37
  25. data/lib/truffler/labeling/labeler.rb +58 -41
  26. data/lib/truffler/labeling/queue.rb +14 -9
  27. data/lib/truffler/labeling/supplied.rb +16 -5
  28. data/lib/truffler/lenses/backfill.rb +28 -10
  29. data/lib/truffler/model.rb +5 -0
  30. data/lib/truffler/providers/backup.rb +1 -1
  31. data/lib/truffler/query_encoding/encoder.rb +75 -49
  32. data/lib/truffler/query_encoding/present_options.rb +54 -0
  33. data/lib/truffler/records/backfill_spend.rb +60 -6
  34. data/lib/truffler/redaction.rb +1 -1
  35. data/lib/truffler/search/encoding.rb +41 -10
  36. data/lib/truffler/search/encoding_cache.rb +12 -1
  37. data/lib/truffler/search/filler.rb +31 -6
  38. data/lib/truffler/search/keystroke.rb +60 -29
  39. data/lib/truffler/search/relaxation.rb +95 -0
  40. data/lib/truffler/search/result.rb +21 -5
  41. data/lib/truffler/search/sql.rb +81 -10
  42. data/lib/truffler/smart_search/dispatcher.rb +36 -24
  43. data/lib/truffler/smart_search/reranker.rb +7 -5
  44. data/lib/truffler/smart_search/run.rb +10 -3
  45. data/lib/truffler/smart_search/starter.rb +13 -11
  46. data/lib/truffler/version.rb +1 -1
  47. data/lib/truffler/vocabulary.rb +10 -0
  48. metadata +7 -1
@@ -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,9 +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
- 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, relaxed_labels: [])
17
+ @local_weak = local_weak
18
+ @relaxed_labels = relaxed_labels
16
19
  @records = records
17
20
  @query = query
18
21
  @encoding = encoding
@@ -29,12 +32,25 @@ module Truffler
29
32
  records.map(&:id)
30
33
  end
31
34
 
32
- # Applied filters, then boosts, then the time range, as `{key:, label:, kind:, name:}`.
35
+ # Whether the local list is too weak to stand alone, which starts the
36
+ # backup provider on the explicit action. An `:encoding_pending` row on
37
+ # a model with a `keyword` source invites Smart search without making
38
+ # the list weak.
39
+ def local_weak?
40
+ @local_weak.nil? ? invite_row.present? : @local_weak
41
+ end
42
+
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".
33
48
  def chips
34
49
  return [] unless encoding
35
50
 
36
- filters = encoding.filters.keys.map { |key| chip(key, :filter) }
37
- 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) }
38
54
  time = encoding.time
39
55
  time ? chips + [ { key: TimeRange.key, label: TimeRange.key, kind: :time, name: time.name } ] : chips
40
56
  end
@@ -3,15 +3,24 @@ module Truffler
3
3
  # Builds the one keystroke query of KTD8, scored per KTD20:
4
4
  #
5
5
  # w_label * SUM(weight * value) over the intent's nonzero label keys
6
- # + w_text * text similarity (inline SQL, or the store's top-K CASE)
6
+ # + w_text * text similarity (a top-K join, inline SQL, or the store's top-K CASE)
7
7
  # + w_keyword * keyword hit + w_exact * exact-source hit
8
8
  # + SOFT_KEYWORD * w_keyword * soft keyword hit
9
9
  #
10
10
  # A weighted dot product, not cosine: cosine would divide out magnitude
11
11
  # and let a record high on unrelated labels outrank the one the query
12
12
  # asked for. Hard filters are EXISTS subqueries that run before scoring.
13
+ #
14
+ # At scale: when every record past the filters is a candidate (label-only
15
+ # and filtered searches), label scores come from one grouped aggregate
16
+ # LEFT JOINed on record_id instead of a subquery per row. A store with
17
+ # `neighbors_sql` (NeighborStore on Postgres) is LEFT JOINed the same
18
+ # way, so text similarity is read from the tenant's top-K. Relation
19
+ # sources run once, as `id = ANY(ARRAY(subquery))` on Postgres.
13
20
  class Sql
14
21
  LABELS = "truffler_labels".freeze
22
+ LABEL_SCORES = "truffler_label_scores".freeze
23
+ NEIGHBORS = "truffler_neighbors".freeze
15
24
  # Share of the keyword weight a soft keyword hit adds (see Encoding).
16
25
  SOFT_KEYWORD = 0.25
17
26
 
@@ -38,7 +47,8 @@ module Truffler
38
47
  # filter the filter decides membership and text matches only rank.
39
48
  def candidates(scope)
40
49
  base = base(scope)
41
- return base if label_only? || encoding.filters.any?
50
+ base = base.joins(Arel.sql(neighbors_join_sql)) if neighbors_sql
51
+ return base if every_base_record?
42
52
 
43
53
  conditions = [ keyword_sql, exact_sql, (text_candidate_sql if text_score_sql) ].compact
44
54
  base.where(Arel.sql(conditions.any? ? conditions.map { |condition| "(#{condition})" }.join(" OR ") : "1 = 0"))
@@ -46,19 +56,21 @@ module Truffler
46
56
 
47
57
  def relation(scope, limit: nil)
48
58
  relation = candidates(scope)
59
+ relation = relation.joins(Arel.sql(label_scores_join_sql)) if grouped_label_scores?
49
60
  relation = relation.select(Arel.sql("#{table}.*")) if relation.select_values.empty?
50
61
  relation = relation.select(*score_columns.map { |name, sql| Arel.sql("(#{sql}) AS #{name}") })
51
62
  relation = relation.reorder(*ordering)
52
63
  limit ? relation.limit(limit) : relation
53
64
  end
54
65
 
66
+ # The label term: a per-candidate subquery when sources narrow the
67
+ # candidates, else the grouped join's score.
55
68
  def label_score_sql
56
- intent = encoding.intent_vector
57
- return if intent.empty?
69
+ return if encoding.intent_vector.empty?
70
+ return "COALESCE(#{connection.quote_table_name(LABEL_SCORES)}.score, 0.0)" if grouped_label_scores?
58
71
 
59
- cases = intent.map { |key, weight| "WHEN #{quote(key)} THEN #{Float(weight)} * #{label_column('value')}" }.join(" ")
60
- "COALESCE((SELECT SUM(CASE #{label_column('label_key')} #{cases} ELSE 0.0 END) FROM #{quoted_labels} " \
61
- "WHERE #{label_scope_sql} AND #{label_column('label_key')} IN (#{intent.keys.map { |key| quote(key) }.join(', ')})), 0.0)"
72
+ "COALESCE((SELECT SUM(#{label_case_sql}) FROM #{quoted_labels} " \
73
+ "WHERE #{label_scope_sql} AND #{label_keys_sql}), 0.0)"
62
74
  end
63
75
 
64
76
  def sources
@@ -67,7 +79,23 @@ module Truffler
67
79
  end
68
80
 
69
81
  def keywords
70
- encoding.keywords(query)
82
+ @keywords ||= encoding.keywords(query, keep: -> { Filler.label_words(definition, tenant_key) })
83
+ end
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?
71
99
  end
72
100
 
73
101
  private
@@ -115,6 +143,41 @@ module Truffler
115
143
  range.to ? scope.where(arrived_at.lt(range.to)) : scope
116
144
  end
117
145
 
146
+ def label_case_sql
147
+ cases = encoding.intent_vector.map { |key, weight| "WHEN #{quote(key)} THEN #{Float(weight)} * #{label_column('value')}" }
148
+ "CASE #{label_column('label_key')} #{cases.join(' ')} ELSE 0.0 END"
149
+ end
150
+
151
+ def label_keys_sql
152
+ "#{label_column('label_key')} IN (#{encoding.intent_vector.keys.map { |key| quote(key) }.join(', ')})"
153
+ end
154
+
155
+ def grouped_label_scores?
156
+ encoding.intent_vector.any? && every_base_record?
157
+ end
158
+
159
+ # One aggregate over the tenant's rows for the intent's keys, which
160
+ # `index_truffler_labels_for_search` (with INCLUDE (record_id)) serves
161
+ # as an index-only scan.
162
+ def label_scores_join_sql
163
+ tenant = definition.scoped? ? " AND #{label_column('tenant_key')} = #{quote(tenant_key)}" : ""
164
+ scores = connection.quote_table_name(LABEL_SCORES)
165
+ "LEFT JOIN (SELECT #{label_column('record_id')} AS record_id, SUM(#{label_case_sql}) AS score FROM #{quoted_labels} " \
166
+ "WHERE #{label_column('record_type')} = #{quote(model.polymorphic_name)}#{tenant} AND #{label_keys_sql} " \
167
+ "GROUP BY #{label_column('record_id')}) #{scores} ON #{scores}.record_id = #{primary_key}"
168
+ end
169
+
170
+ def neighbors_sql
171
+ return @neighbors_sql if defined?(@neighbors_sql)
172
+
173
+ @neighbors_sql = (store.neighbors_sql(model, tenant_key: tenant_key, vector: vector) if vector && store.respond_to?(:neighbors_sql))
174
+ end
175
+
176
+ def neighbors_join_sql
177
+ neighbors = connection.quote_table_name(NEIGHBORS)
178
+ "LEFT JOIN (#{neighbors_sql}) #{neighbors} ON #{neighbors}.record_id = #{primary_key}"
179
+ end
180
+
118
181
  def label_filter_sql(key, threshold)
119
182
  "EXISTS (SELECT 1 FROM #{quoted_labels} WHERE #{label_scope_sql} AND #{label_column('label_key')} = #{quote(key)} " \
120
183
  "AND #{label_column('value')} >= #{Float(threshold)})"
@@ -171,7 +234,10 @@ module Truffler
171
234
  def text_score_sql
172
235
  return @text_score_sql if defined?(@text_score_sql)
173
236
 
174
- @text_score_sql = (store.similarity_sql(model, tenant_key: tenant_key, vector: vector).to_s if vector && store)
237
+ @text_score_sql =
238
+ if neighbors_sql then "COALESCE(#{connection.quote_table_name(NEIGHBORS)}.similarity, 0.0)"
239
+ elsif vector && store then store.similarity_sql(model, tenant_key: tenant_key, vector: vector).to_s
240
+ end
175
241
  end
176
242
 
177
243
  def text_candidate_sql
@@ -186,9 +252,14 @@ module Truffler
186
252
  definition.scoped? ? model.where(definition.tenant_column => tenant_key) : model.all
187
253
  end
188
254
 
255
+ # An id list is the fast path. A relation runs once as an array on
256
+ # Postgres, where `IN (subquery)` becomes a hashed filter over every
257
+ # tenant row.
189
258
  def membership_sql(result)
190
259
  case result
191
- when ActiveRecord::Relation then "#{primary_key} IN (#{result.reselect(result.klass.arel_table[result.klass.primary_key]).to_sql})"
260
+ when ActiveRecord::Relation
261
+ subquery = result.reselect(result.klass.arel_table[result.klass.primary_key]).to_sql
262
+ connection.adapter_name.match?(/postg/i) ? "#{primary_key} = ANY(ARRAY(#{subquery}))" : "#{primary_key} IN (#{subquery})"
192
263
  when nil then nil
193
264
  else
194
265
  ids = Array(result)
@@ -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
@@ -64,24 +67,33 @@ module Truffler
64
67
  if @encodings.encoded?(key) then @encodings.read_encoding(key, query)
65
68
  elsif @encodings.in_flight?(key) then @encoder.await(key, deadline: @deadline, query: query)
66
69
  end
67
- encoding&.without(run.suppressed)
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.invite_row.present?, 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.4"
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
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.4
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kieran Klaassen
@@ -202,6 +202,8 @@ files:
202
202
  - lib/generators/truffler/install/templates/initializer.rb.tt
203
203
  - lib/generators/truffler/install/templates/migration.rb.tt
204
204
  - lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt
205
+ - lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt
206
+ - lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt
205
207
  - lib/generators/truffler/upgrade/upgrade_generator.rb
206
208
  - lib/tasks/truffler.rake
207
209
  - lib/tasks/truffler/bench.rake
@@ -224,9 +226,11 @@ files:
224
226
  - lib/truffler/clients/base.rb
225
227
  - lib/truffler/clients/callable.rb
226
228
  - lib/truffler/clients/cassette.rb
229
+ - lib/truffler/clients/evaluator.rb
227
230
  - lib/truffler/clients/fake.rb
228
231
  - lib/truffler/clients/ruby_llm_typesafe.rb
229
232
  - lib/truffler/configuration.rb
233
+ - lib/truffler/current.rb
230
234
  - lib/truffler/definition.rb
231
235
  - lib/truffler/embeddings.rb
232
236
  - lib/truffler/embeddings/backfill.rb
@@ -286,6 +290,7 @@ files:
286
290
  - lib/truffler/query_encoding/cache.rb
287
291
  - lib/truffler/query_encoding/encoder.rb
288
292
  - lib/truffler/query_encoding/prefetch.rb
293
+ - lib/truffler/query_encoding/present_options.rb
289
294
  - lib/truffler/questions.rb
290
295
  - lib/truffler/railtie.rb
291
296
  - lib/truffler/records/backfill_spend.rb
@@ -300,6 +305,7 @@ files:
300
305
  - lib/truffler/search/filler.rb
301
306
  - lib/truffler/search/keystroke.rb
302
307
  - lib/truffler/search/query.rb
308
+ - lib/truffler/search/relaxation.rb
303
309
  - lib/truffler/search/result.rb
304
310
  - lib/truffler/search/sql.rb
305
311
  - lib/truffler/search/time_phrase.rb