truffler 0.1.0 → 0.1.2

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.
@@ -11,39 +11,84 @@ module Truffler
11
11
  # done, and records already current are skipped, so a rerun never asks
12
12
  # Jev about them again. A spend cap stops the run before a request would
13
13
  # exceed it; host-supplied labels cost nothing, so they are still written
14
- # once the cap is reached. A Jev error releases the claimed rows and ends
14
+ # once the cap is reached. The cap counts everything spent under the
15
+ # model's current app-wide vocabulary version, kept in the
16
+ # truffler_backfill_spends ledger, so reruns and overlapping jobs share
17
+ # it; without that table it falls back to this run plus `spent:`. A Jev error releases the claimed rows and ends
15
18
  # the run with `:client_error`, so the caller keeps the spend metered so far.
19
+ #
20
+ # A budget denial ends the run with `:budget_denied`, unless the run
21
+ # waits: then it backs off (see .backoff) and retries from the same
22
+ # cursor until it completes, reaches the spend cap, or runs out of
23
+ # `max_duration` seconds, which pauses it with the cursor to resume from.
16
24
  class Backfill
17
- Result = Data.define(:status, :labeled, :requests, :cost, :cursor)
25
+ Result = Data.define(:status, :labeled, :requests, :cost, :cursor, :retry_after)
26
+
27
+ INITIAL_BACKOFF = 1.0
28
+ MAX_BACKOFF = 30.0
29
+
30
+ class_attribute :sleeper, default: ->(seconds) { sleep(seconds) }
31
+ class_attribute :clock, default: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
18
32
 
19
33
  STATES = Records::RecordState.table_name
20
34
 
21
35
  class SpendCapReached < StandardError; end
22
36
 
23
37
  # Wraps the client to meter spend per request and refuse a request whose
24
- # estimated cost would push spend past the cap.
38
+ # estimated cost would push spend past the cap. With a ledger the
39
+ # estimate is reserved in SQL before the request and settled to the
40
+ # reported cost after it, so concurrent meters on one ledger never
41
+ # both take the last of the cap.
25
42
  class SpendMeter
26
- attr_reader :spent, :requests
43
+ attr_reader :requests, :cost
27
44
 
28
- def initialize(client, cap:, spent:, config: Truffler.config)
45
+ def initialize(client, cap:, spent:, ledger: nil, config: Truffler.config)
29
46
  @client = client
30
47
  @cap = cap
31
48
  @spent = spent.to_f
49
+ @ledger = ledger
32
50
  @requests = 0
51
+ @cost = 0.0
33
52
  @config = config
34
53
  end
35
54
 
36
- def ask(state:, questions:, **options)
37
- raise SpendCapReached if @cap && @spent + estimate(state, questions) > @cap
55
+ def spent
56
+ @ledger ? @ledger.total : @spent
57
+ end
38
58
 
39
- answers = @client.ask(state: state, questions: questions, **options)
40
- @requests += 1
41
- @spent += answers.usage&.cost.to_f
59
+ def ask(state:, questions:, **options)
60
+ estimate = estimate(state, questions)
61
+ reserve(estimate)
62
+ answers = nil
63
+ begin
64
+ answers = @client.ask(state: state, questions: questions, **options)
65
+ ensure
66
+ @ledger&.settle(-estimate, requests: 0) unless answers
67
+ end
68
+ record(answers.usage&.cost.to_f, estimate)
42
69
  answers
43
70
  end
44
71
 
45
72
  private
46
73
 
74
+ def record(cost, estimate)
75
+ @requests += 1
76
+ @cost += cost
77
+ if @ledger
78
+ @ledger.settle(cost - estimate)
79
+ else
80
+ @spent += cost
81
+ end
82
+ end
83
+
84
+ def reserve(estimate)
85
+ if @ledger
86
+ raise SpendCapReached unless @ledger.reserve(estimate, @cap)
87
+ elsif @cap && @spent + estimate > @cap
88
+ raise SpendCapReached
89
+ end
90
+ end
91
+
47
92
  def estimate(state, questions)
48
93
  @config.cost_for(Tokens.estimate({ state: state, questions: questions }))
49
94
  end
@@ -53,40 +98,72 @@ module Truffler
53
98
  new(model).status
54
99
  end
55
100
 
101
+ # The ledger row for the model's current vocabulary version, or nil
102
+ # when nothing was spent yet or the ledger table is missing.
103
+ def self.spend(model)
104
+ return unless Records::BackfillSpend.available?
105
+
106
+ Records::BackfillSpend.for_model(model).find_by(vocabulary_version: ledger_version(model))
107
+ end
108
+
109
+ # Zeroes the current vocabulary version's ledger in place, so a chain
110
+ # still running keeps its row and continues against the fresh total.
111
+ def self.reset_spend!(model)
112
+ return unless Records::BackfillSpend.available?
113
+
114
+ Records::BackfillSpend.for_model(model).where(vocabulary_version: ledger_version(model))
115
+ .update_all(spent_usd: 0.0, requests: 0, updated_at: Time.current)
116
+ end
117
+
118
+ def self.ledger_version(model)
119
+ model.truffler_definition.vocabulary.version(all_users: true)
120
+ end
121
+
122
+ # Seconds to wait after `denials` consecutive budget denials with no
123
+ # work in between: 1, 2, 4, ... capped at MAX_BACKOFF, or the budget's
124
+ # retry hint when that is longer.
125
+ def self.backoff(denials, retry_after = nil)
126
+ [ [ INITIAL_BACKOFF * (2**denials), MAX_BACKOFF ].min, retry_after.to_f ].max
127
+ end
128
+
56
129
  attr_reader :model, :batch_size, :page_size
57
130
 
58
131
  def initialize(model, spend_cap: Truffler.config.backfill_spend_cap, batch_size: Truffler.config.batch_size,
59
132
  page_size: nil, cursor: nil, spent: 0.0, client: Truffler.config.client, budget: Budget.new)
60
133
  @model = model
134
+ model.truffler_definition.validate_columns!
61
135
  @batch_size = batch_size
62
136
  @page_size = page_size || batch_size * 5
63
137
  @cursor = cursor
64
- @meter = SpendMeter.new(client, cap: spend_cap, spent: spent)
138
+ @spend_cap = spend_cap
139
+ @spent = spent
140
+ @client = client
65
141
  @budget = budget
66
142
  @versions = {}
67
143
  end
68
144
 
69
- def run(max_pages: nil)
145
+ # `progress` is called with the result so far and the delay before each
146
+ # wait; it carries counts, cost, and the cursor, never record text.
147
+ def run(max_pages: nil, wait: false, max_duration: nil, sleeper: self.class.sleeper, clock: self.class.clock,
148
+ progress: nil)
70
149
  @labeled = 0
71
- @started_spent = @meter.spent
72
- cursor = @cursor
73
- pages = 0
150
+ @started_cost = meter.cost
151
+ @pages = 0
152
+ deadline = max_duration && clock.call + max_duration
153
+ denials = 0
74
154
 
75
155
  loop do
76
- scanned, rows = page(cursor)
77
- return result(:complete, nil) if scanned.empty?
78
- return result(:paused, cursor) if max_pages && pages >= max_pages
156
+ before = [ @labeled, meter.requests ]
157
+ status = sweep(max_pages, deadline, clock)
158
+ return result(status) unless wait && status == :budget_denied
79
159
 
80
- rows.group_by(&:last).each do |tenant_key, tenant_rows|
81
- tenant_rows.map(&:first).each_slice(batch_size) do |ids|
82
- stop = label(ids, tenant_key)
83
- return result(stop, cursor) if stop
84
- end
85
- end
160
+ denials = 0 unless before == [ @labeled, meter.requests ]
161
+ delay = self.class.backoff(denials, @retry_after)
162
+ return result(:paused) if deadline && clock.call + delay > deadline
86
163
 
87
- cursor = scanned.last
88
- pages += 1
89
- return result(:complete, nil) if scanned.size < page_size
164
+ progress&.call(result(status), delay)
165
+ sleeper.call(delay)
166
+ denials += 1
90
167
  end
91
168
  end
92
169
 
@@ -117,13 +194,47 @@ module Truffler
117
194
  @queue ||= Queue.new(model)
118
195
  end
119
196
 
197
+ # Spend carried in with `spent:` is ignored when the ledger holds it.
198
+ def meter
199
+ @meter ||= begin
200
+ ledger = Records::BackfillSpend.ledger(model, version_for(nil)) if Records::BackfillSpend.available?
201
+ SpendMeter.new(@client, cap: @spend_cap, spent: ledger ? 0.0 : @spent, ledger: ledger)
202
+ end
203
+ end
204
+
120
205
  def version_for(tenant_key)
121
206
  @versions[tenant_key] ||= definition.vocabulary.version(tenant_key: tenant_key, all_users: true)
122
207
  end
123
208
 
124
- def result(status, cursor)
125
- Result.new(status: status, labeled: @labeled, requests: @meter.requests, cost: @meter.spent - @started_spent,
126
- cursor: cursor)
209
+ def result(status)
210
+ Result.new(status: status, labeled: @labeled, requests: meter.requests, cost: meter.cost - @started_cost,
211
+ cursor: @cursor, retry_after: (@retry_after if status == :budget_denied))
212
+ end
213
+
214
+ # Walks pages below @cursor until done or stopped, returning the status.
215
+ def sweep(max_pages, deadline, clock)
216
+ @retry_after = nil
217
+ loop do
218
+ scanned, rows = page(@cursor)
219
+ return complete if scanned.empty?
220
+ return :paused if (max_pages && @pages >= max_pages) || (deadline && clock.call >= deadline)
221
+
222
+ rows.group_by(&:last).each do |tenant_key, tenant_rows|
223
+ tenant_rows.map(&:first).each_slice(batch_size) do |ids|
224
+ stop = label(ids, tenant_key)
225
+ return stop if stop
226
+ end
227
+ end
228
+
229
+ @cursor = scanned.last
230
+ @pages += 1
231
+ return complete if scanned.size < page_size
232
+ end
233
+ end
234
+
235
+ def complete
236
+ @cursor = nil
237
+ :complete
127
238
  end
128
239
 
129
240
  # Returns the scanned ids (for the cursor) and the [id, tenant_key] rows
@@ -172,10 +283,14 @@ module Truffler
172
283
  return if claimed.empty?
173
284
 
174
285
  begin
175
- Labeler.new(model, client: @meter, budget: @budget).label(claimed, priority: :backfill)
176
- rescue BudgetExhausted, SpendCapReached => error
286
+ Labeler.new(model, client: meter, budget: @budget).label(claimed, priority: :backfill)
287
+ rescue SpendCapReached
288
+ queue.demote(claimed)
289
+ return :spend_cap_reached
290
+ rescue BudgetExhausted => error
177
291
  queue.demote(claimed)
178
- return error.is_a?(SpendCapReached) ? :spend_cap_reached : :budget_denied
292
+ @retry_after = error.retry_after
293
+ return :budget_denied
179
294
  rescue ClientError, IncompleteAnswers => error
180
295
  queue.release(claimed, error)
181
296
  return :client_error
@@ -14,6 +14,7 @@ module Truffler
14
14
 
15
15
  def initialize(model, client: Truffler.config.client, budget: Budget.new)
16
16
  @model = model
17
+ model.truffler_definition.validate_columns!
17
18
  @client = client
18
19
  @budget = budget
19
20
  end
@@ -49,7 +50,7 @@ module Truffler
49
50
  retry_failed_supplied(supplier.failed_ids, states_by_id)
50
51
  return Result.new(labeled: current.size, requests: index, cost: cost, demoted: true)
51
52
  end
52
- raise BudgetExhausted, "no Jev budget for #{priority} labeling" if decision.denied?
53
+ raise BudgetExhausted.new("no Jev budget for #{priority} labeling", retry_after: decision.retry_after) if decision.denied?
53
54
 
54
55
  answers = client.ask(state: request.state, questions: request.questions, priority: decision.priority)
55
56
  cost += answers.usage&.cost.to_f
@@ -10,6 +10,7 @@ module Truffler
10
10
 
11
11
  def initialize(model, config: Truffler.config)
12
12
  @model = model
13
+ model.truffler_definition.validate_columns!
13
14
  @config = config
14
15
  end
15
16
 
@@ -12,6 +12,7 @@ module Truffler
12
12
 
13
13
  def initialize(model)
14
14
  @model = model
15
+ model.truffler_definition.validate_columns!
15
16
  @failed_ids = Set.new
16
17
  end
17
18
 
@@ -84,7 +84,8 @@ module Truffler
84
84
  # regenerated.
85
85
  def vocabulary_for(model, scope, lens)
86
86
  definition = model.truffler_definition
87
- declared = definition.labels.to_h { |key, label| [ key, label.question(scope.tenant_key) ] }
87
+ declared = definition.labels.select { |_, label| label.available?(scope.tenant_key) }
88
+ .to_h { |key, label| [ key, label.question(scope.tenant_key) ] }
88
89
  visible = Lenses.visible_lenses(model, tenant_key: scope.tenant_key, user_digest: scope.user_digest)
89
90
  visible = visible.reject { |other| other.id == lens&.id }
90
91
  declared.merge(visible.each_with_object({}) { |other, all| all.merge!(other.storage_questions) })
@@ -62,8 +62,9 @@ module Truffler
62
62
  # outside the watched columns, e.g. from the job that classified it.
63
63
  def truffler_refresh_labels!
64
64
  definition = self.class.truffler_definition
65
- keys = definition.supplied_labels.map(&:key)
66
- Labeling::Supplied.new(self.class).write([ [ self, keys ] ], tenant_key: definition.tenant_key_for(self)) if keys.any?
65
+ tenant_key = definition.tenant_key_for(self)
66
+ keys = definition.supplied_labels.select { |label| label.available?(tenant_key) }.map(&:key)
67
+ Labeling::Supplied.new(self.class).write([ [ self, keys ] ], tenant_key: tenant_key) if keys.any?
67
68
  self
68
69
  end
69
70
 
@@ -77,10 +78,10 @@ module Truffler
77
78
  end
78
79
 
79
80
  changed = saved_changes.keys
80
- if changed.intersect?([ *definition.fields, definition.tenant_column ].compact)
81
+ if changed.intersect?(definition.relabel_columns)
81
82
  truffler_expire_labels
82
83
  else
83
- watched = definition.supplied_labels.select { |label| changed.intersect?(label.watch) }
84
+ watched = definition.labels.values.select { |label| changed.intersect?(label.watch) }
84
85
  return if watched.empty?
85
86
 
86
87
  truffler_expire_labels(watched.map(&:key))
@@ -103,7 +104,7 @@ module Truffler
103
104
  def truffler_enqueue_embedding
104
105
  definition = self.class.truffler_definition
105
106
  return unless Embeddings.managed?(definition)
106
- return unless previously_new_record? || saved_changes.keys.intersect?([ *definition.fields, definition.tenant_column ].compact)
107
+ return unless previously_new_record? || saved_changes.keys.intersect?(definition.relabel_columns)
107
108
 
108
109
  Jobs::EmbedJob.perform_later(self.class.polymorphic_name, id)
109
110
  end
@@ -2,12 +2,21 @@ module Truffler
2
2
  module QueryEncoding
3
3
  # Encodes one pending query (KTD9). It asks a fixed question set: each
4
4
  # label gets `filter | boost | ignore`, each choice label also gets its
5
- # options plus `none`, and each of the first 12 word tokens gets
5
+ # options plus `Truffler::NO_OPTION`, and each of the first 12 word tokens gets
6
6
  # `keyword | label_term | filler`. Exact-text tokens (digits, dates,
7
7
  # quoted phrases, emails, identifiers) are keywords decided locally and
8
8
  # never asked (R18). Query text travels only in `state` ("query" and
9
9
  # "tokens"); a token question names its word by position, `tokens[n]`,
10
- # so searcher text never lands in an instruction (R8).
10
+ # so searcher text never lands in an instruction (R8). The label
11
+ # vocabulary, with choice option display names, rides along in
12
+ # `state["labels"]` so a token question can tell a word that names a
13
+ # label from text to match.
14
+ #
15
+ # Jev's word roles are then reconciled locally: a keyword that names a
16
+ # label the query applies (its key, a word of its key, the chosen option,
17
+ # or a word of that option's display name, ignoring case and plurals, or
18
+ # sharing its first three letters) becomes a label term, and a common
19
+ # stopword becomes filler.
11
20
  #
12
21
  # Answers become a `Search::Encoding` with the KTD20 intent vector: boost
13
22
  # gives the declared boost, filter narrows and adds `filter_weight`
@@ -22,10 +31,14 @@ module Truffler
22
31
  }.freeze
23
32
  TOKEN_ROLES = {
24
33
  "keyword" => "A word to match in the record text",
25
- "label_term" => "A word that names one of the labels above rather than text to match",
34
+ "label_term" => "A word that names one of the labels in `labels`, or one of its options, rather than text to match",
26
35
  "filler" => "A word that carries no meaning for the search"
27
36
  }.freeze
28
- NO_OPTION = "none".freeze
37
+ NO_OPTION = Truffler::NO_OPTION
38
+ STOPWORDS = %w[
39
+ a about all an and any are at be by for from have i in is it me my now of on or our please so some that the their
40
+ them there they this to up us was we what when where which who why with you your
41
+ ].to_set.freeze
29
42
 
30
43
  Request = Data.define(:state, :questions, :token_ids, :exact_tokens, :unasked_tokens)
31
44
 
@@ -54,16 +67,20 @@ module Truffler
54
67
  criteria: options)
55
68
  end
56
69
 
57
- words = query.tokens.each_with_index.reject { |token, _| query.exact_tokens.include?(token) }
70
+ words = query.tokens.each_with_index.reject do |token, position|
71
+ query.exact_tokens.include?(token) || query.time_position?(position)
72
+ end
58
73
  asked = words.first(MAX_TOKEN_QUESTIONS)
59
74
  token_ids = asked.to_h do |_token, position|
60
75
  id = :"token__#{position}"
61
- questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]?), criteria: TOKEN_ROLES)
76
+ questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]? The labels it may name, ) +
77
+ %(with their options, are in `labels`.), criteria: TOKEN_ROLES)
62
78
  [ position, id.to_s ]
63
79
  end
64
80
 
65
- Request.new(state: { "query" => query.normalized, "tokens" => query.tokens }, questions: questions.to_h, token_ids: token_ids,
66
- exact_tokens: query.exact_tokens, unasked_tokens: words.drop(MAX_TOKEN_QUESTIONS).map(&:first))
81
+ state = { "query" => query.normalized, "tokens" => query.tokens, "labels" => vocabulary_state(labels, tenant_key) }
82
+ Request.new(state: state, questions: questions.to_h, token_ids: token_ids, exact_tokens: query.exact_tokens,
83
+ unasked_tokens: words.drop(MAX_TOKEN_QUESTIONS).map(&:first))
67
84
  end
68
85
 
69
86
  # Encodes the query pending under `cache_key`. Returns the encoding, or
@@ -103,6 +120,7 @@ module Truffler
103
120
  filters = {}
104
121
  boosts = {}
105
122
  intent = {}
123
+ terms = []
106
124
  labels(model, tenant_key, user_key).each_value do |label|
107
125
  key = storage_key(label, answers, tenant_key)
108
126
  next unless key
@@ -113,15 +131,15 @@ module Truffler
113
131
  intent[key] = label.filter_weight
114
132
  when "boost"
115
133
  boosts[key] = intent[key] = label.boost || DEFAULT_BOOST
134
+ else next
116
135
  end
136
+ terms.concat(label_terms(key, option_name(label, key, tenant_key)))
117
137
  end
118
138
 
119
- roles = request.token_ids.transform_values { |id| answers.choice(id) }
120
139
  query = Search::Query.new(request.state["query"])
121
- keyword_tokens = query.tokens.each_with_index.filter_map do |token, position|
122
- token if roles.fetch(position, "keyword") == "keyword"
123
- end
124
- label_term_tokens = roles.filter_map { |position, role| query.tokens[position] if role == "label_term" }
140
+ roles = reconcile(query, request.token_ids.transform_values { |id| answers.choice(id) }, terms.uniq)
141
+ keyword_tokens = query.tokens.each_index.filter_map { |position| query.tokens[position] if roles[position] == "keyword" }
142
+ label_term_tokens = query.tokens.each_index.filter_map { |position| query.tokens[position] if roles[position] == "label_term" }
125
143
  Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent, keyword_tokens: keyword_tokens,
126
144
  label_term_tokens: label_term_tokens)
127
145
  end
@@ -162,7 +180,7 @@ module Truffler
162
180
  end
163
181
 
164
182
  # The label's storage key the query names, or nil for a choice label
165
- # whose option answer is `none`.
183
+ # whose option answer is NO_OPTION.
166
184
  def storage_key(label, answers, tenant_key)
167
185
  return label.key unless label.type == :choice
168
186
 
@@ -170,6 +188,63 @@ module Truffler
170
188
  "#{label.key}:#{option}" if option != NO_OPTION && label.options(tenant_key).key?(option)
171
189
  end
172
190
 
191
+ def vocabulary_state(labels, tenant_key)
192
+ labels.transform_values do |label|
193
+ entry = { "description" => label.description }
194
+ if label.type == :choice
195
+ options = label.options(tenant_key)
196
+ entry["options"] = options.keys
197
+ names = options.compact
198
+ entry["option_names"] = names if names.any?
199
+ end
200
+ entry
201
+ end
202
+ end
203
+
204
+ # {position => role} for every token. Time phrase words are "time";
205
+ # exact tokens and unasked words are keywords; a keyword naming an
206
+ # applied label becomes a label term; stopwords become filler unless
207
+ # they are all that would be left of an encoding that applies nothing.
208
+ def reconcile(query, answered, terms)
209
+ roles = query.tokens.each_index.to_h do |position|
210
+ [ position, query.time_position?(position) ? "time" : answered.fetch(position, "keyword") ]
211
+ end
212
+ words = roles.keys.select { |position| roles[position] == "keyword" && !query.exact_tokens.include?(query.tokens[position]) }
213
+ words.each { |position| roles[position] = "label_term" if names_label?(query.tokens[position], terms) }
214
+ stopwords = words.select { |position| roles[position] == "keyword" && STOPWORDS.include?(query.tokens[position]) }
215
+ return roles if terms.empty? && roles.values.count("keyword") == stopwords.size
216
+
217
+ stopwords.each { |position| roles[position] = "filler" }
218
+ roles
219
+ end
220
+
221
+ # "category:billing" names "category" and "billing"; "needs_action"
222
+ # names "needs_action", "needs", and "action". An option's display
223
+ # name adds its words minus stopwords: "p_17" shown as "Spiral writing
224
+ # tool" also names "spiral", "writing", and "tool".
225
+ STEM = 3
226
+
227
+ def label_terms(storage_key, option_name = nil)
228
+ label, option = Search::Encoding.split_key(storage_key)
229
+ keys = [ label.split(":").last, option ].compact.flat_map { |name| [ name.downcase, *name.downcase.split(/[^\p{Alnum}]+/) ] }
230
+ words = option_name.to_s.downcase.split(/[^\p{Alnum}]+/).reject { |word| STOPWORDS.include?(word) }
231
+ (keys + words).reject(&:empty?).map(&:singularize)
232
+ end
233
+
234
+ def option_name(label, storage_key, tenant_key)
235
+ return unless label.type == :choice
236
+
237
+ label.options(tenant_key)[Search::Encoding.split_key(storage_key).last]
238
+ end
239
+
240
+ # "angry" names "anger": the same word ignoring plurals, or two words of
241
+ # four letters or more that share their first three letters. Only the
242
+ # applied labels' terms are compared, so the loose match stays safe.
243
+ def names_label?(word, terms)
244
+ word = word.singularize
245
+ terms.any? { |term| term == word || (word.length > STEM && term.length > STEM && word[0, STEM] == term[0, STEM]) }
246
+ end
247
+
173
248
  def intent_instructions(label)
174
249
  %(How does the search query use the label "#{label.key}" (#{label.description})?)
175
250
  end
@@ -0,0 +1,51 @@
1
+ module Truffler
2
+ module Records
3
+ # The backfill spend ledger: one row per model and app-wide vocabulary
4
+ # version, so a spend cap holds across runs, reruns, and overlapping
5
+ # BackfillJob chains. Spend is reserved and settled in SQL, never read,
6
+ # added to, and written back.
7
+ class BackfillSpend < ActiveRecord::Base
8
+ self.table_name = "truffler_backfill_spends"
9
+
10
+ scope :for_model, ->(model) { where(record_type: model.polymorphic_name) }
11
+
12
+ # False on apps that upgraded the gem without running
13
+ # `rails g truffler:upgrade`; warns once per process.
14
+ def self.available?
15
+ return true if connection.data_source_exists?(table_name)
16
+
17
+ warn_missing
18
+ false
19
+ end
20
+
21
+ def self.ledger(model, version)
22
+ create_or_find_by!(record_type: model.polymorphic_name, vocabulary_version: version)
23
+ end
24
+
25
+ def self.warn_missing
26
+ return if @missing_warned
27
+
28
+ @missing_warned = true
29
+ Truffler.config.logger.warn("[truffler] #{table_name} is missing, so backfill spend caps apply per run only. " \
30
+ "Run `bin/rails g truffler:upgrade && bin/rails db:migrate`.")
31
+ end
32
+ private_class_method :warn_missing
33
+
34
+ # Adds `amount` unless that would pass `cap`; true when reserved.
35
+ def reserve(amount, cap)
36
+ scope = self.class.where(id: id)
37
+ scope = scope.where("spent_usd + ? <= ?", amount, cap) if cap
38
+ scope.update_all([ "spent_usd = spent_usd + ?, updated_at = ?", amount, Time.current ]) == 1
39
+ end
40
+
41
+ def settle(amount, requests: 1)
42
+ self.class.where(id: id)
43
+ .update_all([ "spent_usd = spent_usd + ?, requests = requests + ?, updated_at = ?", amount, requests, Time.current ])
44
+ end
45
+
46
+ def total
47
+ self.class.where(id: id).pick(:spent_usd).to_f
48
+ end
49
+ end
50
+ end
51
+ end
@@ -12,7 +12,9 @@ module Truffler
12
12
  # - `keyword_tokens`: query tokens for the keyword source; nil means
13
13
  # every token that is not a label term.
14
14
  # - `label_term_tokens`: tokens that named a label rather than a keyword.
15
- Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens) do
15
+ # - `time`: the query's `TimeRange`, resolved at search time and never
16
+ # cached, since "today" moves.
17
+ Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens, :time) do
16
18
  def self.load(value, query)
17
19
  return value if value.is_a?(self)
18
20
  return if value.nil?
@@ -23,12 +25,12 @@ module Truffler
23
25
  label_term_tokens: Array(value["label_term_positions"]).filter_map { |position| tokens[position] })
24
26
  end
25
27
 
26
- def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [])
28
+ def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [], time: nil)
27
29
  filters = weights(filters)
28
30
  boosts = weights(boosts)
29
31
  intent_vector = weights(intent_vector || boosts).reject { |_, weight| weight.zero? }
30
32
  super(filters: filters.freeze, boosts: boosts.freeze, intent_vector: intent_vector.freeze,
31
- keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze)
33
+ keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze, time: time)
32
34
  end
33
35
 
34
36
  def empty?
@@ -36,13 +38,14 @@ module Truffler
36
38
  end
37
39
 
38
40
  # The encoding minus the chips the searcher removed (R20), matched by
39
- # storage key or by label key.
41
+ # storage key or by label key; "time" removes the time range.
40
42
  def without(suppressed)
41
43
  suppressed = Array(suppressed).map(&:to_s).to_set
42
44
  return self if suppressed.empty?
43
45
 
44
46
  keep = ->(key, _) { !suppressed.include?(key) && !suppressed.include?(self.class.split_key(key).first) }
45
- with(filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep))
47
+ with(filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep),
48
+ time: (time unless suppressed.include?(TimeRange.key)))
46
49
  end
47
50
 
48
51
  # Splits a storage key into its label key and choice option. Lens keys
@@ -58,7 +61,7 @@ module Truffler
58
61
  end
59
62
 
60
63
  def keywords(query)
61
- keyword_tokens || (query.tokens - label_term_tokens)
64
+ keyword_tokens || (query.search_tokens - label_term_tokens)
62
65
  end
63
66
 
64
67
  # The cache form: decisions plus token positions in the normalized
@@ -10,9 +10,10 @@ module Truffler
10
10
  attr_reader :model, :query, :tenant_key, :scope, :user_key, :suppressed, :surface, :limit
11
11
 
12
12
  def initialize(model, query, tenant:, scope:, user: nil, suppressed: [], surface: nil, limit: DEFAULT_LIMIT, weights: {},
13
- cache: EncodingCache.new)
13
+ cache: EncodingCache.new, clock: -> { Time.current })
14
14
  @model = model
15
15
  @definition = model.try(:truffler_definition) || raise(DefinitionError, "#{model.name} has no truffler declaration")
16
+ @definition.validate_columns!
16
17
  @query = Query.wrap(query)
17
18
  @tenant_key = tenant&.to_s
18
19
  @scope = scope.nil? && !@definition.scoped? ? model.all : scope
@@ -22,6 +23,7 @@ module Truffler
22
23
  @limit = limit
23
24
  @weights = @definition.ranking.merge(weights.to_h { |key, weight| [ key.to_sym, Float(weight) ] })
24
25
  @cache = cache
26
+ @clock = clock
25
27
  check_scope!
26
28
  end
27
29
 
@@ -39,7 +41,7 @@ module Truffler
39
41
  explicit_action = surface_action
40
42
  cached = read_encoding
41
43
  status = encoding_status(cached)
42
- encoding = visible_lenses_only(cached&.without(suppressed), record_usage: true)
44
+ encoding = with_time(visible_lenses_only(cached&.without(suppressed), record_usage: true))
43
45
  sql = sql(encoding)
44
46
  records = sql.relation(scope, limit: limit).to_a
45
47
  result = Result.new(records: records, query: query, encoding: encoding, encoding_status: status, watermark: watermark,
@@ -52,7 +54,7 @@ module Truffler
52
54
  # How many records the same search would return that arrived after
53
55
  # `since` (R25). Reads the cache only and never prefetches.
54
56
  def count(since:)
55
- sql(visible_lenses_only(read_encoding&.without(suppressed))).candidates(scope)
57
+ sql(with_time(visible_lenses_only(read_encoding&.without(suppressed)))).candidates(scope)
56
58
  .where(model.arel_table[@definition.arrived_at_column].gt(since)).count
57
59
  end
58
60
 
@@ -91,6 +93,15 @@ module Truffler
91
93
  encoding.without(hidden)
92
94
  end
93
95
 
96
+ # The query's time phrase, resolved on this search's clock, unless the
97
+ # searcher removed its chip.
98
+ def with_time(encoding)
99
+ phrase = query.time_phrase
100
+ return encoding if phrase.nil? || suppressed.include?(TimeRange.key)
101
+
102
+ (encoding || Encoding.new).with(time: phrase.range(@clock.call))
103
+ end
104
+
94
105
  def lens_id(key)
95
106
  prefix, id = key.split(":", 3)
96
107
  Integer(id, exception: false) if prefix == Lenses::KEY_PREFIX