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
@@ -19,6 +19,14 @@ module Truffler
19
19
  :smart_run_ttl, :smart_candidate_pool, :broadcaster
20
20
  # Generic nouns that are never required keywords on their own (Search::Filler).
21
21
  attr_accessor :filler_words
22
+ # ->(model, tenant_key) { true/false } for tenant-scoped models; nil indexes every tenant.
23
+ attr_accessor :tenant_enabled
24
+ # :tenant (a spend ledger per tenant for scoped models) or :app (one per model).
25
+ attr_accessor :backfill_spend_cap_scope
26
+ # Choice options below this probability store no row and read as 0.0 (nil stores every option).
27
+ attr_accessor :choice_min_probability
28
+ # Query encoding omits choice options the tenant has no label row for (QueryEncoding::PresentOptions).
29
+ attr_accessor :skip_empty_options
22
30
 
23
31
  def initialize(env: ENV)
24
32
  @model = "jev-latest"
@@ -53,6 +61,10 @@ module Truffler
53
61
  @smart_candidate_pool = 200
54
62
  @broadcaster = nil
55
63
  @filler_words = Search::Filler::DEFAULT_WORDS.dup
64
+ @tenant_enabled = nil
65
+ @backfill_spend_cap_scope = :tenant
66
+ @choice_min_probability = 0.05
67
+ @skip_empty_options = false
56
68
  end
57
69
 
58
70
  def client
@@ -0,0 +1,30 @@
1
+ module Truffler
2
+ # State scoped to one unit of work: a keystroke search, a query encoding,
3
+ # a labeler batch, a Smart run step. Inside `scope`, a per-tenant choice
4
+ # `options:` callable is resolved once per (label, tenant) instead of at
5
+ # every vocabulary, fingerprint, and wording read. Nested scopes share the
6
+ # outermost one, which clears everything when it ends, so nothing carries
7
+ # over to the next search or job. Outside a scope nothing is memoized.
8
+ module Current
9
+ KEY = :truffler_current_options
10
+
11
+ def self.scope
12
+ return yield if ActiveSupport::IsolatedExecutionState.key?(KEY)
13
+
14
+ ActiveSupport::IsolatedExecutionState[KEY] = {}
15
+ begin
16
+ yield
17
+ ensure
18
+ ActiveSupport::IsolatedExecutionState.delete(KEY)
19
+ end
20
+ end
21
+
22
+ def self.options(label, tenant_key)
23
+ memo = ActiveSupport::IsolatedExecutionState[KEY]
24
+ return yield unless memo
25
+
26
+ key = [ label, tenant_key&.to_s ]
27
+ memo.fetch(key) { memo[key] = yield }
28
+ end
29
+ end
30
+ end
@@ -116,6 +116,42 @@ module Truffler
116
116
  @weak_below || DEFAULT_WEAK_BELOW
117
117
  end
118
118
 
119
+ attr_accessor :index_if, :index_scope
120
+
121
+ # config.tenant_enabled is asked only for tenant-scoped models; an
122
+ # unscoped model is always enabled.
123
+ def tenant_enabled?(tenant_key)
124
+ check = Truffler.config.tenant_enabled
125
+ return true if check.nil? || !scoped?
126
+
127
+ check.call(model, tenant_key) ? true : false
128
+ end
129
+
130
+ # Whether the after-commit hooks, Queue, and EmbedJob handle this record:
131
+ # its tenant is enabled and `index_if` (when declared) accepts it.
132
+ def indexable?(record)
133
+ tenant_enabled?(tenant_key_for(record)) && (index_if.nil? || index_if.call(record) ? true : false)
134
+ end
135
+
136
+ # The relation the batch paths (backfills, sweeps) page over.
137
+ def index_relation(relation = model.all)
138
+ index_scope ? index_scope.call(relation) : relation
139
+ end
140
+
141
+ # The tenant a backfill spend ledger row belongs to: the tenant for
142
+ # scoped models under backfill_spend_cap_scope :tenant, else nil (app-wide).
143
+ def ledger_tenant(tenant_key)
144
+ tenant_key if scoped? && Truffler.config.backfill_spend_cap_scope.to_sym == :tenant
145
+ end
146
+
147
+ attr_writer :invite_on_pending_encoding
148
+
149
+ # Whether a model with a `keyword` source shows the Smart search row while
150
+ # the query's encoding is in flight (AE10). Default true.
151
+ def invite_on_pending_encoding
152
+ @invite_on_pending_encoding.nil? || @invite_on_pending_encoding
153
+ end
154
+
119
155
  private
120
156
 
121
157
  def table_available?
@@ -204,6 +240,22 @@ module Truffler
204
240
  def weak_below(count)
205
241
  @definition.weak_below = Integer(count)
206
242
  end
243
+
244
+ def index_if(callable)
245
+ raise DefinitionError, "index_if must be callable with the record" unless callable.respond_to?(:call)
246
+
247
+ @definition.index_if = callable
248
+ end
249
+
250
+ def index_scope(callable)
251
+ raise DefinitionError, "index_scope must be callable with a relation" unless callable.respond_to?(:call)
252
+
253
+ @definition.index_scope = callable
254
+ end
255
+
256
+ def invite_on_pending_encoding(enabled)
257
+ @definition.invite_on_pending_encoding = enabled ? true : false
258
+ end
207
259
  end
208
260
  end
209
261
  end
@@ -3,11 +3,15 @@ module Truffler
3
3
  # Finds records whose embedding is missing or was made under another
4
4
  # fingerprint (model, width, or fields changed) and enqueues EmbedJob for
5
5
  # them, newest first, `batch_size` jobs at a time behind an id cursor.
6
- # ResumeJob runs a bounded pass on every sweep; hosts call `enqueue` with
7
- # no limit after enabling embeddings or changing the model, width, or
8
- # fields.
6
+ # It pages over the definition's index_scope, one tenant at a time when
7
+ # given `tenant_key:`, skipping disabled tenants, with a NOT EXISTS
8
+ # anti-join against truffler_record_states so each page stops at its
9
+ # limit instead of materializing every current id. ResumeJob runs a
10
+ # bounded pass on every sweep; hosts call `enqueue` with no limit after
11
+ # enabling embeddings or changing the model, width, or fields.
9
12
  class Backfill
10
13
  BATCH_SIZE = 1_000
14
+ STATES = Records::RecordState.table_name
11
15
 
12
16
  attr_reader :model
13
17
 
@@ -15,25 +19,30 @@ module Truffler
15
19
  @model = model
16
20
  end
17
21
 
18
- def stale_ids(limit: nil, before: nil)
22
+ def stale_ids(limit: nil, before: nil, tenant_key: nil)
19
23
  definition = model.truffler_definition
20
24
  return [] unless Embeddings.managed?(definition)
25
+ return [] if tenant_key && !definition.tenant_enabled?(tenant_key)
21
26
 
22
- current = Records::RecordState.for_model(model).where(embedding_fingerprint: Embeddings.fingerprint(definition))
23
- .where.not(embedded_at: nil).select(:record_id)
24
- scope = model.where.not(model.primary_key => current)
25
- scope = scope.where(model.primary_key => ...before) if before
26
- scope.order(model.primary_key => :desc).limit(limit).pluck(model.primary_key)
27
+ pk = model.arel_table[model.primary_key]
28
+ scope = definition.index_relation(model.all).where(current_embedding_missing_sql(definition))
29
+ if tenant_key && definition.scoped?
30
+ scope = scope.where(definition.tenant_column => tenant_key)
31
+ elsif definition.scoped? && Truffler.config.tenant_enabled
32
+ scope = scope.where(definition.tenant_column => tenant_keys)
33
+ end
34
+ scope = scope.where(pk.lt(before)) if before
35
+ scope.reorder(pk.desc).limit(limit).pluck(pk)
27
36
  end
28
37
 
29
- def enqueue(limit: nil, batch_size: BATCH_SIZE)
38
+ def enqueue(limit: nil, batch_size: BATCH_SIZE, tenant_key: nil)
30
39
  count = 0
31
40
  cursor = nil
32
41
  loop do
33
42
  take = limit ? [ batch_size, limit - count ].min : batch_size
34
43
  break unless take.positive?
35
44
 
36
- ids = stale_ids(limit: take, before: cursor)
45
+ ids = stale_ids(limit: take, before: cursor, tenant_key: tenant_key)
37
46
  break if ids.empty?
38
47
 
39
48
  ActiveJob.perform_all_later(ids.map { |id| Jobs::EmbedJob.new(model.polymorphic_name, id) })
@@ -43,6 +52,26 @@ module Truffler
43
52
  end
44
53
  count
45
54
  end
55
+
56
+ # The enabled tenants with records in the index scope, for per-tenant sweeps.
57
+ def tenant_keys
58
+ definition = model.truffler_definition
59
+ return [ nil ] unless definition.scoped?
60
+
61
+ definition.index_relation(model.all).reorder(nil).distinct.pluck(definition.tenant_column)
62
+ .map(&:to_s).select { |key| definition.tenant_enabled?(key) }.sort
63
+ end
64
+
65
+ private
66
+
67
+ def current_embedding_missing_sql(definition)
68
+ pk = "#{model.quoted_table_name}.#{model.connection.quote_column_name(model.primary_key)}"
69
+ ActiveRecord::Base.sanitize_sql_array([
70
+ "NOT EXISTS (SELECT 1 FROM #{STATES} WHERE #{STATES}.record_type = ? AND #{STATES}.record_id = #{pk} " \
71
+ "AND #{STATES}.embedding_fingerprint = ? AND #{STATES}.embedded_at IS NOT NULL)",
72
+ model.polymorphic_name, Embeddings.fingerprint(definition)
73
+ ])
74
+ end
46
75
  end
47
76
  end
48
77
  end
@@ -33,7 +33,7 @@ module Truffler
33
33
  label_vocabulary_version: version, created_at: now, updated_at: now }
34
34
  end
35
35
  Records::Embedding.upsert_all(rows, unique_by: %i[record_type record_id],
36
- update_only: %i[tenant_key label_vector label_vocabulary_version updated_at])
36
+ update_only: %i[tenant_key label_vector label_vocabulary_version])
37
37
  rows.size
38
38
  end
39
39
 
@@ -2,9 +2,13 @@ module Truffler
2
2
  module Embeddings
3
3
  # Cosine distance computed by the database: pgvector's `<=>` on a vector
4
4
  # column, or sqlite-vec's `vec_distance_cosine` on float32 blobs (the
5
- # host loads the extension). Because similarity is plain SQL, search can
6
- # score text inline in its one query as an exact scan of the tenant's
7
- # rows; an approximate (HNSW) index is an opt-in host migration.
5
+ # host loads the extension).
6
+ #
7
+ # On Postgres, search reads text similarity from the tenant's top `k`
8
+ # neighbors (`neighbors_sql`, one `ORDER BY embedding <=> q LIMIT k`
9
+ # that an HNSW index can serve), so records outside the top K score no
10
+ # text similarity. Pass `top_k: false` for an exact inline similarity per
11
+ # row, the sqlite-vec default; `top_k: true` uses the join there too.
8
12
  class NeighborStore < VectorStore
9
13
  TABLE = "truffler_embeddings".freeze
10
14
 
@@ -35,16 +39,31 @@ module Truffler
35
39
  end
36
40
  end
37
41
 
38
- def initialize(dialect: nil)
42
+ attr_reader :k
43
+
44
+ def initialize(dialect: nil, k: DEFAULT_K, top_k: nil)
39
45
  @dialect = dialect
46
+ @k = Integer(k)
47
+ @top_k = top_k
40
48
  end
41
49
 
42
- def nearest(model, tenant_key:, vector:, k: DEFAULT_K)
50
+ def nearest(model, tenant_key:, vector:, k: self.k)
43
51
  distance = distance_sql(model, "#{TABLE}.embedding", vector)
44
52
  embeddings(model, tenant_key, vector.size).order(Arel.sql(distance)).limit(k)
45
53
  .pluck(:record_id, Arel.sql("1 - #{distance}")).map { |id, similarity| [ id, similarity.to_f ] }
46
54
  end
47
55
 
56
+ # The tenant's `k` nearest vectors as `(record_id, similarity)` rows,
57
+ # for search to LEFT JOIN on record_id; nil when this store scores
58
+ # inline instead.
59
+ def neighbors_sql(model, tenant_key:, vector:, k: self.k)
60
+ return unless top_k?(model)
61
+
62
+ distance = distance_sql(model, "#{TABLE}.embedding", vector)
63
+ embeddings(model, tenant_key, vector.size).reorder(Arel.sql(distance)).limit(k)
64
+ .select(Arel.sql("#{TABLE}.record_id AS record_id"), Arel.sql("1 - #{distance} AS similarity")).to_sql
65
+ end
66
+
48
67
  def similarity_sql(model, tenant_key:, vector:, k: nil)
49
68
  similarity = "1 - #{distance_sql(model, "#{TABLE}.embedding", vector)}"
50
69
  subquery = embeddings(model, tenant_key, vector.size).where("#{TABLE}.record_id = #{primary_key_sql(model)}")
@@ -55,11 +74,18 @@ module Truffler
55
74
  true
56
75
  end
57
76
 
77
+ def top_k?(model)
78
+ @top_k.nil? ? dialect(model) == :postgres : @top_k
79
+ end
80
+
58
81
  private
59
82
 
60
- def distance_sql(model, column_sql, vector)
83
+ def dialect(model)
61
84
  @dialect ||= self.class.dialect(model.connection)
62
- self.class.distance_sql(@dialect, model.connection, column_sql, vector)
85
+ end
86
+
87
+ def distance_sql(model, column_sql, vector)
88
+ self.class.distance_sql(dialect(model), model.connection, column_sql, vector)
63
89
  end
64
90
  end
65
91
  end
@@ -5,7 +5,12 @@ module Truffler
5
5
  # `similarity_sql` returns a scalar SQL expression over the model's table
6
6
  # that search can put in its SELECT or ORDER BY: stores that compute
7
7
  # similarity in the database (`inline_sql?`) scan every row exactly, and
8
- # the rest fall back to a CASE over the top-K neighbors.
8
+ # the rest fall back to a CASE over the top-K neighbors. A store may also
9
+ # answer `neighbors_sql` with a `(record_id, similarity)` subquery, which
10
+ # search LEFT JOINs instead (see NeighborStore).
11
+ #
12
+ # `config.vector_store` is one of ADAPTERS or a store instance, such as
13
+ # `NeighborStore.new(k: 500)` or a subclass of this class.
9
14
  class VectorStore
10
15
  ADAPTERS = %i[auto ruby neighbor].freeze
11
16
  DEFAULT_K = 200
@@ -14,12 +19,13 @@ module Truffler
14
19
  embeddings = model.truffler_definition.embeddings
15
20
  return unless embeddings
16
21
  return ColumnStore.new(embeddings[:column]) if embeddings.key?(:column)
22
+ return config.vector_store if config.vector_store.respond_to?(:similarity_sql)
17
23
 
18
24
  case config.vector_store&.to_sym
19
25
  when :ruby then RubyStore.new
20
26
  when :neighbor then NeighborStore.new
21
27
  when :auto then NeighborStore.available?(model.connection) ? NeighborStore.new : RubyStore.new
22
- else raise Error, "config.vector_store must be one of #{ADAPTERS.join(', ')}"
28
+ else raise Error, "config.vector_store must be one of #{ADAPTERS.join(', ')} or a store instance"
23
29
  end
24
30
  end
25
31
 
@@ -58,7 +64,7 @@ module Truffler
58
64
  fingerprint: fingerprint, embedding: Records::Embedding.encode(vector), dimensions: vector.size,
59
65
  created_at: now, updated_at: now },
60
66
  unique_by: %i[record_type record_id],
61
- update_only: %i[tenant_key fingerprint embedding dimensions updated_at]
67
+ update_only: %i[tenant_key fingerprint embedding dimensions]
62
68
  )
63
69
  end
64
70
 
@@ -1,7 +1,9 @@
1
1
  module Truffler
2
2
  module Jobs
3
3
  # Backfills one model's stale, missing, failed, and demoted labels at
4
- # backfill priority. Arguments are the record type, the id cursor, the
4
+ # backfill priority, over one tenant when given `tenant_key:` (the
5
+ # tenant's own spend ledger) or else the whole model's indexed records.
6
+ # Arguments are the record type, the tenant key, the id cursor, the
5
7
  # spend so far, the cap, the retry attempt, and the count of budget
6
8
  # denials in a row, never record text. A budget denial reschedules the job
7
9
  # from its cursor after the same backoff a waiting Labeling::Backfill uses
@@ -15,16 +17,23 @@ module Truffler
15
17
 
16
18
  queue_as { Truffler.config.queue_name }
17
19
 
18
- def perform(record_type, cursor: nil, spent: 0.0, spend_cap: Truffler.config.backfill_spend_cap, max_pages: MAX_PAGES,
20
+ # The job's tenant keyword: the tenant for scoped models, none otherwise.
21
+ def self.tenant_argument(model, tenant_key)
22
+ model.truffler_definition.scoped? && !tenant_key.nil? ? { tenant_key: tenant_key.to_s } : {}
23
+ end
24
+
25
+ def perform(record_type, tenant_key: nil, cursor: nil, spent: 0.0, spend_cap: Truffler.config.backfill_spend_cap, max_pages: MAX_PAGES,
19
26
  attempt: 0, denials: 0)
20
27
  model = record_type.safe_constantize
21
28
  return unless model.respond_to?(:truffler_definition) && model.truffler_definition
22
29
 
23
- result = Labeling::Backfill.new(model, cursor: cursor, spent: spent, spend_cap: spend_cap).run(max_pages: max_pages)
24
- Instrumentation.instrument(:backfill, record_type: record_type, outcome: result.status,
30
+ result = Labeling::Backfill.new(model, tenant_key: tenant_key, cursor: cursor, spent: spent, spend_cap: spend_cap)
31
+ .run(max_pages: max_pages)
32
+ Instrumentation.instrument(:backfill, record_type: record_type, tenant_key: tenant_key, outcome: result.status,
25
33
  labeled_count: result.labeled, request_count: result.requests, cost: result.cost)
26
34
 
27
- follow_up = { cursor: result.cursor, spent: spent + result.cost, spend_cap: spend_cap, max_pages: max_pages }
35
+ follow_up = { **self.class.tenant_argument(model, tenant_key), cursor: result.cursor, spent: spent + result.cost,
36
+ spend_cap: spend_cap, max_pages: max_pages }
28
37
  case result.status
29
38
  when :budget_denied then retry_after_denial(record_type, follow_up, result, denials)
30
39
  when :paused then self.class.perform_later(record_type, **follow_up)
@@ -3,6 +3,7 @@ module Truffler
3
3
  # Embeds one record's declared fields and stores the vector. Arguments are
4
4
  # the record type and id only. On an embedder failure nothing is written,
5
5
  # `embedded_at` stays as it was, and the job retries; labels are untouched.
6
+ # A record the definition no longer indexes is skipped.
6
7
  class EmbedJob < ActiveJob::Base
7
8
  queue_as { Truffler.config.queue_name }
8
9
 
@@ -17,6 +18,7 @@ module Truffler
17
18
 
18
19
  record = model.find_by(model.primary_key => record_id)
19
20
  return Records::Embedding.where(record_type: record_type, record_id: record_id).delete_all unless record
21
+ return unless definition.indexable?(record)
20
22
 
21
23
  settings = definition.embeddings
22
24
  fingerprint = Embeddings.fingerprint(definition)
@@ -4,7 +4,7 @@ module Truffler
4
4
  # and tenant key only. On a Jev or budget failure the claimed rows go back
5
5
  # to pending (or failed after max_attempts) and the job retries. Rows over
6
6
  # the tenant's live cap drop to backfill priority and a delayed backfill
7
- # is scheduled for them.
7
+ # of that tenant is scheduled for them. A disabled tenant is left alone.
8
8
  class LabelFlushJob < ActiveJob::Base
9
9
  RETRYABLE = [ ClientError, BudgetExhausted, IncompleteAnswers ].freeze
10
10
 
@@ -18,6 +18,8 @@ module Truffler
18
18
 
19
19
  queue = Labeling::Queue.new(model)
20
20
  queue.clear_marker(tenant_key)
21
+ return unless model.truffler_definition.tenant_enabled?(tenant_key)
22
+
21
23
  states = queue.claim(tenant_key, priority: :live, limit: Truffler.config.batch_size)
22
24
  return if states.empty?
23
25
 
@@ -30,7 +32,7 @@ module Truffler
30
32
 
31
33
  if result.demoted
32
34
  queue.demote(states)
33
- queue.schedule_backfill
35
+ queue.schedule_backfill(tenant_key)
34
36
  end
35
37
  queue.schedule(tenant_key) if queue.pending?(tenant_key, priority: :live)
36
38
  end
@@ -3,12 +3,13 @@ module Truffler
3
3
  # A periodic sweep hosts schedule (every few minutes) so labeling resumes
4
4
  # after a Jev outage or a crashed worker. It returns failed rows and rows
5
5
  # stuck in labeling to pending, reschedules a flush for tenants whose live
6
- # rows have waited past `resume_pending_after`, and starts a backfill for
7
- # rows waiting at backfill priority. For models with gem-managed
6
+ # rows have waited past `resume_pending_after`, and starts a BackfillJob
7
+ # per tenant with rows waiting at backfill priority. Disabled tenants
8
+ # (config.tenant_enabled) get neither. For models with gem-managed
8
9
  # embeddings it also enqueues up to `embedding_sweep_limit` missing or
9
- # stale embeddings, at most once per `embedding_sweep_interval`, so an
10
- # embedder outage cannot pile duplicate jobs onto the queue. Pass a record
11
- # type to sweep one model.
10
+ # stale embeddings, tenant by tenant for scoped models, at most once per
11
+ # `embedding_sweep_interval`, so an embedder outage cannot pile duplicate
12
+ # jobs onto the queue. Pass a record type to sweep one model.
12
13
  class ResumeJob < ActiveJob::Base
13
14
  queue_as { Truffler.config.queue_name }
14
15
 
@@ -28,16 +29,20 @@ module Truffler
28
29
  requeued = requeue(states.where(status: "failed").or(states.where(status: "labeling").where(claimed_at: ...cutoff)))
29
30
  waiting = states.where(status: "pending").where(updated_at: ...cutoff)
30
31
 
32
+ definition = model.truffler_definition
31
33
  live = (requeued.select { |_, priority| priority == "live" }.map(&:first) +
32
- waiting.where(priority: "live").distinct.pluck(:tenant_key)).uniq
34
+ waiting.where(priority: "live").distinct.pluck(:tenant_key)).uniq.select { |key| definition.tenant_enabled?(key) }
33
35
  queue = Labeling::Queue.new(model)
34
36
  live.each do |tenant_key|
35
37
  queue.clear_marker(tenant_key)
36
38
  queue.schedule(tenant_key)
37
39
  end
38
40
 
39
- backfill = requeued.any? { |_, priority| priority == "backfill" } || waiting.exists?(priority: "backfill")
40
- BackfillJob.perform_later(model.polymorphic_name) if backfill
41
+ backfill = (requeued.select { |_, priority| priority == "backfill" }.map(&:first) +
42
+ waiting.where(priority: "backfill").distinct.pluck(:tenant_key)).uniq
43
+ backfill.select { |key| definition.tenant_enabled?(key) }.each do |tenant_key|
44
+ BackfillJob.perform_later(model.polymorphic_name, **BackfillJob.tenant_argument(model, tenant_key))
45
+ end
41
46
  sweep_embeddings(model)
42
47
  end
43
48
 
@@ -47,7 +52,15 @@ module Truffler
47
52
  marker = "truffler:embedding_sweep:#{model.polymorphic_name}"
48
53
  return unless Truffler.config.cache_store.write(marker, true, unless_exist: true, expires_in: embedding_sweep_interval)
49
54
 
50
- Embeddings::Backfill.new(model).enqueue(limit: embedding_sweep_limit)
55
+ backfill = Embeddings::Backfill.new(model)
56
+ return backfill.enqueue(limit: embedding_sweep_limit) unless model.truffler_definition.scoped?
57
+
58
+ remaining = embedding_sweep_limit
59
+ backfill.tenant_keys.each do |tenant_key|
60
+ break unless remaining.positive?
61
+
62
+ remaining -= backfill.enqueue(limit: remaining, tenant_key: tenant_key)
63
+ end
51
64
  end
52
65
 
53
66
  # Returns the distinct [tenant_key, priority] pairs it moved to pending.
@@ -1,7 +1,9 @@
1
1
  module Truffler
2
2
  # One typed label question. Nouls store their probability, scores their
3
3
  # normalized position, and choices one row per option ("label:option") with
4
- # that option's probability. Choice options may be a callable of the tenant
4
+ # that option's probability, for options at or above
5
+ # config.choice_min_probability plus the most likely option; a missing
6
+ # option row reads as 0.0. Choice options may be a callable of the tenant
5
7
  # key, which makes the vocabulary per-tenant; a tenant it gives no options
6
8
  # (`{}` or nil) simply does not have the label. An option's value is its
7
9
  # description, or `{ description:, search: }` to give query encoding a
@@ -104,10 +106,19 @@ module Truffler
104
106
  case type
105
107
  when :noul then { key => probability(value) }
106
108
  when :score then { key => level(value) }
107
- when :choice then choice_values(value, options(tenant_key).keys)
109
+ when :choice then self.class.sparse_choice(choice_values(value, options(tenant_key).keys))
108
110
  end
109
111
  end
110
112
 
113
+ # Drops choice option rows ({"label:option" => probability}) below
114
+ # config.choice_min_probability, always keeping the most likely option.
115
+ def self.sparse_choice(values, min: Truffler.config.choice_min_probability)
116
+ return values if min.nil? || values.empty?
117
+
118
+ top = values.max_by { |_, probability| probability.to_f }.first
119
+ values.select { |key, probability| key == top || probability.to_f >= min }
120
+ end
121
+
111
122
  def storage_keys(tenant_key = nil)
112
123
  type == :choice ? options(tenant_key).keys.map { |option| "#{key}:#{option}" } : [ key ]
113
124
  end
@@ -120,7 +131,10 @@ module Truffler
120
131
  private
121
132
 
122
133
  def option_entries(tenant_key)
123
- options = per_tenant? ? @options.call(tenant_key) : @options
134
+ per_tenant? ? Current.options(self, tenant_key) { build_option_entries(@options.call(tenant_key)).freeze } : build_option_entries(@options)
135
+ end
136
+
137
+ def build_option_entries(options)
124
138
  options = Array(options).to_h { |option| [ option, nil ] } unless options.is_a?(Hash)
125
139
  options = options.to_h { |option, value| [ option.to_s, option_entry(option, value) ] }
126
140
  raise DefinitionError, "#{key}: the option name #{NO_OPTION} is reserved" if options.key?(NO_OPTION)