truffler 0.1.3 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +77 -10
- data/bench/cassettes/1037e0b63ba790c02d14d0ddfc18b68151c11d03ce88d764658044b6bab2e55b.json +1 -0
- data/bench/cassettes/19bba849a846d30a69dc385a337d67704fbe5194506fd3c914d3270d86495fde.json +1 -0
- data/bench/cassettes/3768847dc57df8bf2328771e7763c0b8bd013a167244e7849967dac144420601.json +1 -0
- data/lib/generators/truffler/install/templates/migration.rb.tt +6 -3
- data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +25 -0
- data/lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt +32 -0
- data/lib/generators/truffler/upgrade/upgrade_generator.rb +49 -3
- data/lib/tasks/truffler.rake +38 -13
- data/lib/truffler/benchmark/runner.rb +1 -1
- data/lib/truffler/clients/evaluator.rb +44 -0
- data/lib/truffler/configuration.rb +12 -0
- data/lib/truffler/definition.rb +52 -0
- data/lib/truffler/embeddings/backfill.rb +40 -11
- data/lib/truffler/embeddings/label_vector.rb +1 -1
- data/lib/truffler/embeddings/neighbor_store.rb +33 -7
- data/lib/truffler/embeddings/vector_store.rb +9 -3
- data/lib/truffler/jobs/backfill_job.rb +14 -5
- data/lib/truffler/jobs/embed_job.rb +2 -0
- data/lib/truffler/jobs/label_flush_job.rb +4 -2
- data/lib/truffler/jobs/resume_job.rb +22 -9
- data/lib/truffler/label_definition.rb +55 -12
- data/lib/truffler/labeling/backfill.rb +100 -37
- data/lib/truffler/labeling/labeler.rb +20 -5
- data/lib/truffler/labeling/queue.rb +14 -9
- data/lib/truffler/labeling/supplied.rb +16 -5
- data/lib/truffler/lenses/backfill.rb +28 -10
- data/lib/truffler/lenses/lens_label.rb +7 -0
- data/lib/truffler/model.rb +5 -0
- data/lib/truffler/providers/backup.rb +1 -1
- data/lib/truffler/query_encoding/encoder.rb +56 -39
- data/lib/truffler/records/backfill_spend.rb +30 -6
- data/lib/truffler/redaction.rb +1 -1
- data/lib/truffler/search/encoding.rb +50 -12
- data/lib/truffler/search/encoding_cache.rb +4 -3
- data/lib/truffler/search/filler.rb +73 -0
- data/lib/truffler/search/keystroke.rb +29 -15
- data/lib/truffler/search/result.rb +11 -1
- data/lib/truffler/search/sql.rb +93 -15
- data/lib/truffler/smart_search/dispatcher.rb +1 -1
- data/lib/truffler/smart_search/starter.rb +1 -1
- data/lib/truffler/version.rb +1 -1
- data/lib/truffler/vocabulary.rb +13 -3
- metadata +8 -1
data/lib/truffler/definition.rb
CHANGED
|
@@ -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
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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).
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
|
|
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:
|
|
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
|
|
83
|
+
def dialect(model)
|
|
61
84
|
@dialect ||= self.class.dialect(model.connection)
|
|
62
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
24
|
-
|
|
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,
|
|
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
|
|
7
|
-
# rows waiting at backfill priority.
|
|
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
|
|
10
|
-
# embedder outage cannot pile duplicate
|
|
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.
|
|
40
|
-
|
|
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)
|
|
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,9 +1,13 @@
|
|
|
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
|
|
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
|
-
# (`{}` or nil) simply does not have the label.
|
|
8
|
+
# (`{}` or nil) simply does not have the label. An option's value is its
|
|
9
|
+
# description, or `{ description:, search: }` to give query encoding a
|
|
10
|
+
# short search text apart from the classifier description.
|
|
7
11
|
#
|
|
8
12
|
# A label with `from:` is supplied by the host: its answer is read from the
|
|
9
13
|
# record in the shape Jev answers normalize to and Jev is never asked. Its
|
|
@@ -13,6 +17,7 @@ module Truffler
|
|
|
13
17
|
class LabelDefinition
|
|
14
18
|
TYPES = %i[noul choice score].freeze
|
|
15
19
|
KEY = /\A[a-z][a-z0-9_]*\z/
|
|
20
|
+
OPTION_FIELDS = %i[description search].freeze
|
|
16
21
|
|
|
17
22
|
attr_reader :key, :type, :instructions, :filter_at, :boost, :watch, :version
|
|
18
23
|
# Intent weight a filter decision adds to the KTD20 query vector (default 0).
|
|
@@ -49,13 +54,23 @@ module Truffler
|
|
|
49
54
|
@options.respond_to?(:call)
|
|
50
55
|
end
|
|
51
56
|
|
|
57
|
+
# {option => description}: the classifier wording Jev labels with. An
|
|
58
|
+
# option given as `{ description:, search: }` contributes its description.
|
|
52
59
|
def options(tenant_key = nil)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
options = options.to_h { |option, description| [ option.to_s, description ] }
|
|
56
|
-
raise DefinitionError, "#{key}: the option name #{NO_OPTION} is reserved" if options.key?(NO_OPTION)
|
|
60
|
+
option_entries(tenant_key).transform_values { |entry| entry[:description] }
|
|
61
|
+
end
|
|
57
62
|
|
|
58
|
-
|
|
63
|
+
# {option => name} for query words: the option's short search text, else
|
|
64
|
+
# its description; options with neither are left out.
|
|
65
|
+
def option_names(tenant_key = nil)
|
|
66
|
+
option_entries(tenant_key).transform_values { |entry| entry[:search].presence || entry[:description] }.compact
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# What query encoding reads beyond the labeling fingerprint: descriptions
|
|
70
|
+
# and search texts. Digested into the encoding cache key only, so
|
|
71
|
+
# rewording never stales stored labels.
|
|
72
|
+
def encoding_wording(tenant_key = nil)
|
|
73
|
+
{ description: description, options: (option_entries(tenant_key) if type == :choice) }
|
|
59
74
|
end
|
|
60
75
|
|
|
61
76
|
# False for a per-tenant choice with no options for this tenant.
|
|
@@ -75,11 +90,11 @@ module Truffler
|
|
|
75
90
|
end
|
|
76
91
|
|
|
77
92
|
# Stored supplied values change with their shape and version, never with
|
|
78
|
-
# the Jev model
|
|
79
|
-
#
|
|
93
|
+
# the Jev model or the wording: descriptions and search texts reach only
|
|
94
|
+
# query encoding (see encoding_wording).
|
|
80
95
|
def supplied_fingerprint(tenant_key = nil)
|
|
81
|
-
Canonical.digest(supplied: true, type: type, options: (options(tenant_key) if type == :choice),
|
|
82
|
-
levels: (levels if type == :score),
|
|
96
|
+
Canonical.digest(supplied: true, type: type, options: (options(tenant_key).keys if type == :choice),
|
|
97
|
+
levels: (levels if type == :score), version: version)
|
|
83
98
|
end
|
|
84
99
|
|
|
85
100
|
# {storage_key => value} read from the record, or nil when the host has
|
|
@@ -91,10 +106,19 @@ module Truffler
|
|
|
91
106
|
case type
|
|
92
107
|
when :noul then { key => probability(value) }
|
|
93
108
|
when :score then { key => level(value) }
|
|
94
|
-
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))
|
|
95
110
|
end
|
|
96
111
|
end
|
|
97
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
|
+
|
|
98
122
|
def storage_keys(tenant_key = nil)
|
|
99
123
|
type == :choice ? options(tenant_key).keys.map { |option| "#{key}:#{option}" } : [ key ]
|
|
100
124
|
end
|
|
@@ -106,6 +130,25 @@ module Truffler
|
|
|
106
130
|
|
|
107
131
|
private
|
|
108
132
|
|
|
133
|
+
def option_entries(tenant_key)
|
|
134
|
+
options = per_tenant? ? @options.call(tenant_key) : @options
|
|
135
|
+
options = Array(options).to_h { |option| [ option, nil ] } unless options.is_a?(Hash)
|
|
136
|
+
options = options.to_h { |option, value| [ option.to_s, option_entry(option, value) ] }
|
|
137
|
+
raise DefinitionError, "#{key}: the option name #{NO_OPTION} is reserved" if options.key?(NO_OPTION)
|
|
138
|
+
|
|
139
|
+
options
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def option_entry(option, value)
|
|
143
|
+
return { description: value, search: nil } unless value.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
entry = value.to_h.symbolize_keys
|
|
146
|
+
unknown = entry.keys - OPTION_FIELDS
|
|
147
|
+
raise DefinitionError, "#{key}: option #{option} takes description: and search:, not #{unknown.join(', ')}" if unknown.any?
|
|
148
|
+
|
|
149
|
+
{ description: entry[:description], search: entry[:search] }
|
|
150
|
+
end
|
|
151
|
+
|
|
109
152
|
def levels
|
|
110
153
|
Array(@legend).size
|
|
111
154
|
end
|