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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28c9f27b6811a398815e7cc1026b0d1ed0ef1caf546bd820a59be4efdd4dcffa
4
- data.tar.gz: 1889959cc8dfd3f32af19a37dda640673e08d6459f5a5e7722747cd32a7422fd
3
+ metadata.gz: 99d7f61714bf653e012b5cea1f9d57cfbde9d390aae048ba0d52387292a37159
4
+ data.tar.gz: b9826342d2552dae2b1cd9d671ac4d4d448f5fcbe3822b21e5788b7d272365b0
5
5
  SHA512:
6
- metadata.gz: 76974d97ae716de75fc3cc1992868c2cabbd94c6851d6a85267fb5c3c1d18ca21f2f998c6ae1d135192081bd7b544351801fc88d33cde4070c56464a01be4d98
7
- data.tar.gz: 8a35786e72b7e5b2996ce915da8419cb8d0d0873c8839829155b7d831f043caf4df9a9be29b9219f75d2403abbbd2c915a09b06b31e0135566a0dbe53f14774e
6
+ metadata.gz: 414ddacdd2e195a077792a36b3c1e8050a8e086a99ccb7ffed065776826f6af2c83da47835ce36d663db166b664e2559a01f2f92dcc6c493e120bbcd60ae89f8
7
+ data.tar.gz: 272d2d762fdcd5e0f6a6d0cec08ca332619a14e6ab8b3b94a5f2670e16091bf0c75395866a5a77ede42e7d22483a95252cbf6b0cee3565cacfb91d2ae5c17569
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.2]
4
+
5
+ Fixes from happyhappy's production `churn_risk` backfill and its 0.1.1 upgrade.
6
+
7
+ - `rake truffler:backfill` waits out budget denials instead of ending on `budget_denied`. It backs off 1 s, doubling to 30 s (longer when the budget's new `Decision#retry_after` hint says so), and retries from the same cursor until the backfill completes or reaches the spend cap. `MAX_DURATION=<seconds>` stops it with `paused` and the cursor. While waiting it prints counts, spend, and the cursor, never record text. In code: `Labeling::Backfill#run(wait: true, max_duration:, sleeper:, clock:, progress:)`. `BackfillJob` reschedules denials with the same backoff (it used to wait a fixed 30 s), counting consecutive denials in its arguments.
8
+ - The backfill spend cap holds across runs. Spend is recorded per model and app-wide vocabulary version in a new `truffler_backfill_spends` table. Each Jev request reserves its estimate against the cap in SQL, so rerunning the rake task or overlapping `BackfillJob` chains can no longer spend more than `backfill_spend_cap` for one vocabulary version. Before, overlapping chains could spend up to twice the cap. A vocabulary change starts a new ledger. `truffler:status` prints the spend for the current version, and `RESET_SPEND=1` zeroes it before a backfill. Lens backfills still charge only their lens row.
9
+ - Upgrading from 0.1.1: run `bin/rails generate truffler:upgrade && bin/rails db:migrate` to add `truffler_backfill_spends`. Until you do, backfills log one warning and cap spend per run as in 0.1.1. Fresh installs get the table from `truffler:install`.
10
+ - Time phrases now include hours ("last 3 hours", "past hour", "last hour"), "past week", "past month", and "past/last N months" as rolling windows ending now; "this/last week" and "this/last month" keep their calendar meaning. Chips and suppression are unchanged.
11
+ - A query word now names a choice option by the words of its display name or description (minus stopwords), not only its key, so "spiral" names option `p_17` described as "Spiral writing tool" when the encoding applies it. The request state's label vocabulary adds `option_names` (option key to display name) next to `options`.
12
+
13
+ ## [0.1.1]
14
+
15
+ Fixes from the first host integration (happyhappy).
16
+
17
+ - Live queries no longer return zero results next to a label filter. Query encoding sends the label vocabulary (descriptions and choice option names) in the request state; a keyword that names an applied label or its option (including a shared first-three-letter stem, so "angry" names `anger`) becomes a label term and common stopwords become filler; and when a label filter applies, keyword hits only add to the score instead of being required.
18
+ - Declaring `truffler` on a model whose table does not exist yet (a fresh database during `db:prepare`) no longer raises. Column checks run on the first labeling or search instead and raise `DefinitionError` then if columns are still unknown.
19
+ - Query encoding's "no option" answer is the reserved `Truffler::NO_OPTION` (`"truffler:none"`), so a host choice option named `none` can be filtered. Host options may not use the reserved name.
20
+ - Time phrases (`today`, `yesterday`, `this/last week`, `this/last month`, `past/last N days/weeks`, `since <weekday>`) are parsed locally, never asked of Jev or matched as keywords, and limit results on the `arrived_at` column. They show as a removable `kind: :time` chip (suppress `"time"`); keystroke search accepts a `clock:` for tests.
21
+ - `watch:` works on asked labels, not only on `from:` labels, and a model-level `watch :column, ...` relabels every label when those columns change, for `reads` fields backed by methods.
22
+ - `Clients::Fake` answers unscripted choices with their neutral option (`ignore`, `Truffler::NO_OPTION`, `keyword`) when present, so unscripted host tests no longer filter on every label.
23
+ - `config.backfill_spend_cap` defaults to 5.0 USD for `BackfillJob`, `ResumeJob` backfills, and `rake truffler:backfill`. Set it to `nil`, or pass `SPEND_CAP=none`, to disable it; an unparseable `SPEND_CAP` aborts.
24
+ - A per-tenant choice whose options callable returns `{}` or nil for a tenant is left out of that tenant's vocabulary (not asked, not encoded) instead of raising.
25
+
3
26
  ## 0.1.0
4
27
 
5
28
  - `truffler do ... end` declarations on Active Record models: tenant, fields, noul/choice/score labels, keyword and exact sources, embeddings, a provider, surfaces, ranking weights, and `weak_below`.
data/README.md CHANGED
@@ -34,12 +34,23 @@ bin/rails db:migrate
34
34
 
35
35
  The generator writes three files:
36
36
 
37
- - A migration that creates `truffler_labels`, `truffler_record_states`, `truffler_embeddings`, `truffler_query_misses`, `truffler_lenses`, and `truffler_lens_versions`.
37
+ - A migration that creates `truffler_labels`, `truffler_record_states`, `truffler_embeddings`, `truffler_query_misses`, `truffler_lenses`, `truffler_lens_versions`, and `truffler_backfill_spends`.
38
38
  - `config/initializers/truffler.rb`.
39
39
  - `app/channels/truffler_channel.rb`.
40
40
 
41
41
  It takes two options. `--record-id-type=bigint|integer|string|uuid` must match your primary keys. `--vector-dimensions=N` stores embeddings in a pgvector column of that width; it needs Postgres and the `neighbor` gem, which provides the `t.vector` column type.
42
42
 
43
+ ### Upgrading from 0.1.1
44
+
45
+ 0.1.2 adds the `truffler_backfill_spends` table, which keeps the backfill spend cap across runs. Add it with:
46
+
47
+ ```bash
48
+ bin/rails generate truffler:upgrade
49
+ bin/rails db:migrate
50
+ ```
51
+
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
+
43
54
  Truffler digests user keys and query misses with `secret_key_base`. Rails supplies it automatically; outside Rails, set `config.secret_key_base`.
44
55
 
45
56
  ## Declaring a model
@@ -80,7 +91,8 @@ end
80
91
  Here is what each option does:
81
92
 
82
93
  - `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.
83
- - Choice `options:` may be a callable of the tenant key, which gives each tenant its own vocabulary.
94
+ - 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).
95
+ - `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`.
84
96
  - `keyword` also accepts a single callable, `->(scope, tokens) { relation }`.
85
97
  - `embeddings column: :my_vector` searches a vector column you maintain yourself. Truffler never writes it.
86
98
  - `embeddings` refuses to send encrypted fields to the embedding provider unless you pass `allow_encrypted: true`.
@@ -96,12 +108,12 @@ Here is what each option does:
96
108
  | `filter_at:`, `boost:`, `filter_weight:` | all | Filter threshold, boost weight, and the intent weight a filter adds. |
97
109
  | `description:` | all | The label's wording in query encoding. Defaults to the question, then to the key. |
98
110
  | `from:` | all | `->(record) { answer }`. Makes the label host-supplied; Jev is never asked. See [Labels you already compute](#labels-you-already-compute). |
99
- | `watch:` | with `from:` | Extra columns whose change refreshes the label, in addition to `reads`. |
111
+ | `watch:` | all | Extra columns whose change refreshes (or, for an asked label, re-asks) just this label, in addition to `reads`. |
100
112
  | `version:` | with `from:` | Any value; changing it rewrites the label for every record on the next backfill. |
101
113
 
102
114
  Keys must be lowercase snake case without a double underscore, and `lens` is reserved.
103
115
 
104
- Saving a record enqueues labeling (and embedding, when enabled) after commit, but only when a field in `reads`, the tenant column, or a supplied label's `watch:` column changed. Destroying a record removes its labels, state, and embeddings.
116
+ Saving a record enqueues labeling (and embedding, when enabled) after commit, but only when a column-backed field in `reads`, the tenant column, a model-level `watch` column, or a label's `watch:` column changed. Destroying a record removes its labels, state, and embeddings.
105
117
 
106
118
  ### Labels you already compute
107
119
 
@@ -171,10 +183,14 @@ result = Email.truffler(params[:q], tenant: Current.account.id, scope: Current.a
171
183
 
172
184
  A keystroke search makes no network call. It reads the query encoding and query vector from the cache. When the cache misses, it enqueues `EncodeQueryJob`, and the next keystroke or reload picks up the result.
173
185
 
186
+ Query encoding sends Jev the label vocabulary (each label's description and a choice label's option names) next to the query, and asks for each label whether the query filters on it, prefers it, or ignores it, plus the role of each word. A choice label's option question also offers `Truffler::NO_OPTION` (`"truffler:none"`), meaning the query names none of its options, so a host option literally called `none` stays filterable. Word roles are then checked locally: a word that names a label the query applies (its key, a word of its key, the chosen option, or a word of that option's display name or description, ignoring case and plurals, or a word of four letters or more sharing its first three letters, so "angry" names `anger`) counts as naming the label, and common stopwords are dropped. When the encoding applies a label filter, the filter decides which records match and keyword hits only rank them; without one, the remaining keywords must match.
187
+
188
+ 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).
189
+
174
190
  The returned `Truffler::Search::Result` exposes:
175
191
 
176
192
  - `records` and `ids`.
177
- - `chips`: `[{key:, label:, kind: :filter | :boost, name:}]`.
193
+ - `chips`: `[{key:, label:, kind: :filter | :boost | :time, name:}]`.
178
194
  - `invite_row`: `{query:, reason: :weak | :empty | :encoding_pending}` or nil.
179
195
  - `encoding_status`: `:cached`, `:pending`, or `:none`.
180
196
  - `watermark` and `new_matches_count`.
@@ -322,7 +338,7 @@ Set these in `Truffler.configure do |config| ... end`.
322
338
  | `max_field_chars`, `request_token_budget`, `max_questions_per_request` | 4,000, 48,000, 200 | Request packing limits. |
323
339
  | `queue_name` | `:default` | Queue for every Truffler job. |
324
340
  | `cost_per_million_tokens` | 0.042 | Jev input price, used in usage events and estimates. |
325
- | `backfill_spend_cap` | nil | Dollar cap per backfill run. Supplied labels cost nothing and are still written once it is reached. |
341
+ | `backfill_spend_cap` | 5.0 | Dollar cap on backfill spend per model and vocabulary version, shared by `BackfillJob` chains, `ResumeJob` backfills, and `truffler:backfill` runs (see Backfill). `nil` disables it; for the rake task, `SPEND_CAP=none` does. Supplied labels cost nothing and are still written once it is reached. |
326
342
  | `resume_pending_after` | 5 minutes | How long before `ResumeJob` treats work as stuck. |
327
343
  | `embedder` | `Embeddings::RubyLLMEmbedder.new` | Any `Embeddings::Embedder` subclass. The default calls `RubyLLM.embed`, which works on ruby_llm 1.x and 2. |
328
344
  | `embedding_cost_per_million_tokens` | 0.02 | Embedding price. |
@@ -348,10 +364,10 @@ Truffler enqueues most of its own jobs. Run a worker for `config.queue_name` and
348
364
  | `Truffler::Jobs::ResumeJob` | Every few minutes. Requeues failed and stuck labeling after an outage or a crashed worker, and enqueues up to 1,000 missing or stale embeddings per model per hour. |
349
365
  | `Truffler::Jobs::PruneQueryMissesJob` | Daily. Enforces `miss_retention`. |
350
366
  | `Truffler::Jobs::ExpireLensesJob` | Daily. Expires lenses unused for `lenses.expire_after`. |
351
- | `bin/rails "truffler:backfill[Email]"` (`SPEND_CAP=5`) or `Truffler::Jobs::BackfillJob.perform_later("Email")` | After adopting Truffler, changing a declaration, or changing the model pin. |
367
+ | `bin/rails "truffler:backfill[Email]"` (see Backfill) or `Truffler::Jobs::BackfillJob.perform_later("Email")` | After adopting Truffler, changing a declaration, or changing the model pin. |
352
368
  | `Truffler::Embeddings::Backfill.new(Email).enqueue` | After enabling embeddings or changing the embedding model, width, or fields, to re-embed everything now instead of through the `ResumeJob` sweep. It enqueues 1,000 jobs at a time; pass `limit:` to cap the total. |
353
369
 
354
- `bin/rails "truffler:status[Email]"` prints labeling counts. `bin/rails "truffler:suggestions[Email]"` prints candidate questions drawn from logged query misses.
370
+ `bin/rails "truffler:status[Email]"` prints labeling counts and the backfill spend for the current vocabulary version. `bin/rails "truffler:suggestions[Email]"` prints candidate questions drawn from logged query misses.
355
371
 
356
372
  With Solid Queue, for example:
357
373
 
@@ -368,6 +384,18 @@ truffler_expire_lenses:
368
384
  schedule: every day at 3am
369
385
  ```
370
386
 
387
+ ### Backfill
388
+
389
+ `bin/rails "truffler:backfill[Email]"` labels missing, stale, and failed records inline, newest first, at backfill priority. Backfill has the lowest share of the request budget, so the task waits whenever the budget turns it away. It backs off 1 s, doubling to 30 s (or longer if the budget says a slot frees later), and resumes from the same cursor. It ends when every record is current (`complete`) or the spend cap is reached (`spend_cap_reached`). While it waits it prints the records labeled, the spend, and the cursor, never record text. Environment variables:
390
+
391
+ | Variable | Effect |
392
+ |---|---|
393
+ | `SPEND_CAP=20` or `none` | Overrides `backfill_spend_cap` for this run. |
394
+ | `MAX_DURATION=600` | Stops after that many seconds with `paused` and the cursor. Rerun to continue. |
395
+ | `RESET_SPEND=1` | Zeroes the spend recorded for the current vocabulary version before starting. |
396
+
397
+ 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.
398
+
371
399
  ## Privacy
372
400
 
373
401
  These guarantees hold for every model, and matter most for models that use Active Record encryption:
@@ -423,3 +451,5 @@ bundle exec rubocop
423
451
  ```
424
452
 
425
453
  Tests run on in-memory SQLite with fake clients. Any live Jev call raises `Truffler::LiveCallInTest`.
454
+
455
+ In your own tests, `Truffler::Clients::Fake` answers unscripted questions neutrally: nouls no, scores the lowest level, and choices their neutral option when they have one (`ignore` for a query's label intent, `Truffler::NO_OPTION` for an option question, `keyword` for a word role), otherwise the first option. An unscripted query encoding therefore applies no labels; script the ones a test needs, e.g. `fake.answer("intent__needs_action", "filter")`.
@@ -105,5 +105,15 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
105
105
  end
106
106
  add_index :truffler_lens_versions, [:lens_id, :number], unique: true,
107
107
  name: "index_truffler_lens_versions_on_lens_and_number"
108
+
109
+ create_table :truffler_backfill_spends do |t|
110
+ t.string :record_type, null: false
111
+ t.string :vocabulary_version, null: false
112
+ t.float :spent_usd, null: false, default: 0.0
113
+ t.integer :requests, null: false, default: 0
114
+ t.timestamps
115
+ end
116
+ add_index :truffler_backfill_spends, [:record_type, :vocabulary_version], unique: true,
117
+ name: "index_truffler_backfill_spends_on_ledger"
108
118
  end
109
119
  end
@@ -0,0 +1,19 @@
1
+ class CreateTrufflerBackfillSpends < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ return if table_exists?(:truffler_backfill_spends)
4
+
5
+ create_table :truffler_backfill_spends do |t|
6
+ t.string :record_type, null: false
7
+ t.string :vocabulary_version, null: false
8
+ t.float :spent_usd, null: false, default: 0.0
9
+ t.integer :requests, null: false, default: 0
10
+ t.timestamps
11
+ end
12
+ add_index :truffler_backfill_spends, [:record_type, :vocabulary_version], unique: true,
13
+ name: "index_truffler_backfill_spends_on_ledger"
14
+ end
15
+
16
+ def down
17
+ drop_table :truffler_backfill_spends, if_exists: true
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Truffler
5
+ module Generators
6
+ # Adds the tables a newer Truffler needs to an app installed with an older
7
+ # one. Each migration skips a table that already exists.
8
+ class UpgradeGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def create_backfill_spends_migration
14
+ migration_template "backfill_spends_migration.rb.tt", File.join(db_migrate_path, "create_truffler_backfill_spends.rb")
15
+ end
16
+
17
+ private
18
+
19
+ def migration_version
20
+ "[#{ActiveRecord::Migration.current_version}]"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -9,12 +9,42 @@ namespace :truffler do
9
9
  model
10
10
  end
11
11
 
12
- desc "Backfill stale, missing, and failed labels for a model (SPEND_CAP=dollars)"
12
+ resolve_spend_cap = lambda do |value|
13
+ case value.to_s.strip.downcase
14
+ when "" then Truffler.config.backfill_spend_cap
15
+ when "none" then nil
16
+ else Float(value, exception: false) || abort("SPEND_CAP must be a dollar amount or none, got #{value.inspect}")
17
+ end
18
+ end
19
+
20
+ resolve_max_duration = lambda do |value|
21
+ next if value.to_s.strip.empty?
22
+
23
+ seconds = Float(value, exception: false)
24
+ abort "MAX_DURATION must be a number of seconds, got #{value.inspect}" unless seconds&.positive?
25
+ seconds
26
+ end
27
+
28
+ describe_cursor = ->(cursor) { cursor.nil? ? "none" : cursor }
29
+
30
+ desc "Backfill stale, missing, and failed labels for a model, waiting out budget denials " \
31
+ "(SPEND_CAP=dollars or none; default config.backfill_spend_cap; MAX_DURATION=seconds; RESET_SPEND=1 for a fresh spend ledger)"
13
32
  task :backfill, [ :model ] => :setup do |_, args|
14
33
  model = resolve_model.call(args[:model])
15
- spend_cap = ENV["SPEND_CAP"].presence&.to_f || Truffler.config.backfill_spend_cap
16
- result = Truffler::Labeling::Backfill.new(model, spend_cap: spend_cap).run
17
- puts "#{model.name}: #{result.status}, #{result.labeled} labeled in #{result.requests} requests, $#{format('%.6f', result.cost)}"
34
+ spend_cap = resolve_spend_cap.call(ENV.fetch("SPEND_CAP", nil))
35
+ max_duration = resolve_max_duration.call(ENV.fetch("MAX_DURATION", nil))
36
+ if ENV.fetch("RESET_SPEND", nil) == "1"
37
+ Truffler::Labeling::Backfill.reset_spend!(model)
38
+ puts "#{model.name}: fresh spend ledger for the current vocabulary version"
39
+ end
40
+ progress = lambda do |so_far, delay|
41
+ puts "#{model.name}: waiting #{format('%.1f', delay)}s for backfill budget (#{so_far.labeled} labeled, " \
42
+ "$#{format('%.6f', so_far.cost)} spent, cursor #{describe_cursor.call(so_far.cursor)})"
43
+ end
44
+ result = Truffler::Labeling::Backfill.new(model, spend_cap: spend_cap).run(wait: true, max_duration: max_duration, progress: progress)
45
+ summary = "#{model.name}: #{result.status}, #{result.labeled} labeled in #{result.requests} requests, $#{format('%.6f', result.cost)}"
46
+ summary += ", cursor #{describe_cursor.call(result.cursor)}" if result.status == :paused
47
+ puts summary
18
48
  end
19
49
 
20
50
  desc "Print a model's labeling counts by status and staleness"
@@ -22,5 +52,13 @@ namespace :truffler do
22
52
  model = resolve_model.call(args[:model])
23
53
  puts model.name
24
54
  Truffler::Labeling::Backfill.status(model).each { |key, count| puts format(" %-9s %d", key, count) }
55
+ if Truffler::Records::BackfillSpend.available?
56
+ ledger = Truffler::Labeling::Backfill.spend(model)
57
+ cap = Truffler.config.backfill_spend_cap
58
+ puts format(" %-9s $%.6f in %d requests (vocabulary %s, cap %s)", "spent", ledger&.spent_usd.to_f, ledger&.requests.to_i,
59
+ Truffler::Labeling::Backfill.ledger_version(model).first(12), cap ? format("$%.2f", cap) : "none")
60
+ else
61
+ puts format(" %-9s %s", "spent", "not tracked across runs; run bin/rails g truffler:upgrade && bin/rails db:migrate")
62
+ end
25
63
  end
26
64
  end
@@ -8,12 +8,17 @@ module Truffler
8
8
  #
9
9
  # Outcomes: :granted takes a slot; :demoted means a tenant is over its live
10
10
  # cap and its records should wait at backfill priority (no slot is taken);
11
- # :denied means skip, pause, or reschedule. Only live callers wait, up to
12
- # max_wait. A cache that cannot count (the null store) never blocks.
11
+ # :denied means skip, pause, or reschedule, with `retry_after` seconds until
12
+ # the denying window rolls over. Only live callers wait, up to max_wait. A
13
+ # cache that cannot count (the null store) never blocks.
13
14
  class Budget
14
15
  PRIORITIES = %i[live encode rerank backfill].freeze
15
16
 
16
- Decision = Data.define(:outcome, :priority, :reason) do
17
+ Decision = Data.define(:outcome, :priority, :reason, :retry_after) do
18
+ def initialize(outcome:, priority:, reason:, retry_after: nil)
19
+ super
20
+ end
21
+
17
22
  def granted? = outcome == :granted
18
23
  def demoted? = outcome == :demoted
19
24
  def denied? = outcome == :denied
@@ -38,9 +43,9 @@ module Truffler
38
43
  return Decision.new(:demoted, :backfill, :tenant_cap) unless take(tenant_counter(tenant_key), records, config.tenant_live_cap, taken)
39
44
  end
40
45
  if (cap = config.user_caps[priority]) && user_key
41
- return deny(priority, :user_cap, taken) unless take(user_counter(priority, user_key), 1, cap, taken)
46
+ return deny(priority, :user_cap, taken, until_next_minute) unless take(user_counter(priority, user_key), 1, cap, taken)
42
47
  end
43
- return deny(priority, :exhausted, taken) unless take_second(priority)
48
+ return deny(priority, :exhausted, taken, until_next_second) unless take_second(priority)
44
49
 
45
50
  Decision.new(:granted, priority, nil)
46
51
  end
@@ -54,7 +59,7 @@ module Truffler
54
59
 
55
60
  cap = config.user_caps[priority]
56
61
  return Decision.new(:granted, priority, nil) unless cap && user_key
57
- return deny(priority, :user_cap, []) unless take(user_counter(priority, user_key), 1, cap)
62
+ return deny(priority, :user_cap, [], until_next_minute) unless take(user_counter(priority, user_key), 1, cap)
58
63
 
59
64
  Decision.new(:granted, priority, nil)
60
65
  end
@@ -97,10 +102,20 @@ module Truffler
97
102
  end
98
103
  end
99
104
 
100
- def deny(priority, reason, taken)
105
+ def deny(priority, reason, taken, retry_after)
101
106
  taken.each { |key, amount| @cache.decrement(key, amount, expires_in: 2.minutes) }
102
107
  Instrumentation.instrument(:budget_denied, priority: priority)
103
- Decision.new(:denied, priority, reason)
108
+ Decision.new(:denied, priority, reason, retry_after)
109
+ end
110
+
111
+ def until_next_second
112
+ now = @clock.call
113
+ now.floor + 1 - now
114
+ end
115
+
116
+ def until_next_minute
117
+ now = @clock.call
118
+ (minute + 1) * 60 - now
104
119
  end
105
120
 
106
121
  def tenant_counter(tenant_key)
@@ -9,8 +9,14 @@ module Truffler
9
9
  # fake.answer(:urgency, 2) # score level
10
10
  # fake.answer(:spam) { |tag, state| state.dig("records", tag, "body").include?("$$$") ? 0.9 : 0.1 }
11
11
  #
12
- # Unscripted questions answer no, the first option, or the lowest level.
12
+ # Unscripted questions answer no, the lowest level, or for a choice its
13
+ # neutral option when it has one (`ignore` for a query-encoding intent,
14
+ # `Truffler::NO_OPTION` for an option question, `keyword` for a word
15
+ # role), else the first option. So an unscripted query encoding applies
16
+ # no label.
13
17
  class Fake < Base
18
+ NEUTRAL_OPTIONS = [ "ignore", NO_OPTION, "keyword" ].freeze
19
+
14
20
  attr_reader :calls
15
21
 
16
22
  def initialize(model: nil, &default)
@@ -67,7 +73,7 @@ module Truffler
67
73
  probabilities = options.to_h { |option| [ option, 0.0 ] }
68
74
  case value
69
75
  when Hash then probabilities.merge!(value.transform_keys(&:to_s).transform_values(&:to_f))
70
- when nil then probabilities[options.first] = 1.0
76
+ when nil then probabilities[(NEUTRAL_OPTIONS & options).first || options.first] = 1.0
71
77
  else probabilities[value.to_s] = 1.0
72
78
  end
73
79
  pick, confidence = probabilities.max_by { |_, probability| probability }
@@ -3,6 +3,7 @@ module Truffler
3
3
  DEFAULT_REQUESTS_PER_MINUTE = 1_200
4
4
  DEFAULT_PRIORITY_CEILINGS = { live: 1.0, encode: 0.9, rerank: 0.75, backfill: 0.5 }.freeze
5
5
  DEFAULT_USER_CAPS = { encode: 30, rerank: 10 }.freeze
6
+ DEFAULT_BACKFILL_SPEND_CAP = 5.0
6
7
 
7
8
  attr_accessor :model, :cost_per_million_tokens, :requests_per_minute, :headroom, :priority_ceilings,
8
9
  :user_caps, :tenant_live_cap, :max_wait, :batch_size, :grouping_window, :max_attempts,
@@ -35,7 +36,7 @@ module Truffler
35
36
  @queue_name = :default
36
37
  @miss_retention = 30.days
37
38
  @miss_min_distinct_users = 5
38
- @backfill_spend_cap = nil
39
+ @backfill_spend_cap = DEFAULT_BACKFILL_SPEND_CAP
39
40
  @resume_pending_after = 5.minutes
40
41
  @vector_store = :auto
41
42
  @embedding_cost_per_million_tokens = 0.02
@@ -4,7 +4,7 @@ module Truffler
4
4
  EXPLICIT_ACTIONS = %i[enter key row].freeze
5
5
  DEFAULT_EMBEDDINGS = { model: "text-embedding-3-small", dimensions: 256 }.freeze
6
6
 
7
- attr_reader :model, :fields, :labels, :exact_sources, :providers, :surfaces
7
+ attr_reader :model, :fields, :labels, :exact_sources, :providers, :surfaces, :watch_columns
8
8
  attr_accessor :tenant_column, :keyword, :embeddings, :order, :arrived_at_column
9
9
 
10
10
  def initialize(model)
@@ -14,6 +14,7 @@ module Truffler
14
14
  @exact_sources = {}
15
15
  @providers = {}
16
16
  @surfaces = {}
17
+ @watch_columns = []
17
18
  @arrived_at_column = "created_at"
18
19
  end
19
20
 
@@ -39,6 +40,13 @@ module Truffler
39
40
  labels.values.select(&:supplied?)
40
41
  end
41
42
 
43
+ # Columns whose change relabels every label: the column-backed fields,
44
+ # the tenant column, and the model-level `watch` columns. Method-backed
45
+ # fields never show up in saved_changes, so they need `watch`.
46
+ def relabel_columns
47
+ [ *fields, tenant_column, *watch_columns ].compact
48
+ end
49
+
42
50
  def per_tenant_vocabulary?
43
51
  labels.each_value.any?(&:per_tenant?)
44
52
  end
@@ -71,13 +79,28 @@ module Truffler
71
79
  Array(model.try(:encrypted_attributes)).map(&:to_s) & fields
72
80
  end
73
81
 
82
+ # Column checks wait for the table: a model declared at boot on a fresh
83
+ # database is checked on its first labeling or search instead.
74
84
  def validate!
75
85
  raise DefinitionError, "#{model.name}: declare the fields Jev reads with `reads`" if fields.empty?
76
86
 
77
- check_columns([ tenant_column, *fields, *Array(keyword).grep(String), *supplied_labels.flat_map(&:watch) ].compact.uniq)
87
+ @columns_deferred = !table_available?
88
+ validate_columns! unless @columns_deferred
78
89
  check_embeddings if embeddings
79
90
  end
80
91
 
92
+ def validate_columns!
93
+ return if @columns_checked
94
+
95
+ if @columns_deferred
96
+ return unless table_available?
97
+
98
+ model.reset_column_information
99
+ end
100
+ check_columns([ tenant_column, *fields, *Array(keyword).grep(String), *watch_columns, *labels.values.flat_map(&:watch) ].compact.uniq)
101
+ @columns_checked = true
102
+ end
103
+
81
104
  DEFAULT_RANKING = { label: 1.0, text: 1.0, keyword: 0.5, exact: 1.0, min_similarity: 0.0 }.freeze
82
105
  DEFAULT_WEAK_BELOW = 3
83
106
 
@@ -95,6 +118,12 @@ module Truffler
95
118
 
96
119
  private
97
120
 
121
+ def table_available?
122
+ model.connection.data_source_exists?(model.table_name)
123
+ rescue ActiveRecord::ActiveRecordError
124
+ false
125
+ end
126
+
98
127
  def check_columns(names)
99
128
  columns = model.attribute_names
100
129
  missing = names.reject { |name| columns.include?(name) || model.method_defined?(name) }
@@ -124,6 +153,10 @@ module Truffler
124
153
  @definition.fields.concat(fields.map(&:to_s))
125
154
  end
126
155
 
156
+ def watch(*columns)
157
+ @definition.watch_columns.concat(columns.map(&:to_s))
158
+ end
159
+
127
160
  def label(key, type, **options)
128
161
  @definition.add_label(LabelDefinition.new(key, type, **options))
129
162
  end
@@ -7,7 +7,17 @@ module Truffler
7
7
  class LiveCallInTest < Error; end
8
8
  class IncompleteAnswers < Error; end
9
9
  class CassetteMiss < Error; end
10
- class BudgetExhausted < Error; end
10
+
11
+ # Carries the budget's hint of how many seconds until a retry can succeed.
12
+ class BudgetExhausted < Error
13
+ attr_reader :retry_after
14
+
15
+ def initialize(message = nil, retry_after: nil)
16
+ @retry_after = retry_after
17
+ super(message)
18
+ end
19
+ end
20
+
11
21
  class TenantMismatch < Error; end
12
22
  class NotAuthorized < Error; end
13
23
  class InvalidLens < Error; end
@@ -2,20 +2,21 @@ module Truffler
2
2
  module Jobs
3
3
  # Backfills one model's stale, missing, failed, and demoted labels at
4
4
  # backfill priority. Arguments are the record type, the id cursor, the
5
- # spend so far, the cap, and the retry attempt, never record text. A
6
- # budget denial or a page limit reschedules the job from its cursor; the
7
- # spend cap ends it. A Jev error reschedules it with backoff and the spend
5
+ # spend so far, the cap, the retry attempt, and the count of budget
6
+ # denials in a row, never record text. A budget denial reschedules the job
7
+ # from its cursor after the same backoff a waiting Labeling::Backfill uses
8
+ # (reset whenever a run lands work); a page limit reschedules it at once;
9
+ # the spend cap ends it. A Jev error reschedules it with backoff and the spend
8
10
  # already made, so retries cannot push past the cap; after MAX_ATTEMPTS
9
11
  # the released rows wait for the ResumeJob sweep.
10
12
  class BackfillJob < ActiveJob::Base
11
13
  MAX_PAGES = 20
12
14
  MAX_ATTEMPTS = 10
13
- BUDGET_WAIT = 30.seconds
14
15
 
15
16
  queue_as { Truffler.config.queue_name }
16
17
 
17
18
  def perform(record_type, cursor: nil, spent: 0.0, spend_cap: Truffler.config.backfill_spend_cap, max_pages: MAX_PAGES,
18
- attempt: 0)
19
+ attempt: 0, denials: 0)
19
20
  model = record_type.safe_constantize
20
21
  return unless model.respond_to?(:truffler_definition) && model.truffler_definition
21
22
 
@@ -25,7 +26,7 @@ module Truffler
25
26
 
26
27
  follow_up = { cursor: result.cursor, spent: spent + result.cost, spend_cap: spend_cap, max_pages: max_pages }
27
28
  case result.status
28
- when :budget_denied then self.class.set(wait: BUDGET_WAIT).perform_later(record_type, **follow_up)
29
+ when :budget_denied then retry_after_denial(record_type, follow_up, result, denials)
29
30
  when :paused then self.class.perform_later(record_type, **follow_up)
30
31
  when :client_error then retry_later(record_type, follow_up, attempt + 1)
31
32
  when :complete, :spend_cap_reached then nil
@@ -35,6 +36,12 @@ module Truffler
35
36
 
36
37
  private
37
38
 
39
+ def retry_after_denial(record_type, follow_up, result, denials)
40
+ denials = 0 if result.labeled.positive? || result.requests.positive?
41
+ wait = Labeling::Backfill.backoff(denials, result.retry_after)
42
+ self.class.set(wait: wait).perform_later(record_type, **follow_up, denials: denials + 1)
43
+ end
44
+
38
45
  def retry_later(record_type, follow_up, attempt)
39
46
  return if attempt >= MAX_ATTEMPTS
40
47
 
@@ -2,12 +2,14 @@ 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
4
  # that option's probability. Choice options may be a callable of the tenant
5
- # key, which makes the vocabulary per-tenant.
5
+ # key, which makes the vocabulary per-tenant; a tenant it gives no options
6
+ # (`{}` or nil) simply does not have the label.
6
7
  #
7
8
  # A label with `from:` is supplied by the host: its answer is read from the
8
9
  # record in the shape Jev answers normalize to and Jev is never asked. Its
9
- # question is optional, `watch:` names extra columns that refresh it, and
10
- # `version:` forces a refresh when its logic changes.
10
+ # question is optional and `version:` forces a refresh when its logic
11
+ # changes. On any label, `watch:` names extra columns whose change
12
+ # refreshes (or re-asks) just that label.
11
13
  class LabelDefinition
12
14
  TYPES = %i[noul choice score].freeze
13
15
  KEY = /\A[a-z][a-z0-9_]*\z/
@@ -50,9 +52,15 @@ module Truffler
50
52
  def options(tenant_key = nil)
51
53
  options = per_tenant? ? @options.call(tenant_key) : @options
52
54
  options = Array(options).to_h { |option| [ option, nil ] } unless options.is_a?(Hash)
53
- raise DefinitionError, "#{key}: choice options for tenant #{tenant_key.inspect} are empty" if options.empty?
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)
54
57
 
55
- options.to_h { |option, description| [ option.to_s, description ] }
58
+ options
59
+ end
60
+
61
+ # False for a per-tenant choice with no options for this tenant.
62
+ def available?(tenant_key = nil)
63
+ type != :choice || !per_tenant? || options(tenant_key).any?
56
64
  end
57
65
 
58
66
  def question(tenant_key = nil)
@@ -139,7 +147,7 @@ module Truffler
139
147
  def validate_supplied!
140
148
  unless supplied?
141
149
  raise DefinitionError, "#{key}: a question is required" if instructions.blank?
142
- raise DefinitionError, "#{key}: watch: and version: need from:" if @watch.any? || !@version.nil?
150
+ raise DefinitionError, "#{key}: version: needs from:" unless @version.nil?
143
151
 
144
152
  return
145
153
  end