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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +38 -8
- data/lib/generators/truffler/install/templates/migration.rb.tt +10 -0
- data/lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt +19 -0
- data/lib/generators/truffler/upgrade/upgrade_generator.rb +24 -0
- data/lib/tasks/truffler.rake +42 -4
- data/lib/truffler/budget.rb +23 -8
- data/lib/truffler/clients/fake.rb +8 -2
- data/lib/truffler/configuration.rb +2 -1
- data/lib/truffler/definition.rb +35 -2
- data/lib/truffler/errors.rb +11 -1
- data/lib/truffler/jobs/backfill_job.rb +13 -6
- data/lib/truffler/label_definition.rb +14 -6
- data/lib/truffler/labeling/backfill.rb +148 -33
- data/lib/truffler/labeling/labeler.rb +2 -1
- data/lib/truffler/labeling/queue.rb +1 -0
- data/lib/truffler/labeling/supplied.rb +1 -0
- data/lib/truffler/lenses/drafter.rb +2 -1
- data/lib/truffler/model.rb +6 -5
- data/lib/truffler/query_encoding/encoder.rb +89 -14
- data/lib/truffler/records/backfill_spend.rb +51 -0
- data/lib/truffler/search/encoding.rb +9 -6
- data/lib/truffler/search/keystroke.rb +14 -3
- data/lib/truffler/search/query.rb +18 -6
- data/lib/truffler/search/result.rb +4 -2
- data/lib/truffler/search/sql.rb +11 -3
- data/lib/truffler/search/time_phrase.rb +87 -0
- data/lib/truffler/search/time_range.rb +10 -0
- data/lib/truffler/smart_search/starter.rb +3 -3
- data/lib/truffler/version.rb +1 -1
- data/lib/truffler/vocabulary.rb +5 -3
- data/lib/truffler.rb +4 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99d7f61714bf653e012b5cea1f9d57cfbde9d390aae048ba0d52387292a37159
|
|
4
|
+
data.tar.gz: b9826342d2552dae2b1cd9d671ac4d4d448f5fcbe3822b21e5788b7d272365b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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:` |
|
|
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
|
|
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` |
|
|
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]"` (
|
|
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
|
data/lib/tasks/truffler.rake
CHANGED
|
@@ -9,12 +9,42 @@ namespace :truffler do
|
|
|
9
9
|
model
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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
|
data/lib/truffler/budget.rb
CHANGED
|
@@ -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
|
|
12
|
-
#
|
|
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
|
|
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 =
|
|
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
|
data/lib/truffler/definition.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/truffler/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
|
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
|
|
10
|
-
# `
|
|
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
|
-
|
|
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
|
|
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}:
|
|
150
|
+
raise DefinitionError, "#{key}: version: needs from:" unless @version.nil?
|
|
143
151
|
|
|
144
152
|
return
|
|
145
153
|
end
|