truffler 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41cc5bfdb1f4f8a5b18ab9388518140bb5b2c927c87f115094c43ffa1c37659a
4
- data.tar.gz: 11ee26029552cfaaa18d68fed182a2ede4641f9491c942eecbf6aa86326baef8
3
+ metadata.gz: 1e678dd275fc608a97a93cc9eb7a881ae686c4cc428bfe2a823310f2d5a4b559
4
+ data.tar.gz: 3145a1087183f63dae1baf3bd38237761760f368b79828860af802c81caea690
5
5
  SHA512:
6
- metadata.gz: b1bc305d0c1745424ce0c55e905a6f592a423f37274edb420def42073119de9f089ac52fded8f0af0319327e9e7e3738b41e6d62fe77ec1d398d1f7c7bddd170
7
- data.tar.gz: 8dd567f12f014c36ac80e900a87965390cbaae8f3c3e63e588cd6668510e68afc020f8d0039198fea2c13a98aabff2425b6a5eef839c5b3be64864ca8be1e868
6
+ metadata.gz: ebe167d14a66fc74e219e1a5f9206ba158018418b87437b212e51eb6e356c27dd35a5312e443f6cd108a21f57c223e7219c39f5ed45d44ac643aae1feb08a103
7
+ data.tar.gz: 2352317c03e9c9e8b91597efdf3aab02ea09ca54bb9ff3bb9bcf1fc0a214d1b23d62f1542ef3c7f6866e720f7e7624aa9eab23bea04fe6fe86c649a20e5faaaf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.6]
4
+
5
+ A fix from happyhappy production and follow-ups from 0.1.5.
6
+
7
+ - Zero-result relaxation. With a Cora product filter applied, the word "email" became a `Source: email` filter chip and results dropped from 50 to 0 because the tenant has no email sources. When a keystroke search under the encoding's filters returns nothing but would match without them, those filters now become soft boosts: they stay in the intent vector so matching records rank first, but nothing requires them. The words they consumed become keywords again. Truffler first relaxes only the filters no record in the tenant carries at their threshold and keeps the rest, then every filter if that still finds nothing. It never relaxes into "every record in the tenant" when no word, exact match, or vector match remains. The fallback costs one extra query, only on an empty result; non-empty keystrokes stay one SELECT. `Result#relaxed_labels` lists the relaxed storage keys, and their chips carry `relaxed: true`. Chips the searcher removed stay removed. Smart search candidate filtering falls back the same way instead of reranking an empty set, and records `relaxed_labels` on the run (`run.to_h`).
8
+ - New `config.skip_empty_options` (default false). When true, query encoding offers Jev only the choice options the tenant has label rows for (at or above `choice_min_probability`) and leaves out choice labels with none. The present-option set is digested into the encoding cache key and cached for 5 minutes, so a new option reaches encoding within that TTL and keystrokes stay one SELECT.
9
+ - `truffler:install` and `truffler:upgrade` migration templates pass rubocop-rails-omakase (`[ :a, :b ]` array brackets). A test renders every template and runs RuboCop with the omakase config on the output.
10
+ - `Records::BackfillSpend` no longer caches a missing `truffler_backfill_spends.tenant_key` for the life of the process. On a miss it reloads the column information at most once a minute, so workers started before `db:migrate` move to tenant ledgers without a restart.
11
+ - `Backfill.status` works with an `index_scope` that orders. It plucked distinct tenants over `index_relation` without `reorder(nil)`, which raised on Postgres (`for SELECT DISTINCT, ORDER BY expressions must appear in select list`).
12
+ - A per-tenant choice `options:` callable runs once per label and tenant for each keystroke search, query encoding, labeler batch, and Smart run step, instead of at every vocabulary, fingerprint, and wording read. In happyhappy that was about 8 times per keystroke, and 14 per encoding and 21 per labeler batch in the test model. The new `Truffler::Current.scope` memoizes for one unit of work and clears when it ends, so nothing is shared between searches, jobs, or tenants.
13
+ - Backfill spend ledgers key on the Jev-asked labels only (`Vocabulary#ledger_version`). Changing a supplied (`from:`) label, such as adding an option to a supplied product choice, no longer starts a fresh ledger; the backfill rewrites only that label with no Jev call and no charge. Changing an asked label still starts a new ledger. Existing ledgers keep counting: a model without supplied labels keeps the same key, and one with supplied labels has its pre-0.1.6 row taken over by the first backfill.
14
+
3
15
  ## [0.1.5]
4
16
 
5
17
  Fixes from the Cora integration at Postgres scale, and two from happyhappy's 0.1.4 upgrade.
data/README.md CHANGED
@@ -51,7 +51,7 @@ bin/rails db:migrate
51
51
 
52
52
  The migration creates only that table and skips it if it already exists. Until you run it, backfills log one warning and cap spend per run, as 0.1.1 did.
53
53
 
54
- 0.1.5 adds `truffler_backfill_spends.tenant_key` for per-tenant spend ledgers. The same `truffler:upgrade` command writes that migration. Until you run it, every tenant shares the model's app-wide ledger and a warning is logged once.
54
+ 0.1.5 adds `truffler_backfill_spends.tenant_key` for per-tenant spend ledgers. The same `truffler:upgrade` command writes that migration. Until you run it, every tenant shares the model's app-wide ledger and a warning is logged once. Workers already running when you migrate pick up the column within a minute, without a restart.
55
55
 
56
56
  `truffler:upgrade` is safe to rerun. It skips any migration already in `db/migrate` or already applied to the database, and writes only the missing ones.
57
57
 
@@ -96,7 +96,7 @@ end
96
96
  Here is what each option does:
97
97
 
98
98
  - `filter_at` makes a label a hard filter at that probability when the query asks for it. `boost` is the label's weight when the query prefers it. `filter_weight:` (default 0) is how much intent weight a filter adds on top.
99
- - Choice `options:` may be a callable of the tenant key, which gives each tenant its own vocabulary. A tenant it gives no options (`{}` or nil) simply lacks the label: it is neither asked nor encoded there. The option name `truffler:none` is reserved (see `Truffler::NO_OPTION` below).
99
+ - Choice `options:` may be a callable of the tenant key, which gives each tenant its own vocabulary. A tenant it gives no options (`{}` or nil) simply lacks the label: it is neither asked nor encoded there. The callable runs once per label and tenant for each keystroke search, query encoding, labeler batch, and Smart run step (`Truffler::Current.scope`), never shared between them. The option name `truffler:none` is reserved (see `Truffler::NO_OPTION` below).
100
100
  - An option's value may be `{ description: "...", search: "..." }` instead of a plain description, e.g. `options: { "prod-a1" => { description: "Cora, the AI email assistant that drafts replies...", search: "Cora email assistant" } }`. Jev labels records with the description; query words are matched against the short search text (else the description), and the request state's `option_names` carries it. A per-tenant callable may return the same shape. Editing a search text never stales labels or triggers a backfill; it only changes the query-encoding cache key, so queries are re-encoded.
101
101
  - `watch :column, ...` relabels every label when one of those columns changes. Saving a record only relabels on columns that changed, so a `reads` field backed by a method (a conversation built from messages, say) needs the columns it is built from in `watch`.
102
102
  - `keyword` also accepts a single callable, `->(scope, tokens) { relation }`. It, and each `exact` callable, may return an Array of ids instead of a relation, which is far faster on a large tenant (see [Keystroke search at scale](#keystroke-search-at-scale)).
@@ -203,12 +203,15 @@ Query encoding sends Jev the label vocabulary (each label's description and a ch
203
203
 
204
204
  When the searcher removes a chip, the words that named only that label (or only labels that are now all removed) become keywords again, so removing the `urgent` chip from "urgent refunds" searches for both words. A word that named a label or option key only by shared prefix ("urgently" for `urgent`) also adds a small keyword score (a quarter of the keyword weight) while its label applies, ranking records whose text contains it higher without requiring it.
205
205
 
206
+ When a search under the encoding's filters returns nothing but would match without them, the filters relax instead of leaving an empty list. With a product filter applied, "email" can become a `Source: email` filter in a tenant with no email sources. Truffler first relaxes only the filters no record in the tenant carries at their threshold, keeping the rest (the product filter stays). If that still finds nothing, it relaxes every filter. A relaxed filter becomes a soft boost, so records that do match it still rank first, and the words it consumed become keywords again. A filter is never relaxed into "every record in the tenant": if no word comes back and there is no exact or vector match, the list stays empty. Relaxation runs one extra query, only when the result is empty. `result.relaxed_labels` lists the relaxed storage keys, and their chips keep `kind: :filter` with `relaxed: true`, so the UI can say "No email sources; showing keyword matches". Chips the searcher removed stay removed.
207
+
206
208
  Time phrases are handled in Ruby and never asked of Jev: `today`, `yesterday`, `this week`, `last week`, `this month`, `last month`, `past|last N hours|days|weeks|months`, `last hour`, `past hour`, `past week`, `past month`, and `since monday` through `since sunday`. "Past/last N units" is a rolling window ending now; "this/last week" and "this/last month" are calendar windows. The first one in a query limits results to records whose `arrived_at` column falls in that window, its words are not keywords, and it shows as a chip `{key: "time", label: "time", kind: :time, name: "This week"}`. Pass `"time"` in `suppressed:` to drop it. Weeks start on `Date.beginning_of_week`, and the window is computed from `Time.current` (or a `clock:` callable passed to the search, for tests).
207
209
 
208
210
  The returned `Truffler::Search::Result` exposes:
209
211
 
210
212
  - `records` and `ids`.
211
- - `chips`: `[{key:, label:, kind: :filter | :boost | :time, name:}]`.
213
+ - `chips`: `[{key:, label:, kind: :filter | :boost | :time, name:}]`, plus `relaxed: true` on a filter chip that was relaxed.
214
+ - `relaxed_labels`: storage keys of filters relaxed because they left nothing to show (empty otherwise).
212
215
  - `invite_row`: `{query:, reason: :weak | :empty | :encoding_pending}` or nil.
213
216
  - `local_weak?`: whether the list is too weak to stand alone (starts the backup provider). A pending encoding on a model with a `keyword` source invites Smart search without making the list weak.
214
217
  - `encoding_status`: `:cached`, `:pending`, or `:none`.
@@ -260,7 +263,7 @@ The keystroke list is left untouched. The job then does the following:
260
263
 
261
264
  1. Starts the provider backup, if one is declared.
262
265
  2. Takes a rerank slot under the per-user cap. If no slot is available, the run pauses.
263
- 3. Waits up to `encoding_deadline` for an in-flight query encoding and applies its filters.
266
+ 3. Waits up to `encoding_deadline` for an in-flight query encoding and applies its filters. If they leave no candidate, they relax as on the keystroke instead of reranking an empty set. `run.relaxed_labels` and `run.to_h[:relaxed_labels]` list them.
264
267
  4. Fans out one `RerankChunkJob` per `rerank_chunk_size` candidates.
265
268
 
266
269
  Each chunk appends to the buckets and pings. When the searcher edits the query or accepts or removes a chip, call `Email.jev_cancel_smart_search(tenant:, user:, surface:)`.
@@ -395,6 +398,7 @@ Set these in `Truffler.configure do |config| ... end`.
395
398
  | `tenant_enabled` | nil (every tenant) | `->(model, tenant_key) { ... }`, asked for tenant-scoped models only. A disabled tenant is never labeled, embedded, or backfilled (see Indexing only some tenants). |
396
399
  | `backfill_spend_cap_scope` | `:tenant` | `:tenant` keeps a spend ledger per tenant for tenant-scoped models, so `backfill_spend_cap` applies to each tenant. `:app` keeps one ledger per model. Unscoped models always use one. |
397
400
  | `choice_min_probability` | 0.05 | Choice labels store a row only for options at or above this probability, plus the most likely option. A missing option reads as 0.0 in filters, boosts, label vectors, and contributions. `nil` stores every option. No migration is needed: rows written earlier stay until their record is relabeled. |
401
+ | `skip_empty_options` | false | When true, query encoding offers Jev only the choice options the tenant has label rows for (at or above `choice_min_probability`, or above 0.0 when that is `nil`), and leaves out choice labels with none, so a query cannot filter on an option no record carries. The present-option set is part of the encoding cache key and is read through the cache store for 5 minutes (`Truffler::QueryEncoding::PresentOptions::TTL`), so keystrokes stay one query and a new option reaches encoding within 5 minutes. |
398
402
 
399
403
  ## Jobs to schedule
400
404
 
@@ -436,7 +440,7 @@ truffler_expire_lenses:
436
440
  | `RESET_SPEND=1` | Zeroes the spend recorded for the current vocabulary version before starting (every tenant's ledger when no `TENANT` is given). |
437
441
  | `TENANT=42` | Backfills only that tenant, against that tenant's ledger. `truffler:status` takes it too, to print that tenant's spend. |
438
442
 
439
- The spend cap holds across runs. Truffler records backfill spend per model and app-wide vocabulary version in `truffler_backfill_spends`, and reserves each request's estimate against the cap in SQL. A rerun, a `ResumeJob` backfill, and overlapping `BackfillJob` chains all draw on the same total, so together they stop at `backfill_spend_cap`. Changing the vocabulary (a reworded question, a new label, or an activated lens) starts a new total. Lens backfills are capped separately by each lens's `spend_cap_usd`. `BackfillJob` reschedules itself after a denial with the same backoff. In code, `Truffler::Labeling::Backfill.new(Email).run(wait: true, max_duration: 600)` does what the task does.
443
+ The spend cap holds across runs. Truffler records backfill spend per model and ledger version in `truffler_backfill_spends`, and reserves each request's estimate against the cap in SQL. A rerun, a `ResumeJob` backfill, and overlapping `BackfillJob` chains all draw on the same total, so together they stop at `backfill_spend_cap`. Changing what Jev is asked (a reworded question, a new asked label, or an activated lens) starts a new total. Supplied (`from:`) labels cost nothing and never do: adding an option to a supplied choice keeps the ledger, and the next backfill rewrites only that label with no Jev call. Ledgers written before 0.1.6 keep counting. A model without supplied labels keeps the same key, and one with supplied labels has its old row taken over on the first backfill, so no spend is reset. Lens backfills are capped separately by each lens's `spend_cap_usd`. `BackfillJob` reschedules itself after a denial with the same backoff. In code, `Truffler::Labeling::Backfill.new(Email).run(wait: true, max_duration: 600)` does what the task does.
440
444
 
441
445
  For tenant-scoped models the ledger is per tenant (`backfill_spend_cap_scope :tenant`, the default), so the cap applies to each tenant. A whole-model run skips a tenant once its ledger reaches the cap, keeps labeling the others, and ends with `spend_cap_reached`. A tenant run (`TENANT=`, or `BackfillJob.perform_later("Email", tenant_key: "42")`) stops at that tenant's cap. `ResumeJob` and the flush job's over-cap demotion enqueue one `BackfillJob` per tenant.
442
446
 
@@ -9,9 +9,9 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
9
9
  t.string :fingerprint, null: false
10
10
  t.datetime :labeled_at, null: false
11
11
  end
12
- add_index :truffler_labels, [:record_type, :record_id, :label_key], unique: true,
12
+ add_index :truffler_labels, [ :record_type, :record_id, :label_key ], unique: true,
13
13
  name: "index_truffler_labels_on_record_and_label"
14
- add_index :truffler_labels, [:record_type, :tenant_key, :label_key, :value], include: [:record_id],
14
+ add_index :truffler_labels, [ :record_type, :tenant_key, :label_key, :value ], include: [ :record_id ],
15
15
  name: "index_truffler_labels_for_search"
16
16
 
17
17
  create_table :truffler_record_states do |t|
@@ -29,9 +29,9 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
29
29
  t.datetime :embedded_at
30
30
  t.timestamps
31
31
  end
32
- add_index :truffler_record_states, [:record_type, :record_id], unique: true,
32
+ add_index :truffler_record_states, [ :record_type, :record_id ], unique: true,
33
33
  name: "index_truffler_record_states_on_record"
34
- add_index :truffler_record_states, [:record_type, :tenant_key, :status, :priority],
34
+ add_index :truffler_record_states, [ :record_type, :tenant_key, :status, :priority ],
35
35
  name: "index_truffler_record_states_for_claims"
36
36
 
37
37
  create_table :truffler_embeddings do |t|
@@ -49,9 +49,9 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
49
49
  t.string :label_vocabulary_version
50
50
  t.timestamps
51
51
  end
52
- add_index :truffler_embeddings, [:record_type, :record_id], unique: true,
52
+ add_index :truffler_embeddings, [ :record_type, :record_id ], unique: true,
53
53
  name: "index_truffler_embeddings_on_record"
54
- add_index :truffler_embeddings, [:record_type, :tenant_key], name: "index_truffler_embeddings_on_tenant"
54
+ add_index :truffler_embeddings, [ :record_type, :tenant_key ], name: "index_truffler_embeddings_on_tenant"
55
55
 
56
56
  create_table :truffler_query_misses do |t|
57
57
  t.string :record_type, null: false
@@ -61,7 +61,7 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
61
61
  t.text :query_text
62
62
  t.datetime :created_at, null: false
63
63
  end
64
- add_index :truffler_query_misses, [:record_type, :tenant_key, :created_at],
64
+ add_index :truffler_query_misses, [ :record_type, :tenant_key, :created_at ],
65
65
  name: "index_truffler_query_misses_on_tenant"
66
66
  add_index :truffler_query_misses, :created_at
67
67
 
@@ -86,7 +86,7 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
86
86
  t.datetime :last_used_at
87
87
  t.timestamps
88
88
  end
89
- add_index :truffler_lenses, [:record_type, :status, :scope_type, :tenant_key],
89
+ add_index :truffler_lenses, [ :record_type, :status, :scope_type, :tenant_key ],
90
90
  name: "index_truffler_lenses_for_visibility"
91
91
 
92
92
  create_table :truffler_lens_versions do |t|
@@ -103,7 +103,7 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
103
103
  t.datetime :activated_at
104
104
  t.datetime :created_at, null: false
105
105
  end
106
- add_index :truffler_lens_versions, [:lens_id, :number], unique: true,
106
+ add_index :truffler_lens_versions, [ :lens_id, :number ], unique: true,
107
107
  name: "index_truffler_lens_versions_on_lens_and_number"
108
108
 
109
109
  create_table :truffler_backfill_spends do |t|
@@ -114,9 +114,9 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
114
114
  t.integer :requests, null: false, default: 0
115
115
  t.timestamps
116
116
  end
117
- add_index :truffler_backfill_spends, [:record_type, :tenant_key, :vocabulary_version], unique: true,
117
+ add_index :truffler_backfill_spends, [ :record_type, :tenant_key, :vocabulary_version ], unique: true,
118
118
  name: "index_truffler_backfill_spends_on_tenant_ledger"
119
- add_index :truffler_backfill_spends, [:record_type, :vocabulary_version], unique: true, where: "tenant_key IS NULL",
119
+ add_index :truffler_backfill_spends, [ :record_type, :vocabulary_version ], unique: true, where: "tenant_key IS NULL",
120
120
  name: "index_truffler_backfill_spends_on_app_ledger"
121
121
  end
122
122
  end
@@ -9,7 +9,7 @@ class CreateTrufflerBackfillSpends < ActiveRecord::Migration<%= migration_versio
9
9
  t.integer :requests, null: false, default: 0
10
10
  t.timestamps
11
11
  end
12
- add_index :truffler_backfill_spends, [:record_type, :vocabulary_version], unique: true,
12
+ add_index :truffler_backfill_spends, [ :record_type, :vocabulary_version ], unique: true,
13
13
  name: "index_truffler_backfill_spends_on_ledger"
14
14
  end
15
15
 
@@ -5,11 +5,11 @@ class AddTenantKeyToTrufflerBackfillSpends < ActiveRecord::Migration<%= migratio
5
5
  remove_index :truffler_backfill_spends, name: "index_truffler_backfill_spends_on_ledger"
6
6
  end
7
7
  unless index_name_exists?(:truffler_backfill_spends, "index_truffler_backfill_spends_on_tenant_ledger")
8
- add_index :truffler_backfill_spends, [:record_type, :tenant_key, :vocabulary_version], unique: true,
8
+ add_index :truffler_backfill_spends, [ :record_type, :tenant_key, :vocabulary_version ], unique: true,
9
9
  name: "index_truffler_backfill_spends_on_tenant_ledger"
10
10
  end
11
11
  unless index_name_exists?(:truffler_backfill_spends, "index_truffler_backfill_spends_on_app_ledger")
12
- add_index :truffler_backfill_spends, [:record_type, :vocabulary_version], unique: true, where: "tenant_key IS NULL",
12
+ add_index :truffler_backfill_spends, [ :record_type, :vocabulary_version ], unique: true, where: "tenant_key IS NULL",
13
13
  name: "index_truffler_backfill_spends_on_app_ledger"
14
14
  end
15
15
  end
@@ -19,7 +19,7 @@ class AddTenantKeyToTrufflerBackfillSpends < ActiveRecord::Migration<%= migratio
19
19
  remove_index :truffler_backfill_spends, name: "index_truffler_backfill_spends_on_tenant_ledger", if_exists: true
20
20
  remove_index :truffler_backfill_spends, name: "index_truffler_backfill_spends_on_app_ledger", if_exists: true
21
21
  remove_column :truffler_backfill_spends, :tenant_key, if_exists: true
22
- add_index :truffler_backfill_spends, [:record_type, :vocabulary_version], unique: true,
22
+ add_index :truffler_backfill_spends, [ :record_type, :vocabulary_version ], unique: true,
23
23
  name: "index_truffler_backfill_spends_on_ledger", if_not_exists: true
24
24
  end
25
25
  end
@@ -25,6 +25,8 @@ module Truffler
25
25
  attr_accessor :backfill_spend_cap_scope
26
26
  # Choice options below this probability store no row and read as 0.0 (nil stores every option).
27
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
28
30
 
29
31
  def initialize(env: ENV)
30
32
  @model = "jev-latest"
@@ -62,6 +64,7 @@ module Truffler
62
64
  @tenant_enabled = nil
63
65
  @backfill_spend_cap_scope = :tenant
64
66
  @choice_min_probability = 0.05
67
+ @skip_empty_options = false
65
68
  end
66
69
 
67
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
@@ -131,7 +131,10 @@ module Truffler
131
131
  private
132
132
 
133
133
  def option_entries(tenant_key)
134
- 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)
135
138
  options = Array(options).to_h { |option| [ option, nil ] } unless options.is_a?(Hash)
136
139
  options = options.to_h { |option, value| [ option.to_s, option_entry(option, value) ] }
137
140
  raise DefinitionError, "#{key}: the option name #{NO_OPTION} is reserved" if options.key?(NO_OPTION)
@@ -14,7 +14,8 @@ module Truffler
14
14
  # Jev about them again. A spend cap stops the run before a request would
15
15
  # exceed it; host-supplied labels cost nothing, so they are still written
16
16
  # once the cap is reached. The cap counts everything spent under the
17
- # current vocabulary version in the truffler_backfill_spends ledger, so
17
+ # current ledger version (the asked labels' fingerprints, so supplied
18
+ # labels never reset it) in the truffler_backfill_spends ledger, so
18
19
  # reruns and overlapping jobs share it. Tenant-scoped models keep one
19
20
  # ledger per tenant (backfill_spend_cap_scope :tenant, the default), so
20
21
  # the cap applies to each tenant: a whole-model run skips a tenant at its
@@ -105,13 +106,16 @@ module Truffler
105
106
  new(model, tenant_key: tenant_key).status
106
107
  end
107
108
 
108
- # The ledger row for the current vocabulary version (the tenant's, for
109
- # a tenant ledger), or nil when nothing was spent yet or the ledger
110
- # table is missing.
109
+ # The ledger row for the current ledger version (the tenant's, for a
110
+ # tenant ledger), or nil when nothing was spent yet or the ledger table
111
+ # is missing. A row still keyed by the pre-0.1.6 vocabulary version
112
+ # counts until a backfill takes it over.
111
113
  def self.spend(model, tenant_key: nil)
112
114
  return unless Records::BackfillSpend.available?
113
115
 
114
- Records::BackfillSpend.for_ledger(model, tenant_key).find_by(vocabulary_version: ledger_version(model, tenant_key))
116
+ ledgers = Records::BackfillSpend.for_ledger(model, tenant_key)
117
+ ledgers.find_by(vocabulary_version: ledger_version(model, tenant_key)) ||
118
+ ledgers.find_by(vocabulary_version: legacy_ledger_version(model, tenant_key))
115
119
  end
116
120
 
117
121
  # Zeroes the current vocabulary version's ledger in place, so a chain
@@ -123,12 +127,20 @@ module Truffler
123
127
  ledgers = if all_tenants && Records::BackfillSpend.tenant_ledgers?
124
128
  Records::BackfillSpend.for_model(model).where.not(tenant_key: nil)
125
129
  else
126
- Records::BackfillSpend.for_ledger(model, tenant_key).where(vocabulary_version: ledger_version(model, tenant_key))
130
+ Records::BackfillSpend.for_ledger(model, tenant_key)
131
+ .where(vocabulary_version: [ ledger_version(model, tenant_key), legacy_ledger_version(model, tenant_key) ])
127
132
  end
128
133
  ledgers.update_all(spent_usd: 0.0, requests: 0, updated_at: Time.current)
129
134
  end
130
135
 
136
+ # Spend ledgers key on the asked labels only (Vocabulary#ledger_version),
137
+ # so a supplied-label change never resets the cap.
131
138
  def self.ledger_version(model, tenant_key = nil)
139
+ model.truffler_definition.vocabulary.ledger_version(tenant_key: tenant_key, all_users: true)
140
+ end
141
+
142
+ # What ledgers were keyed by before 0.1.6: the whole vocabulary version.
143
+ def self.legacy_ledger_version(model, tenant_key = nil)
132
144
  model.truffler_definition.vocabulary.version(tenant_key: tenant_key, all_users: true)
133
145
  end
134
146
 
@@ -220,7 +232,7 @@ module Truffler
220
232
  return [ scope.where(definition.tenant_column => @tenant_key), [ @tenant_key ] ] if @tenant_key
221
233
  return [ scope, nil ] unless Truffler.config.tenant_enabled
222
234
 
223
- tenants = scope.distinct.pluck(definition.tenant_column).map(&:to_s).select { |tenant_key| enabled?(tenant_key) }
235
+ tenants = scope.reorder(nil).distinct.pluck(definition.tenant_column).map(&:to_s).select { |tenant_key| enabled?(tenant_key) }
224
236
  [ scope.where(definition.tenant_column => tenants), tenants ]
225
237
  end
226
238
 
@@ -233,7 +245,10 @@ module Truffler
233
245
  def meter(tenant_key)
234
246
  ledger_tenant = ledger_available? ? definition.ledger_tenant(tenant_key) : nil
235
247
  @meters[ledger_tenant] ||= begin
236
- ledger = Records::BackfillSpend.ledger(model, version_for(ledger_tenant), tenant_key: ledger_tenant) if ledger_available?
248
+ if ledger_available?
249
+ ledger = Records::BackfillSpend.ledger(model, self.class.ledger_version(model, ledger_tenant), tenant_key: ledger_tenant,
250
+ legacy_version: version_for(ledger_tenant))
251
+ end
237
252
  SpendMeter.new(@client, cap: @spend_cap, spent: ledger ? 0.0 : @spent, ledger: ledger)
238
253
  end
239
254
  end
@@ -20,46 +20,48 @@ module Truffler
20
20
  end
21
21
 
22
22
  def label(states, priority:)
23
- return Result.new(labeled: 0, requests: 0, cost: 0.0, demoted: false) if states.empty?
24
-
25
- tenant_key = tenant_key_of(states)
26
- records = load_records(states, tenant_key)
27
- vocabulary = definition.vocabulary
28
- @labels = vocabulary.labels_for(tenant_key: tenant_key, all_users: true)
29
- fingerprints = vocabulary.fingerprints(tenant_key: tenant_key, all_users: true)
30
- version = vocabulary.version(tenant_key: tenant_key, all_users: true)
31
- states_by_id = states.index_by { |state| state.record_id.to_s }
32
-
33
- stored = stored_fingerprints(records)
34
- askable = askable_labels
35
- stale = records.map { |record| [ record, stale_keys(stored[record.id.to_s].to_h, fingerprints, tenant_key, askable) ] }
36
- supplied = askable.select(&:supplied?).map(&:key)
37
- supplier = Supplied.new(model)
38
- written = supplier.write(stale.map { |record, keys| [ record, keys & supplied ] }, tenant_key: tenant_key)
39
- current, pending = stale.map { |record, keys| [ record, keys - supplied ] }.partition { |_, keys| keys.empty? }
40
- settled = current.reject { |record, _| supplier.failed_ids.include?(record.id) }
41
- Records::RecordState.mark_labeled(settled.map { |record, _| states_by_id[record.id.to_s].id }, version: version)
42
- Embeddings::LabelVector.new(model).write(current.map { |record, _| record.id } - written, tenant_key: tenant_key)
43
-
44
- requests = RequestBuilder.new(definition, tenant_key: tenant_key, labels: @labels).build(pending)
45
- cost = 0.0
46
- requests.each_with_index do |request, index|
47
- decision = budget.acquire(priority: priority, tenant_key: (tenant_key if index.zero?),
48
- records: index.zero? ? pending.size : 1)
49
- if decision.demoted?
50
- retry_failed_supplied(supplier.failed_ids, states_by_id)
51
- return Result.new(labeled: current.size, requests: index, cost: cost, demoted: true)
23
+ Current.scope do
24
+ return Result.new(labeled: 0, requests: 0, cost: 0.0, demoted: false) if states.empty?
25
+
26
+ tenant_key = tenant_key_of(states)
27
+ records = load_records(states, tenant_key)
28
+ vocabulary = definition.vocabulary
29
+ @labels = vocabulary.labels_for(tenant_key: tenant_key, all_users: true)
30
+ fingerprints = vocabulary.fingerprints(tenant_key: tenant_key, all_users: true)
31
+ version = vocabulary.version(tenant_key: tenant_key, all_users: true)
32
+ states_by_id = states.index_by { |state| state.record_id.to_s }
33
+
34
+ stored = stored_fingerprints(records)
35
+ askable = askable_labels
36
+ stale = records.map { |record| [ record, stale_keys(stored[record.id.to_s].to_h, fingerprints, tenant_key, askable) ] }
37
+ supplied = askable.select(&:supplied?).map(&:key)
38
+ supplier = Supplied.new(model)
39
+ written = supplier.write(stale.map { |record, keys| [ record, keys & supplied ] }, tenant_key: tenant_key)
40
+ current, pending = stale.map { |record, keys| [ record, keys - supplied ] }.partition { |_, keys| keys.empty? }
41
+ settled = current.reject { |record, _| supplier.failed_ids.include?(record.id) }
42
+ Records::RecordState.mark_labeled(settled.map { |record, _| states_by_id[record.id.to_s].id }, version: version)
43
+ Embeddings::LabelVector.new(model).write(current.map { |record, _| record.id } - written, tenant_key: tenant_key)
44
+
45
+ requests = RequestBuilder.new(definition, tenant_key: tenant_key, labels: @labels).build(pending)
46
+ cost = 0.0
47
+ requests.each_with_index do |request, index|
48
+ decision = budget.acquire(priority: priority, tenant_key: (tenant_key if index.zero?),
49
+ records: index.zero? ? pending.size : 1)
50
+ if decision.demoted?
51
+ retry_failed_supplied(supplier.failed_ids, states_by_id)
52
+ return Result.new(labeled: current.size, requests: index, cost: cost, demoted: true)
53
+ end
54
+ raise BudgetExhausted.new("no Jev budget for #{priority} labeling", retry_after: decision.retry_after) if decision.denied?
55
+
56
+ answers = client.ask(state: request.state, questions: request.questions, priority: decision.priority)
57
+ cost += answers.usage&.cost.to_f
58
+ charge_lenses(request, answers.usage&.cost.to_f)
59
+ store(request, answers, fingerprints, tenant_key, version, states_by_id)
52
60
  end
53
- raise BudgetExhausted.new("no Jev budget for #{priority} labeling", retry_after: decision.retry_after) if decision.denied?
54
61
 
55
- answers = client.ask(state: request.state, questions: request.questions, priority: decision.priority)
56
- cost += answers.usage&.cost.to_f
57
- charge_lenses(request, answers.usage&.cost.to_f)
58
- store(request, answers, fingerprints, tenant_key, version, states_by_id)
62
+ retry_failed_supplied(supplier.failed_ids, states_by_id)
63
+ Result.new(labeled: records.size, requests: requests.size, cost: cost, demoted: false)
59
64
  end
60
-
61
- retry_failed_supplied(supplier.failed_ids, states_by_id)
62
- Result.new(labeled: records.size, requests: requests.size, cost: cost, demoted: false)
63
65
  end
64
66
 
65
67
  private
@@ -51,50 +51,55 @@ module Truffler
51
51
  end
52
52
 
53
53
  def request(model, query, tenant_key:, user_key: nil)
54
- labels = labels(model, tenant_key, user_key)
55
- questions = Questions.new
56
- labels.each_value do |label|
57
- questions.choice(:"intent__#{label.question_key}", instructions: intent_instructions(label), criteria: INTENTS)
58
- end
59
- labels.each_value do |label|
60
- next unless label.type == :choice
54
+ Current.scope do
55
+ present = present_options(model, tenant_key, user_key)
56
+ labels = labels(model, tenant_key, user_key, present)
57
+ questions = Questions.new
58
+ labels.each_value do |label|
59
+ questions.choice(:"intent__#{label.question_key}", instructions: intent_instructions(label), criteria: INTENTS)
60
+ end
61
+ labels.each_value do |label|
62
+ next unless label.type == :choice
61
63
 
62
- options = label.options(tenant_key).merge(NO_OPTION => "The query names none of these")
63
- questions.choice(:"option__#{label.question_key}", instructions: %(Which "#{label.key}" option does the search query ask about?),
64
- criteria: options)
65
- end
64
+ options = PresentOptions.options(label, tenant_key, present).merge(NO_OPTION => "The query names none of these")
65
+ questions.choice(:"option__#{label.question_key}", instructions: %(Which "#{label.key}" option does the search query ask about?),
66
+ criteria: options)
67
+ end
66
68
 
67
- words = query.tokens.each_with_index.reject do |token, position|
68
- query.exact_tokens.include?(token) || query.time_position?(position)
69
- end
70
- asked = words.first(MAX_TOKEN_QUESTIONS)
71
- token_ids = asked.to_h do |_token, position|
72
- id = :"token__#{position}"
73
- questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]? The labels it may name, ) +
74
- %(with their options, are in `labels`.), criteria: TOKEN_ROLES)
75
- [ position, id.to_s ]
76
- end
69
+ words = query.tokens.each_with_index.reject do |token, position|
70
+ query.exact_tokens.include?(token) || query.time_position?(position)
71
+ end
72
+ asked = words.first(MAX_TOKEN_QUESTIONS)
73
+ token_ids = asked.to_h do |_token, position|
74
+ id = :"token__#{position}"
75
+ questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]? The labels it may name, ) +
76
+ %(with their options, are in `labels`.), criteria: TOKEN_ROLES)
77
+ [ position, id.to_s ]
78
+ end
77
79
 
78
- state = { "query" => query.normalized, "tokens" => query.tokens, "labels" => vocabulary_state(labels, tenant_key) }
79
- Request.new(state: state, questions: questions.to_h, token_ids: token_ids, exact_tokens: query.exact_tokens,
80
- unasked_tokens: words.drop(MAX_TOKEN_QUESTIONS).map(&:first))
80
+ state = { "query" => query.normalized, "tokens" => query.tokens, "labels" => vocabulary_state(labels, tenant_key, present) }
81
+ Request.new(state: state, questions: questions.to_h, token_ids: token_ids, exact_tokens: query.exact_tokens,
82
+ unasked_tokens: words.drop(MAX_TOKEN_QUESTIONS).map(&:first))
83
+ end
81
84
  end
82
85
 
83
86
  # Encodes the query pending under `cache_key`. Returns the encoding, or
84
87
  # nil when the payload expired, the vocabulary moved on, or the encode
85
88
  # budget was denied (a silent skip). Always releases the in-flight marker.
86
89
  def encode(cache_key)
87
- pending = cache.read_payload(cache_key)
88
- return unless pending
89
-
90
- model, query, tenant_key, user_key = pending.values_at(:model, :query, :tenant_key, :user_key)
91
- return unless cache.key(model, query, tenant_key: tenant_key, user_key: user_key) == cache_key
92
-
93
- encoding = cache.encoded?(cache_key) ? cache.read_encoding(cache_key, query) : encode_labels(model, query, tenant_key, user_key)
94
- embed_query(model, query, tenant_key)
95
- encoding
96
- ensure
97
- cache.release(cache_key)
90
+ Current.scope do
91
+ pending = cache.read_payload(cache_key)
92
+ return unless pending
93
+
94
+ model, query, tenant_key, user_key = pending.values_at(:model, :query, :tenant_key, :user_key)
95
+ return unless cache.key(model, query, tenant_key: tenant_key, user_key: user_key) == cache_key
96
+
97
+ encoding = cache.encoded?(cache_key) ? cache.read_encoding(cache_key, query) : encode_labels(model, query, tenant_key, user_key)
98
+ embed_query(model, query, tenant_key)
99
+ encoding
100
+ ensure
101
+ cache.release(cache_key)
102
+ end
98
103
  end
99
104
 
100
105
  # Polls the cache until the encoding lands or the deadline (seconds
@@ -118,8 +123,9 @@ module Truffler
118
123
  boosts = {}
119
124
  intent = {}
120
125
  names = {}
121
- labels(model, tenant_key, user_key).each_value do |label|
122
- key = storage_key(label, answers, tenant_key)
126
+ present = present_options(model, tenant_key, user_key)
127
+ labels(model, tenant_key, user_key, present).each_value do |label|
128
+ key = storage_key(label, answers, tenant_key, present)
123
129
  next unless key
124
130
 
125
131
  case answers.choice("intent__#{label.question_key}")
@@ -175,25 +181,41 @@ module Truffler
175
181
  cache.write_vector(model, query, vector, tenant_key: tenant_key)
176
182
  end
177
183
 
178
- def labels(model, tenant_key, user_key)
184
+ # The searcher's vocabulary; with skip_empty_options, minus choice
185
+ # labels the tenant has no option rows for (see PresentOptions).
186
+ def labels(model, tenant_key, user_key, present = present_options(model, tenant_key, user_key))
187
+ labels = vocabulary_labels(model, tenant_key, user_key)
188
+ return labels unless present
189
+
190
+ labels.select { |_, label| label.type != :choice || PresentOptions.options(label, tenant_key, present).any? }
191
+ end
192
+
193
+ def vocabulary_labels(model, tenant_key, user_key)
179
194
  model.truffler_definition.vocabulary.labels_for(tenant_key: tenant_key, user_key: user_key)
180
195
  end
181
196
 
197
+ def present_options(model, tenant_key, user_key)
198
+ return unless PresentOptions.enabled?
199
+
200
+ PresentOptions.new.keys(model, vocabulary_labels(model, tenant_key, user_key), tenant_key: tenant_key)
201
+ end
202
+
182
203
  # The label's storage key the query names, or nil for a choice label
183
204
  # whose option answer is NO_OPTION.
184
- def storage_key(label, answers, tenant_key)
205
+ def storage_key(label, answers, tenant_key, present)
185
206
  return label.key unless label.type == :choice
186
207
 
187
208
  option = answers.choice("option__#{label.question_key}")
188
- "#{label.key}:#{option}" if option != NO_OPTION && label.options(tenant_key).key?(option)
209
+ "#{label.key}:#{option}" if option != NO_OPTION && PresentOptions.options(label, tenant_key, present).key?(option)
189
210
  end
190
211
 
191
- def vocabulary_state(labels, tenant_key)
212
+ def vocabulary_state(labels, tenant_key, present)
192
213
  labels.transform_values do |label|
193
214
  entry = { "description" => label.description }
194
215
  if label.type == :choice
195
- entry["options"] = label.options(tenant_key).keys
196
- names = label.option_names(tenant_key)
216
+ options = PresentOptions.options(label, tenant_key, present).keys
217
+ entry["options"] = options
218
+ names = label.option_names(tenant_key).slice(*options)
197
219
  entry["option_names"] = names if names.any?
198
220
  end
199
221
  entry