truffler 0.1.0
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 +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +425 -0
- data/bench/cassettes/004cad52c71fc8d847bf388eb9c2c288e1169b65ec4a28ce040cb7fea7c55f04.json +1 -0
- data/bench/cassettes/0547d3d23c3fbaae86772ffa0e4e96475aa152a2556b8fa4bc7568effcb81a28.json +1 -0
- data/bench/cassettes/07aa23d166ab3500178e3f3c0838183051b093507bef88e1e2d29efdf5759ee6.json +1 -0
- data/bench/cassettes/0858c5006c0bfac3e970ddaff3c139f6c110dbe3d8539fd95263706c637cdc8e.json +1 -0
- data/bench/cassettes/0c0eb7c27d76bd0b9263690a63cd92d6b68c305826318555fd5b084a09970b78.json +1 -0
- data/bench/cassettes/0fada0c0a103882e2895f7f455d73d2cbe2b4a81bf743bb19b2b4f6e0130126b.json +1 -0
- data/bench/cassettes/1117d79ac7c43b21beaf75e510461f91cd4ecc3cdbbe461f1794644af4aaf8f3.json +1 -0
- data/bench/cassettes/11b7444a074cca4d0ef1535db0cf0edefe4c34eac4be3c593c9c2f33d3b038b8.json +1 -0
- data/bench/cassettes/11d00371d1b993675be00463159164536f1f8ed90a48652b0935eb9c36f8b684.json +1 -0
- data/bench/cassettes/1456ebe12616ce961d68a14eada62778a7271ebd3d1b4cb6eefd6857651c7afc.json +1 -0
- data/bench/cassettes/14c3693f64b0e9ea7e470277fccae6ab501d404185eec5bb9074ef4b75c572c0.json +1 -0
- data/bench/cassettes/14fe9f6d8055d41f5d970dc32ab640c213ea8782d365c82d32b9884ba4f28c67.json +1 -0
- data/bench/cassettes/1a1d8557e4698426c2467aa573a240357715d3d9e3fa77bf066672a8b6bf9a86.json +1 -0
- data/bench/cassettes/1b5a8aaaf98305560588246cd51f53c6c185a951f8018c26bf588954d9de1cea.json +1 -0
- data/bench/cassettes/1fe596be747e33e9c7e4d7c50de32613957ac66bee43679b4bed96c551b1fd0a.json +1 -0
- data/bench/cassettes/21ebce6db6d39b1747dccf25bd3ceb298e1285425190b4d78830c395a7ad786a.json +1 -0
- data/bench/cassettes/23066253621e9cb00fd93cbe95ef1124ff0715585fcb6325b3abbd0802b2c303.json +1 -0
- data/bench/cassettes/2f6576325dfab696cec8d8b35fa89c29960bee15d10dab31c91cef507f4842be.json +1 -0
- data/bench/cassettes/33ec27d22c0f63dbbe93008f50af07fa8667a5c806a9fbfc7897683915412b40.json +1 -0
- data/bench/cassettes/351f745b0c47e0cf3272b0fea7fc4941b6aa733f013c55a9a19707b542543200.json +1 -0
- data/bench/cassettes/37d974fdc0c1876413111c244d363c8d2306e3229fec668c158b5c924ffdb983.json +1 -0
- data/bench/cassettes/39524467463dd612cbf03e314b9cfd8cbe1c5f06969da2289359cf7cf83de5da.json +1 -0
- data/bench/cassettes/3f2c758c111ab7f1163ba579f2329718afe15620507d356d3ebe2a053df237ea.json +1 -0
- data/bench/cassettes/44bbc3a3f0ceb0edb443b080b939af80dd145795505bd13cac113a35365d0f04.json +1 -0
- data/bench/cassettes/45bf701a13c348acb45a3b3a56990570e3caf797c3daee5d1358a477593ce587.json +1 -0
- data/bench/cassettes/4676a0f2df29493fdfa0b7cd75c097e647373862d88373dc0e1fa9fca26707c2.json +1 -0
- data/bench/cassettes/49708a50e3c78f45beed2f1b8d8d5375b99ea4ab3497fed7650e6ece4ec81ba8.json +1 -0
- data/bench/cassettes/54ea30ecca8fa49f607d5c8265296eb16edb48d0620a7399c6480d4cb0f43ae1.json +1 -0
- data/bench/cassettes/550e73a8291f53d0f2b008f166560eff4d10cc7c09a927d3c4f70544f2578300.json +1 -0
- data/bench/cassettes/570039823d5af1b6c1d4bcbe14f9802d40e851f8e4946278a46884c76c0ceee1.json +1 -0
- data/bench/cassettes/5ae9dce82fa7b9084b592f657a24acfd5852c4fcb091a6a8e22f2cbbf947bcdd.json +1 -0
- data/bench/cassettes/5c40794d3bf6e229c6ffd26922388776121f971ef27279e02a35ce004e0794dd.json +1 -0
- data/bench/cassettes/62f2e71a2d9cfdc18fdd6600f5a1450519b25a75c2f799b7695258627250497d.json +1 -0
- data/bench/cassettes/642becd7ad9cd4cc2ac6d389ef8ff14fd9fe125beca834afee625ea2c31476be.json +1 -0
- data/bench/cassettes/656cc1158890e928c129d9cd54c1e08af1b6a2c0f6f94bacf395b13a198dc81f.json +1 -0
- data/bench/cassettes/67b30efd5681046fb5f2252d73f9aedf603be1b7c7895e4e71c6cfb88238309e.json +1 -0
- data/bench/cassettes/691958dd39537d72c4b0ab4ba69fea3c51b8e06a49a772f3ffd5bb5747dfe99d.json +1 -0
- data/bench/cassettes/6931e51f0455b5e3260165aa22a445d3a4f782ae5233a961384149cfbb045fe0.json +1 -0
- data/bench/cassettes/6b79cf1a80fc9edd09bb4606613ff2de2ab2bc61487983dffac1174025270997.json +1 -0
- data/bench/cassettes/6d683884d5d039db072ac18c9420bdcc989838f08a71096076cccc2edcd48234.json +1 -0
- data/bench/cassettes/6df49dc7eae4104adf1b020fd846d893da3e49f4f13a4c6fd1f87a78c410ba31.json +1 -0
- data/bench/cassettes/75cc5d99bbc1d48ace6095d41daae1e2e2c829291f01d40f0ce40d6eea52c0bf.json +1 -0
- data/bench/cassettes/771a86cbb6c025101384a50d01a4745683e1c2b17bbfff73e40e475dbf016efb.json +1 -0
- data/bench/cassettes/7a2a93c357473f4a289a6ec250e3bfe6f51152a0c448e5420ffebee7872f9d11.json +1 -0
- data/bench/cassettes/7a4774a02c3eebe24d4fbd443ea90661711fe5cb8248bce7a2dedfedf6ddbc03.json +1 -0
- data/bench/cassettes/7c1fa04f8eb43251ca3f6652977b698e4e6ba5e120da8301b4dbce79ef01ccb9.json +1 -0
- data/bench/cassettes/7c4dea48c2bc2c413da81307ce3cbb2749b20e16e4d831fca5861decf11ec55b.json +1 -0
- data/bench/cassettes/7dd499da389eaeeb59eba533695c20fd1a43e25f89f21e3456b826f9953b2010.json +1 -0
- data/bench/cassettes/80438d0bbfe106baa236eef358e46260693e6491f67e7a9f8ea1076c6fbfd449.json +1 -0
- data/bench/cassettes/80db562534181514f243ac74d7ddaec1fafc28b2b72ae35482cfc2de8f2d9e61.json +1 -0
- data/bench/cassettes/825a51543665a468a55c823788c68eb5785c90be8131988f72bf98f4f0294177.json +1 -0
- data/bench/cassettes/83d8276c227b73e542bd70c023d9a8ffa1c2cf4dd78e71d61457f097bc743d19.json +1 -0
- data/bench/cassettes/891405ffd7727b6d4c4186e2c60a08aa8b62c7b711bd1558c8033fcf8e4178a4.json +1 -0
- data/bench/cassettes/89fabff05dc4c0abdf630781a324b7802cbc0dc8bd09b20ce59643465a2aeaec.json +1 -0
- data/bench/cassettes/8cbceece3ca6136b6c6bb71c9bf28f409a5fa070596ebfc0f3187aba287bf6d3.json +1 -0
- data/bench/cassettes/8d56da27350d9dea20468eae30fa51cbbb10637c06d977b3e1a00734966be46f.json +1 -0
- data/bench/cassettes/8fb43b452b5e15248f34bf48e41cfc227129cfe349fe4b3fc4ab7133d66914bc.json +1 -0
- data/bench/cassettes/92e5426bd0c1f31d7acca92f77d7f2586e74054467729f0be28d139f515354d5.json +1 -0
- data/bench/cassettes/938be225155e1952b817652778b7cab377017fc4a219628ae89ad01a9c1151bc.json +1 -0
- data/bench/cassettes/9519ad701b28e7b2c5f1e1cad5df1ec3e5254c8cec969b9bee1361af8803d39d.json +1 -0
- data/bench/cassettes/9536e8ed68330eda400d765c3ca16a4dd3bfe0d026b569599a33e7e4951f4860.json +1 -0
- data/bench/cassettes/99c6e5aefd97722f4028bacc11d299eb9036805cd5cf7bf75f879ebe3f27c00a.json +1 -0
- data/bench/cassettes/9cdfd2f3ef8dc4cde917a9d196e3137159067c10ec8d2132f32da034865f9959.json +1 -0
- data/bench/cassettes/a1e3030984d4e1982fb5a2b2068664187358c88c6b71f768841d3e78406e9535.json +1 -0
- data/bench/cassettes/a2140f51b8fa25da73efd387a41f9eb84ffbc1f1ab47dec0c3b37b249ff373a0.json +1 -0
- data/bench/cassettes/a36d248ce5c26fd66430b780c8ecdd176f29830ac4edb2bf0b2e07cb93ffb865.json +1 -0
- data/bench/cassettes/a64329816ca1e537e90b89862a86be119e1e6236c6f3cf0141a96505b0734364.json +1 -0
- data/bench/cassettes/aba24c67dcaefa51c204a529b5ea3fe01333181a608a8ab8899568c731677e7d.json +1 -0
- data/bench/cassettes/aed233524926f2b4ff8c2ec21224bd284595c91eb57e0fb6981cdc8ac7e7d8d3.json +1 -0
- data/bench/cassettes/b00e9da6403e48aa1f4f9346419f88b2a336e1a2ac4a28515d84b2337eb777b2.json +1 -0
- data/bench/cassettes/b02f4c76c073882ff0fd12fddc28e4db52d9e6486a130194bf1057c7693de831.json +1 -0
- data/bench/cassettes/b6a9cb89ae4afd08a6919eaf7f609790c895d4cf22d47dad6c3b287b9278be87.json +1 -0
- data/bench/cassettes/b71b982225eab48d02c81913877571dfa53717c0983d1d72cbe3787242dda055.json +1 -0
- data/bench/cassettes/bda5e115b566e7f955aa45222859fe4f725949d5f91e3d794821701491448510.json +1 -0
- data/bench/cassettes/c0ac703268faf8d49dbc1ad0e877693a9f52181ae6070de844fcd4218d33d5e4.json +1 -0
- data/bench/cassettes/c0b57bcbb4004edd44c2d8c7e1f8cfc3567d954a3d5b21b2cda7257e3bc07437.json +1 -0
- data/bench/cassettes/d1ade9adde20981211c470a77b4d560264cae290941e692be6876a79227e3d18.json +1 -0
- data/bench/cassettes/d28de3038544a5bf9c8425b6976e360fae71c5ca96409784ad6642703701986a.json +1 -0
- data/bench/cassettes/d6418acb9f7f58ffdf3049481cbd17cfb3f0581064e157c9c58c19a3fc26b4d4.json +1 -0
- data/bench/cassettes/d71e8cf7c2be4b6e4089eb40c5a108c0c45fc58398af5138f905129f640afbb8.json +1 -0
- data/bench/cassettes/d9672c45b5358b92f7740abfb20b287235cf949990c88558f41d3a9c00eb6d3b.json +1 -0
- data/bench/cassettes/da0c9ce695111f6acf3cfb58dc0494f79230739d1773377db420bf438fd2e244.json +1 -0
- data/bench/cassettes/db57e3a15971cd2832313172c0d7df6e6a48b13a276ba9877eeb7d39389d74d3.json +1 -0
- data/bench/cassettes/db9c8c4d0a8d855a2578572afe5edc5eebb39c0fd5f04489614ea2557ec4f0cd.json +1 -0
- data/bench/cassettes/e2c396b0475a07a615dde9208b088e9e479720c1ec5889ade07fbfd0d28fb517.json +1 -0
- data/bench/cassettes/e3006ccc97646f43f4ea23694665526e47982fa1ea413359b9669071f6cb990d.json +1 -0
- data/bench/cassettes/e623737636e797d22f80d128cbff46101837044e159f41fa62a8d3d0cc6bb996.json +1 -0
- data/bench/cassettes/ea0cefaaac1a62e8eac962391df025a3b14c3a3d5b69fe57d60b65811010908c.json +1 -0
- data/bench/cassettes/ee3e06456cf4201ab46f579dd53bdea4071ce43bfe02e9b870bcb4b9c1c680b4.json +1 -0
- data/bench/cassettes/f26e675da9088f08cb76dd6b3fd89230b84fd1b9be3767c36857217bda2da15b.json +1 -0
- data/bench/cassettes/f396f34b2356d42af385eac9f5760e97e553a584c3f93da7bd618ad311e04d93.json +1 -0
- data/bench/cassettes/fce02c5c04cb8c27e868f6c54cfcbbbf69bde2f7f612d50ff8fb6d0aec9df3e3.json +1 -0
- data/bench/cassettes/ffc09a04b083822c1597dd96bb27ee4cde365bc8346175ebf6e378ab2ab1b598.json +1 -0
- data/bench/cassettes/manifest.json +1 -0
- data/bench/fixtures/gold.jsonl +27 -0
- data/bench/fixtures/injection.jsonl +6 -0
- data/bench/fixtures/records.jsonl +90 -0
- data/bench/params.yml +30 -0
- data/lib/generators/truffler/install/install_generator.rb +51 -0
- data/lib/generators/truffler/install/templates/channel.rb.tt +17 -0
- data/lib/generators/truffler/install/templates/initializer.rb.tt +23 -0
- data/lib/generators/truffler/install/templates/migration.rb.tt +109 -0
- data/lib/tasks/truffler/bench.rake +30 -0
- data/lib/tasks/truffler/suggestions.rake +8 -0
- data/lib/tasks/truffler.rake +26 -0
- data/lib/truffler/answers.rb +66 -0
- data/lib/truffler/benchmark/adapters.rb +80 -0
- data/lib/truffler/benchmark/database.rb +31 -0
- data/lib/truffler/benchmark/dataset.rb +65 -0
- data/lib/truffler/benchmark/email.rb +50 -0
- data/lib/truffler/benchmark/generator.rb +188 -0
- data/lib/truffler/benchmark/metrics.rb +62 -0
- data/lib/truffler/benchmark/params.rb +91 -0
- data/lib/truffler/benchmark/runner.rb +370 -0
- data/lib/truffler/benchmark/synthetic_jev.rb +85 -0
- data/lib/truffler/benchmark.rb +29 -0
- data/lib/truffler/broadcaster.rb +46 -0
- data/lib/truffler/budget.rb +118 -0
- data/lib/truffler/canonical.rb +27 -0
- data/lib/truffler/clients/base.rb +49 -0
- data/lib/truffler/clients/callable.rb +26 -0
- data/lib/truffler/clients/cassette.rb +44 -0
- data/lib/truffler/clients/fake.rb +85 -0
- data/lib/truffler/clients/ruby_llm_typesafe.rb +32 -0
- data/lib/truffler/configuration.rb +89 -0
- data/lib/truffler/definition.rb +176 -0
- data/lib/truffler/embeddings/backfill.rb +48 -0
- data/lib/truffler/embeddings/column_store.rb +65 -0
- data/lib/truffler/embeddings/embedder.rb +53 -0
- data/lib/truffler/embeddings/fake_embedder.rb +45 -0
- data/lib/truffler/embeddings/label_vector.rb +81 -0
- data/lib/truffler/embeddings/neighbor_store.rb +66 -0
- data/lib/truffler/embeddings/ruby_llm_embedder.rb +25 -0
- data/lib/truffler/embeddings/ruby_store.rb +12 -0
- data/lib/truffler/embeddings/vector_store.rb +84 -0
- data/lib/truffler/embeddings.rb +30 -0
- data/lib/truffler/errors.rb +40 -0
- data/lib/truffler/instrumentation.rb +17 -0
- data/lib/truffler/jobs/backfill_job.rb +45 -0
- data/lib/truffler/jobs/embed_job.rb +33 -0
- data/lib/truffler/jobs/encode_query_job.rb +17 -0
- data/lib/truffler/jobs/expire_lenses_job.rb +15 -0
- data/lib/truffler/jobs/label_flush_job.rb +39 -0
- data/lib/truffler/jobs/lens_backfill_job.rb +33 -0
- data/lib/truffler/jobs/provider_search_job.rb +18 -0
- data/lib/truffler/jobs/prune_query_misses_job.rb +15 -0
- data/lib/truffler/jobs/rerank_chunk_job.rb +14 -0
- data/lib/truffler/jobs/resume_job.rb +61 -0
- data/lib/truffler/jobs/smart_search_job.rb +14 -0
- data/lib/truffler/label_definition.rb +153 -0
- data/lib/truffler/labeling/backfill.rb +189 -0
- data/lib/truffler/labeling/labeler.rb +161 -0
- data/lib/truffler/labeling/queue.rb +125 -0
- data/lib/truffler/labeling/request_builder.rb +87 -0
- data/lib/truffler/labeling/supplied.rb +70 -0
- data/lib/truffler/lenses/activator.rb +51 -0
- data/lib/truffler/lenses/backfill.rb +112 -0
- data/lib/truffler/lenses/draft.rb +16 -0
- data/lib/truffler/lenses/drafter.rb +147 -0
- data/lib/truffler/lenses/fake_generator.rb +58 -0
- data/lib/truffler/lenses/lens.rb +161 -0
- data/lib/truffler/lenses/lens_label.rb +51 -0
- data/lib/truffler/lenses/policy.rb +46 -0
- data/lib/truffler/lenses/previewer.rb +217 -0
- data/lib/truffler/lenses/proposer.rb +53 -0
- data/lib/truffler/lenses/ruby_llm_generator.rb +42 -0
- data/lib/truffler/lenses/scope.rb +35 -0
- data/lib/truffler/lenses/sealed_description.rb +29 -0
- data/lib/truffler/lenses/settings.rb +45 -0
- data/lib/truffler/lenses/validation.rb +69 -0
- data/lib/truffler/lenses/version.rb +34 -0
- data/lib/truffler/lenses.rb +138 -0
- data/lib/truffler/misses/clusterer.rb +69 -0
- data/lib/truffler/misses/recorder.rb +29 -0
- data/lib/truffler/misses/suggestions.rb +49 -0
- data/lib/truffler/misses.rb +83 -0
- data/lib/truffler/model.rb +111 -0
- data/lib/truffler/providers/backup.rb +58 -0
- data/lib/truffler/providers/runner.rb +67 -0
- data/lib/truffler/providers.rb +57 -0
- data/lib/truffler/query_encoding/cache.rb +71 -0
- data/lib/truffler/query_encoding/encoder.rb +183 -0
- data/lib/truffler/query_encoding/prefetch.rb +34 -0
- data/lib/truffler/query_encoding.rb +12 -0
- data/lib/truffler/questions.rb +78 -0
- data/lib/truffler/railtie.rb +11 -0
- data/lib/truffler/records/embedding.rb +39 -0
- data/lib/truffler/records/label.rb +11 -0
- data/lib/truffler/records/query_miss.rb +20 -0
- data/lib/truffler/records/record_state.rb +30 -0
- data/lib/truffler/redaction.rb +24 -0
- data/lib/truffler/registry.rb +16 -0
- data/lib/truffler/search/encoding.rb +84 -0
- data/lib/truffler/search/encoding_cache.rb +68 -0
- data/lib/truffler/search/keystroke.rb +149 -0
- data/lib/truffler/search/query.rb +67 -0
- data/lib/truffler/search/result.rb +100 -0
- data/lib/truffler/search/sql.rb +197 -0
- data/lib/truffler/smart_search/dispatcher.rb +88 -0
- data/lib/truffler/smart_search/reranker.rb +105 -0
- data/lib/truffler/smart_search/run.rb +306 -0
- data/lib/truffler/smart_search/starter.rb +73 -0
- data/lib/truffler/smart_search/store.rb +89 -0
- data/lib/truffler/smart_search.rb +43 -0
- data/lib/truffler/tokens.rb +12 -0
- data/lib/truffler/usage.rb +7 -0
- data/lib/truffler/version.rb +3 -0
- data/lib/truffler/vocabulary.rb +48 -0
- data/lib/truffler.rb +41 -0
- metadata +336 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Truffler
|
|
4
|
+
module Benchmark
|
|
5
|
+
# Loads a dataset into the current database, labels it through the real
|
|
6
|
+
# pipeline (Queue, Labeler, RequestBuilder, Budget) with the given client,
|
|
7
|
+
# and returns one JSON-ready report: the R32 metrics, the R33 packed-batch
|
|
8
|
+
# agreement, the R34 injection result, and the params echo.
|
|
9
|
+
#
|
|
10
|
+
# Modes: :replay reads committed cassettes and never calls out; :record
|
|
11
|
+
# rewrites a cassette directory; :synthetic answers from SyntheticJev with
|
|
12
|
+
# no cassette, for generated datasets of any size.
|
|
13
|
+
class Runner
|
|
14
|
+
MODES = %i[replay record synthetic].freeze
|
|
15
|
+
SOURCES = { synthetic: "synthetic-fake", live: "live" }.freeze
|
|
16
|
+
MANIFEST = "manifest.json".freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :params, :dataset, :mode, :source
|
|
19
|
+
|
|
20
|
+
def self.build(params: Params.load(Benchmark.path("params.yml")), mode: :replay, jev: :synthetic, records: nil,
|
|
21
|
+
tenants: 3, seed: Generator::DEFAULT_SEED, cassettes: nil, env: ENV, jev_options: {},
|
|
22
|
+
searcher: Adapters.searcher, reranker: Adapters.reranker)
|
|
23
|
+
mode = mode.to_sym
|
|
24
|
+
jev = jev.to_sym
|
|
25
|
+
raise ArgumentError, "MODE must be one of #{MODES.join(', ')}" unless MODES.include?(mode)
|
|
26
|
+
raise ArgumentError, "JEV must be one of #{SOURCES.keys.join(', ')}" unless SOURCES.key?(jev)
|
|
27
|
+
|
|
28
|
+
dataset = records ? Generator.new(records: Integer(records), tenants: tenants, seed: seed).dataset : Dataset.load(Benchmark.path("fixtures"))
|
|
29
|
+
cassettes = (cassettes || default_cassettes(jev)).to_s
|
|
30
|
+
client = client_for(mode, jev, dataset, cassettes, env, jev_options)
|
|
31
|
+
new(params: params, dataset: dataset, client: client, mode: mode, source: source_for(mode, jev, cassettes),
|
|
32
|
+
cassettes: (cassettes unless mode == :synthetic), searcher: searcher, reranker: reranker)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# MODE, JEV, PARAMS, BENCH_RECORDS, BENCH_TENANTS, and CASSETTES from the
|
|
36
|
+
# environment. TRUFFLER_CASSETTE_MODE=record means recording live Jev.
|
|
37
|
+
def self.from_env(env = ENV)
|
|
38
|
+
mode = env["MODE"].presence || env["TRUFFLER_CASSETTE_MODE"].presence || "replay"
|
|
39
|
+
jev = env["JEV"].presence || (env["TRUFFLER_CASSETTE_MODE"] == "record" ? "live" : "synthetic")
|
|
40
|
+
build(params: Params.load(env["PARAMS"].presence || Benchmark.path("params.yml")), mode: mode, jev: jev,
|
|
41
|
+
records: env["BENCH_RECORDS"].presence, tenants: Integer(env["BENCH_TENANTS"].presence || 3),
|
|
42
|
+
cassettes: env["CASSETTES"].presence, env: env)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.default_cassettes(jev)
|
|
46
|
+
jev == :live ? File.join(Dir.pwd, "tmp", "bench", "cassettes-live") : Benchmark.path("cassettes")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.client_for(mode, jev, dataset, cassettes, env, jev_options)
|
|
50
|
+
inner = if jev == :live
|
|
51
|
+
raise ArgumentError, "live Jev needs MODE=record or MODE=replay" if mode == :synthetic
|
|
52
|
+
raise ArgumentError, "recording live Jev needs TYPESAFE_API_KEY" if mode == :record && env["TYPESAFE_API_KEY"].blank?
|
|
53
|
+
|
|
54
|
+
Clients::RubyLLMTypeSafe.new if mode == :record
|
|
55
|
+
else
|
|
56
|
+
SyntheticJev.new(dataset, **jev_options)
|
|
57
|
+
end
|
|
58
|
+
return inner if mode == :synthetic
|
|
59
|
+
|
|
60
|
+
Clients::Cassette.new(mode == :record ? inner : nil, dir: cassettes, mode: mode)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.source_for(mode, jev, cassettes)
|
|
64
|
+
manifest = File.join(cassettes, MANIFEST)
|
|
65
|
+
return JSON.parse(File.read(manifest)).fetch("source") if mode == :replay && File.exist?(manifest)
|
|
66
|
+
|
|
67
|
+
SOURCES.fetch(jev)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize(params:, dataset:, client:, mode:, source:, cassettes: nil, searcher: Adapters.searcher,
|
|
71
|
+
reranker: Adapters.reranker, model: Email)
|
|
72
|
+
@params = params
|
|
73
|
+
@dataset = dataset
|
|
74
|
+
@client = client
|
|
75
|
+
@mode = mode.to_sym
|
|
76
|
+
@source = source
|
|
77
|
+
@cassettes = cassettes
|
|
78
|
+
@searcher = searcher
|
|
79
|
+
@reranker = reranker
|
|
80
|
+
@model = model
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def run
|
|
84
|
+
check_batch_size!
|
|
85
|
+
prepare_cassettes if mode == :record
|
|
86
|
+
@model.create_table!
|
|
87
|
+
@model.declare!(params)
|
|
88
|
+
load_dataset
|
|
89
|
+
labeling = label_all
|
|
90
|
+
agreement = agreement_check
|
|
91
|
+
injection_labels = injection_label_check
|
|
92
|
+
search, rerank, query_cost = measure_queries
|
|
93
|
+
|
|
94
|
+
report(labeling, agreement, injection_labels, search, rerank, query_cost)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def check_batch_size!
|
|
100
|
+
return if params.batch_size <= Truffler.config.tenant_live_cap
|
|
101
|
+
|
|
102
|
+
raise ArgumentError, "labeling.batch_size #{params.batch_size} exceeds tenant_live_cap #{Truffler.config.tenant_live_cap}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def prepare_cassettes
|
|
106
|
+
FileUtils.mkdir_p(@cassettes)
|
|
107
|
+
Dir[File.join(@cassettes, "*.json")].each { |file| File.delete(file) }
|
|
108
|
+
File.write(File.join(@cassettes, MANIFEST), "#{Canonical.json(source: source, generator: 'Truffler::Benchmark')}\n")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def record_type
|
|
112
|
+
@model.polymorphic_name
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def load_dataset
|
|
116
|
+
Records::Label.where(record_type: record_type).delete_all
|
|
117
|
+
Records::RecordState.where(record_type: record_type).delete_all
|
|
118
|
+
now = Time.current
|
|
119
|
+
@dataset.labeled_records.each_slice(1_000) do |slice|
|
|
120
|
+
@model.insert_all!(slice.map do |record|
|
|
121
|
+
{ id: record.id, tenant_id: Integer(record.tenant), subject: record.subject, body: record.body,
|
|
122
|
+
sender_name: record.sender_name, sender_email: record.sender_email,
|
|
123
|
+
received_at: Time.iso8601(record.received_at), created_at: now, updated_at: now }
|
|
124
|
+
end)
|
|
125
|
+
Records::RecordState.insert_all!(slice.map do |record|
|
|
126
|
+
{ record_type: record_type, record_id: record.id, tenant_key: record.tenant, status: "pending",
|
|
127
|
+
priority: "live", attempts: 0, created_at: now, updated_at: now }
|
|
128
|
+
end)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Labels every record at live priority under the account budget on a
|
|
133
|
+
# simulated clock: waits advance the clock, and a tenant over its live
|
|
134
|
+
# cap waits for the next minute, so throughput is what the budget allows.
|
|
135
|
+
def label_all
|
|
136
|
+
clock = SimulatedClock.new
|
|
137
|
+
budget = Budget.new(cache: ActiveSupport::Cache::MemoryStore.new, clock: -> { clock.now }, sleeper: ->(seconds) { clock.advance(seconds) })
|
|
138
|
+
labeler = Labeling::Labeler.new(@model, client: @client, budget: budget)
|
|
139
|
+
queue = Labeling::Queue.new(@model)
|
|
140
|
+
totals = { labeled: 0, requests: 0, cost: 0.0, demotions: 0 }
|
|
141
|
+
pending = @dataset.tenants.dup
|
|
142
|
+
|
|
143
|
+
until pending.empty?
|
|
144
|
+
progressed = false
|
|
145
|
+
pending.dup.each do |tenant|
|
|
146
|
+
states = queue.claim(tenant, priority: :live, limit: params.batch_size)
|
|
147
|
+
next pending.delete(tenant) if states.empty?
|
|
148
|
+
|
|
149
|
+
result = labeler.label(states, priority: :live)
|
|
150
|
+
if result.demoted
|
|
151
|
+
totals[:demotions] += 1
|
|
152
|
+
Records::RecordState.where(id: states.map(&:id)).update_all(status: "pending", claimed_at: nil)
|
|
153
|
+
else
|
|
154
|
+
progressed = true
|
|
155
|
+
totals[:labeled] += result.labeled
|
|
156
|
+
totals[:requests] += result.requests
|
|
157
|
+
totals[:cost] += result.cost
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
clock.next_minute unless progressed || pending.empty?
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
minutes = [ clock.elapsed.ceil, 1 ].max / 60.0
|
|
164
|
+
totals.merge(minutes: minutes, budget: budget)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def agreement_check
|
|
168
|
+
tolerance = params.dig(:labeling, :agreement_tolerance)
|
|
169
|
+
floor = params.dig(:labeling, :agreement_floor)
|
|
170
|
+
sample = agreement_sample
|
|
171
|
+
results = params.agreement_batch_sizes.to_h { |size| [ size, label_values(sample, size) ] }
|
|
172
|
+
single = results.fetch(1)[:values]
|
|
173
|
+
|
|
174
|
+
sizes = results.to_h do |size, result|
|
|
175
|
+
compared = Metrics.agreement(single, result[:values], tolerance: tolerance)
|
|
176
|
+
[ size.to_s, { "agreement" => compared[:agreement].round(4), "adoptable" => Metrics.adoptable?(compared[:agreement], floor: floor),
|
|
177
|
+
"requests" => result[:requests], "disagreements" => compared[:disagreements].size } ]
|
|
178
|
+
end
|
|
179
|
+
adopted = sizes.select { |_, entry| entry["adoptable"] }.keys.map(&:to_i).max
|
|
180
|
+
{ "tolerance" => tolerance, "floor" => floor, "sample" => sample.values.sum(&:size), "batch_sizes" => sizes,
|
|
181
|
+
"adopted_batch_size" => adopted }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def agreement_sample
|
|
185
|
+
per_tenant = params.dig(:labeling, :agreement_sample_per_tenant)
|
|
186
|
+
ids = @dataset.records.group_by(&:tenant).transform_values { |records| records.first(per_tenant).map(&:id) }
|
|
187
|
+
ids.transform_values { |tenant_ids| @model.where(id: tenant_ids).order(:id).to_a }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def label_values(sample, size)
|
|
191
|
+
definition = @model.truffler_definition
|
|
192
|
+
values = Hash.new { |hash, key| hash[key] = {} }
|
|
193
|
+
requests = 0
|
|
194
|
+
sample.each do |tenant, records|
|
|
195
|
+
builder = Labeling::RequestBuilder.new(definition, tenant_key: tenant)
|
|
196
|
+
records.each_slice(size) do |chunk|
|
|
197
|
+
builder.build(chunk.map { |record| [ record, definition.label_keys ] }).each do |request|
|
|
198
|
+
requests += 1
|
|
199
|
+
answers = @client.ask(state: request.state, questions: request.questions, priority: :backfill)
|
|
200
|
+
request.entries.each do |tag, (record, keys)|
|
|
201
|
+
keys.each { |key| values[record.id][key] = answer_value(definition.label(key), answers, Questions.tagged_id(tag, key)) }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
{ values: values, requests: requests }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def answer_value(label, answers, id)
|
|
210
|
+
case label.type
|
|
211
|
+
when :noul then answers.noul(id)
|
|
212
|
+
when :score then answers.score(id)
|
|
213
|
+
when :choice then answers.choice(id)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def injection_label_check
|
|
218
|
+
tolerance = params.dig(:injection, :label_tolerance)
|
|
219
|
+
ids = @dataset.injections.flat_map { |injection| [ injection.clean_id, injection.record.id ] }
|
|
220
|
+
stored = Records::Label.where(record_type: record_type, record_id: ids).pluck(:record_id, :label_key, :value)
|
|
221
|
+
.each_with_object(Hash.new { |hash, key| hash[key] = {} }) { |(id, key, value), map| map[id][key] = value }
|
|
222
|
+
|
|
223
|
+
failures = @dataset.injections.filter_map do |injection|
|
|
224
|
+
clean = stored[injection.clean_id]
|
|
225
|
+
twin = stored[injection.record.id]
|
|
226
|
+
changed = (clean.keys | twin.keys).reject { |key| Metrics.agree?(clean[key], twin[key], tolerance) }
|
|
227
|
+
next if changed.empty?
|
|
228
|
+
|
|
229
|
+
{ "fixture" => injection.id, "clean_id" => injection.clean_id, "twin_id" => injection.record.id,
|
|
230
|
+
"labels" => changed.map { |key| key.split(":").first }.uniq.sort }
|
|
231
|
+
end
|
|
232
|
+
{ "passed" => failures.empty?, "tolerance" => tolerance, "twins" => @dataset.injections.size, "failures" => failures }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def measure_queries
|
|
236
|
+
cost = 0.0
|
|
237
|
+
counter = ->(*, payload) { cost += payload[:cost].to_f }
|
|
238
|
+
search = rerank = nil
|
|
239
|
+
ActiveSupport::Notifications.subscribed(counter, "truffler.jev_call") do
|
|
240
|
+
search = search_metrics
|
|
241
|
+
rerank = rerank_metrics(search)
|
|
242
|
+
end
|
|
243
|
+
query_cost = Benchmark.not_available?(search) ? search : (cost / @dataset.gold.size).round(8)
|
|
244
|
+
[ search, rerank, query_cost ]
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def search_metrics
|
|
248
|
+
return @searcher if Benchmark.not_available?(@searcher)
|
|
249
|
+
|
|
250
|
+
returned = {}
|
|
251
|
+
latencies = []
|
|
252
|
+
@dataset.gold.each do |gold|
|
|
253
|
+
started = Instrumentation.monotonic_ms
|
|
254
|
+
ids = @searcher.call(query: gold.query, tenant_key: gold.tenant, kind: gold.kind, model: @model, params: params)
|
|
255
|
+
return ids if Benchmark.not_available?(ids)
|
|
256
|
+
|
|
257
|
+
latencies << Instrumentation.monotonic_ms - started
|
|
258
|
+
returned[gold.id] = ids.to_a
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
by_kind = @dataset.gold.group_by(&:kind)
|
|
262
|
+
score = ->(metric) { by_kind.transform_values { |golds| Metrics.mean(golds.map { |gold| Metrics.public_send(metric, gold.expected_ids, returned[gold.id]) }).round(4) } }
|
|
263
|
+
{ "recall" => score.(:recall), "precision" => score.(:precision),
|
|
264
|
+
"latency" => { "p50" => Metrics.percentile(latencies, 50).round(3), "p95" => Metrics.percentile(latencies, 95).round(3) },
|
|
265
|
+
"returned" => returned }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def rerank_metrics(search)
|
|
269
|
+
return @reranker if Benchmark.not_available?(@reranker)
|
|
270
|
+
return search if Benchmark.not_available?(search)
|
|
271
|
+
|
|
272
|
+
depth = params.rerank_depth
|
|
273
|
+
requests = 0
|
|
274
|
+
buckets = Hash.new(0)
|
|
275
|
+
@dataset.gold.select { |gold| gold.kind == "intent" }.each do |gold|
|
|
276
|
+
result = rerank(gold.query, gold.tenant, search["returned"].fetch(gold.id), depth)
|
|
277
|
+
return result if Benchmark.not_available?(result)
|
|
278
|
+
|
|
279
|
+
requests += result[:requests]
|
|
280
|
+
result[:buckets].each_value { |bucket| buckets[bucket.to_s] += 1 }
|
|
281
|
+
end
|
|
282
|
+
{ "depth" => depth, "requests" => requests, "buckets" => buckets.sort.to_h, "injection" => rerank_injection_check(depth) }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def rerank(query, tenant, candidate_ids, depth)
|
|
286
|
+
@reranker.call(query: query, tenant_key: tenant, model: @model, candidate_ids: candidate_ids.first(depth), depth: depth, params: params,
|
|
287
|
+
client: @client)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def rerank_injection_check(depth)
|
|
291
|
+
failures = @dataset.injections.filter_map do |injection|
|
|
292
|
+
buckets = rerank(injection.query, injection.record.tenant, [ injection.clean_id, injection.record.id ], depth)
|
|
293
|
+
return buckets if Benchmark.not_available?(buckets)
|
|
294
|
+
next if buckets[:buckets][injection.clean_id].to_s == buckets[:buckets][injection.record.id].to_s
|
|
295
|
+
|
|
296
|
+
{ "fixture" => injection.id, "clean_id" => injection.clean_id, "twin_id" => injection.record.id }
|
|
297
|
+
end
|
|
298
|
+
{ "passed" => failures.empty?, "failures" => failures }
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def report(labeling, agreement, injection_labels, search, rerank, query_cost)
|
|
302
|
+
injection_rerank = Benchmark.not_available?(rerank) ? rerank : rerank.delete("injection")
|
|
303
|
+
failures = []
|
|
304
|
+
failures << "injection_labels" unless injection_labels["passed"]
|
|
305
|
+
failures << "injection_rerank" if injection_rerank.is_a?(Hash) && injection_rerank["passed"] == false
|
|
306
|
+
failures << "batch_size_agreement" unless agreement["batch_sizes"].fetch(params.batch_size.to_s)["adoptable"]
|
|
307
|
+
|
|
308
|
+
report = {
|
|
309
|
+
"truffler_bench" => REPORT_VERSION,
|
|
310
|
+
"source" => source,
|
|
311
|
+
"mode" => mode.to_s,
|
|
312
|
+
"params" => params.to_h,
|
|
313
|
+
"dataset" => @dataset.summary,
|
|
314
|
+
"metrics" => {
|
|
315
|
+
"recall" => per_kind(search, "recall"),
|
|
316
|
+
"precision" => per_kind(search, "precision"),
|
|
317
|
+
"keystroke_latency_ms" => Benchmark.not_available?(search) ? search : search["latency"],
|
|
318
|
+
"labeling_throughput" => throughput(labeling),
|
|
319
|
+
"cost" => { "per_labeled_record" => (labeling[:cost] / [ labeling[:labeled], 1 ].max).round(8), "per_query" => query_cost }
|
|
320
|
+
},
|
|
321
|
+
"agreement" => agreement,
|
|
322
|
+
"injection" => { "labels" => injection_labels, "rerank" => injection_rerank },
|
|
323
|
+
"rerank" => rerank,
|
|
324
|
+
"checks" => { "passed" => failures.empty?, "failures" => failures }
|
|
325
|
+
}
|
|
326
|
+
JSON.parse(JSON.generate(report.as_json))
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def per_kind(search, metric)
|
|
330
|
+
return { "intent" => search, "exact_text" => search } if Benchmark.not_available?(search)
|
|
331
|
+
|
|
332
|
+
search[metric]
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def throughput(labeling)
|
|
336
|
+
budget = labeling[:budget]
|
|
337
|
+
{ "records" => labeling[:labeled], "requests" => labeling[:requests],
|
|
338
|
+
"records_per_request" => (labeling[:labeled] / [ labeling[:requests], 1 ].max.to_f).round(3),
|
|
339
|
+
"simulated_minutes" => labeling[:minutes].round(4),
|
|
340
|
+
"requests_per_minute" => (labeling[:requests] / labeling[:minutes]).round(2),
|
|
341
|
+
"records_per_minute" => (labeling[:labeled] / labeling[:minutes]).round(2),
|
|
342
|
+
"tenant_demotions" => labeling[:demotions],
|
|
343
|
+
"budget_live_requests_per_minute" => (budget.ceiling(:live) * 60).round(2),
|
|
344
|
+
"tenant_live_cap" => Truffler.config.tenant_live_cap }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
class SimulatedClock
|
|
348
|
+
START = 1_800_000_000.0
|
|
349
|
+
|
|
350
|
+
attr_reader :now
|
|
351
|
+
|
|
352
|
+
def initialize
|
|
353
|
+
@now = START
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def advance(seconds)
|
|
357
|
+
@now += seconds
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def next_minute
|
|
361
|
+
@now = ((@now / 60).floor + 1) * 60.0
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def elapsed
|
|
365
|
+
@now - START
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Truffler
|
|
4
|
+
module Benchmark
|
|
5
|
+
# A deterministic stand-in for Jev that answers from the dataset's ground
|
|
6
|
+
# truth plus seeded noise. It produces the committed cassettes. Packed
|
|
7
|
+
# requests drift: each extra record in a request raises the chance that
|
|
8
|
+
# an answer flips, which is what the R33 agreement check has to catch.
|
|
9
|
+
# With obey_injections it follows instructions embedded in record text,
|
|
10
|
+
# modeling a model that fails R34.
|
|
11
|
+
#
|
|
12
|
+
# Questions about records it does not know (rerank candidates, queries)
|
|
13
|
+
# answer no, the first option, or the lowest level.
|
|
14
|
+
class SyntheticJev < Clients::Base
|
|
15
|
+
MODEL = "synthetic-jev".freeze
|
|
16
|
+
INJECTED = "ignore all previous instructions".freeze
|
|
17
|
+
OBEYED = { "needs_action" => 1.0, "urgent" => 1.0, "category" => "billing", "importance" => 2 }.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(dataset, seed: Generator::DEFAULT_SEED, noise: 0.03, packed_flip_rate: 0.003, obey_injections: false)
|
|
20
|
+
@seed = seed
|
|
21
|
+
@noise = noise
|
|
22
|
+
@packed_flip_rate = packed_flip_rate
|
|
23
|
+
@obey_injections = obey_injections
|
|
24
|
+
@truth = dataset.labeled_records.to_h { |record| [ self.class.key(record.to_h.stringify_keys), record.truth ] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.key(fields)
|
|
28
|
+
Canonical.digest([ fields["subject"].to_s, fields["body"].to_s ])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def perform(state:, questions:, model:)
|
|
32
|
+
state = state.deep_stringify_keys
|
|
33
|
+
records = state["records"].to_h
|
|
34
|
+
answers = questions.to_h do |id, question|
|
|
35
|
+
tag, label = Questions.split_id(id)
|
|
36
|
+
[ id, answer(question.deep_stringify_keys, records[tag], label, records.size) ]
|
|
37
|
+
end
|
|
38
|
+
{ "answers" => answers, "model" => MODEL, "usage" => { "input_tokens" => Tokens.estimate({ state: state, questions: questions }) } }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def answer(question, fields, label, batch)
|
|
44
|
+
key = fields && self.class.key(fields)
|
|
45
|
+
truth = key && @truth[key]
|
|
46
|
+
truth = OBEYED if truth && @obey_injections && fields["body"].to_s.downcase.include?(INJECTED)
|
|
47
|
+
flip = truth && unit(key, label, batch, "flip") < @packed_flip_rate * (batch - 1)
|
|
48
|
+
drift = truth ? (unit(key, label, batch, "noise") * 2 - 1) * @noise : 0.0
|
|
49
|
+
|
|
50
|
+
case question["type"]
|
|
51
|
+
when "noul" then noul(truth&.dig(label).to_f, drift, flip)
|
|
52
|
+
when "choice" then choice(question["criteria"].keys, truth&.dig(label), drift, flip)
|
|
53
|
+
when "score" then score(question["criteria"].size, truth&.dig(label).to_i, flip)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def noul(value, drift, flip)
|
|
58
|
+
value = (value + drift).clamp(0.0, 1.0)
|
|
59
|
+
value = value >= 0.5 ? value - 0.4 : value + 0.4 if flip
|
|
60
|
+
{ "type" => "noul", "noul" => value.round(4) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def choice(options, truth, drift, flip)
|
|
64
|
+
index = options.index(truth.to_s) || 0
|
|
65
|
+
index = (index + 1) % options.size if flip
|
|
66
|
+
confidence = (0.8 + drift).round(4)
|
|
67
|
+
rest = options.size > 1 ? ((1 - confidence) / (options.size - 1)).round(4) : 0.0
|
|
68
|
+
probabilities = options.each_with_index.to_h { |option, position| [ option, position == index ? confidence : rest ] }
|
|
69
|
+
{ "type" => "choice", "choice" => options[index], "probabilities" => probabilities, "confidence" => confidence }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def score(levels, level, flip)
|
|
73
|
+
level = level.clamp(0, levels - 1)
|
|
74
|
+
level = level.zero? ? 1 : level - 1 if flip
|
|
75
|
+
legend = Array.new(levels) { |index| [ index.to_s, index.to_s ] }.to_h
|
|
76
|
+
probabilities = legend.keys.to_h { |key| [ key, key.to_i == level ? 1.0 : 0.0 ] }
|
|
77
|
+
{ "type" => "score", "score" => level, "legend" => legend, "probabilities" => probabilities, "confidence" => 1.0 }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def unit(*parts)
|
|
81
|
+
Digest::SHA256.hexdigest([ @seed, *parts ].join("|"))[0, 8].to_i(16) / 0xffffffff.to_f
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# The replayable benchmark behind `rake truffler:bench` (KTD17). Everything
|
|
3
|
+
# it reads lives under bench/: params.yml, synthetic fixtures, and the
|
|
4
|
+
# committed cassettes. No step touches real data (R35).
|
|
5
|
+
module Benchmark
|
|
6
|
+
ROOT = File.expand_path("../../bench", __dir__)
|
|
7
|
+
REPORT_VERSION = 1
|
|
8
|
+
|
|
9
|
+
# Stands in for a metric whose unit has not landed yet, so the report
|
|
10
|
+
# keeps every key and says what it waits for instead of failing.
|
|
11
|
+
NotAvailable = Data.define(:requires) do
|
|
12
|
+
def to_h
|
|
13
|
+
{ "status" => "not_available_yet", "requires" => requires }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def as_json(*)
|
|
17
|
+
to_h
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.path(*parts)
|
|
22
|
+
File.join(ROOT, *parts)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.not_available?(value)
|
|
26
|
+
value.is_a?(NotAvailable) || (value.is_a?(Hash) && value["status"] == "not_available_yet")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# The data-free Action Cable ping of KTD13: `{run_id, section, changed_at}`
|
|
3
|
+
# on `truffler:<user_key>`. Hosts answer it with an Inertia partial reload
|
|
4
|
+
# of the named prop (`smart`, `provider`), so no result data rides the
|
|
5
|
+
# socket (R27). A failed broadcast is reported and swallowed: a ping is a
|
|
6
|
+
# hint, and the work that triggered it must not fail with it.
|
|
7
|
+
#
|
|
8
|
+
# `config.broadcaster` may be any object responding to
|
|
9
|
+
# `broadcast(stream, payload)`; the default is `ActionCable.server` when
|
|
10
|
+
# Action Cable is loaded, otherwise pings are dropped.
|
|
11
|
+
module Broadcaster
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def stream(user_key)
|
|
15
|
+
"truffler:#{user_key}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# True when a ping was handed to the server.
|
|
19
|
+
def ping(user_key, run_id:, section:)
|
|
20
|
+
return false if user_key.blank?
|
|
21
|
+
|
|
22
|
+
target = server
|
|
23
|
+
return false unless target
|
|
24
|
+
|
|
25
|
+
target.broadcast(stream(user_key), { run_id: run_id, section: section.to_s, changed_at: Time.current.iso8601(6) })
|
|
26
|
+
true
|
|
27
|
+
rescue StandardError => error
|
|
28
|
+
report(error, run_id: run_id, section: section)
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def server
|
|
33
|
+
Truffler.config.broadcaster || (ActionCable.server if defined?(ActionCable) && ActionCable.respond_to?(:server))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def report(error, run_id:, section:)
|
|
37
|
+
Instrumentation.instrument(:broadcast_failed, run_id: run_id, section: section.to_s, error_class: error.class.name)
|
|
38
|
+
Truffler.config.logger.warn("truffler: broadcast failed (#{error.class.name}) for run #{run_id}")
|
|
39
|
+
Rails.error.report(error, handled: true, context: { run_id: run_id, section: section.to_s }) if rails_error_reporter?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def rails_error_reporter?
|
|
43
|
+
defined?(Rails) && Rails.respond_to?(:error) && Rails.error.respond_to?(:report)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# One account-wide Jev request budget shared by every gem call. The gem's
|
|
3
|
+
# share is the account limit minus headroom for the app's other Jev calls,
|
|
4
|
+
# counted per second in the cache store so bursts spread across the minute.
|
|
5
|
+
# Each priority may fill the second only up to its ceiling, so lower
|
|
6
|
+
# priorities give way first: live labeling, then query encoding, then
|
|
7
|
+
# rerank, then backfill.
|
|
8
|
+
#
|
|
9
|
+
# Outcomes: :granted takes a slot; :demoted means a tenant is over its live
|
|
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.
|
|
13
|
+
class Budget
|
|
14
|
+
PRIORITIES = %i[live encode rerank backfill].freeze
|
|
15
|
+
|
|
16
|
+
Decision = Data.define(:outcome, :priority, :reason) do
|
|
17
|
+
def granted? = outcome == :granted
|
|
18
|
+
def demoted? = outcome == :demoted
|
|
19
|
+
def denied? = outcome == :denied
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :config
|
|
23
|
+
|
|
24
|
+
def initialize(config: Truffler.config, cache: config.cache_store,
|
|
25
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_REALTIME) }, sleeper: ->(seconds) { sleep(seconds) })
|
|
26
|
+
@config = config
|
|
27
|
+
@cache = cache
|
|
28
|
+
@clock = clock
|
|
29
|
+
@sleeper = sleeper
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def acquire(priority:, user_key: nil, tenant_key: nil, records: 1)
|
|
33
|
+
priority = priority.to_sym
|
|
34
|
+
raise ArgumentError, "priority must be one of #{PRIORITIES.join(', ')}" unless PRIORITIES.include?(priority)
|
|
35
|
+
|
|
36
|
+
taken = []
|
|
37
|
+
if priority == :live && tenant_key
|
|
38
|
+
return Decision.new(:demoted, :backfill, :tenant_cap) unless take(tenant_counter(tenant_key), records, config.tenant_live_cap, taken)
|
|
39
|
+
end
|
|
40
|
+
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)
|
|
42
|
+
end
|
|
43
|
+
return deny(priority, :exhausted, taken) unless take_second(priority)
|
|
44
|
+
|
|
45
|
+
Decision.new(:granted, priority, nil)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Counts one unit against the per-user cap for `priority` without taking
|
|
49
|
+
# a request slot, for callers that gate work but make no Jev call
|
|
50
|
+
# themselves (the Smart dispatcher; each rerank chunk acquires its own).
|
|
51
|
+
def admit(priority:, user_key:)
|
|
52
|
+
priority = priority.to_sym
|
|
53
|
+
raise ArgumentError, "priority must be one of #{PRIORITIES.join(', ')}" unless PRIORITIES.include?(priority)
|
|
54
|
+
|
|
55
|
+
cap = config.user_caps[priority]
|
|
56
|
+
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)
|
|
58
|
+
|
|
59
|
+
Decision.new(:granted, priority, nil)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def gem_per_minute
|
|
63
|
+
(config.requests_per_minute * (1 - config.headroom)).floor
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ceiling(priority)
|
|
67
|
+
[ gem_per_minute / 60.0 * config.priority_ceilings.fetch(priority), 1 ].max
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def take_second(priority)
|
|
73
|
+
deadline = @clock.call + (priority == :live ? config.max_wait : 0)
|
|
74
|
+
loop do
|
|
75
|
+
now = @clock.call
|
|
76
|
+
second = now.floor
|
|
77
|
+
return true if take("second/#{second}", 1, ceiling(priority))
|
|
78
|
+
|
|
79
|
+
wait = second + 1 - now
|
|
80
|
+
return false if now + wait > deadline
|
|
81
|
+
|
|
82
|
+
@sleeper.call(wait)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def take(counter, amount, limit, taken = nil)
|
|
87
|
+
key = "truffler/budget/#{counter}"
|
|
88
|
+
count = @cache.increment(key, amount, expires_in: 2.minutes)
|
|
89
|
+
return true if count.nil?
|
|
90
|
+
|
|
91
|
+
if count <= limit
|
|
92
|
+
taken&.push([ key, amount ])
|
|
93
|
+
true
|
|
94
|
+
else
|
|
95
|
+
@cache.decrement(key, amount, expires_in: 2.minutes)
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def deny(priority, reason, taken)
|
|
101
|
+
taken.each { |key, amount| @cache.decrement(key, amount, expires_in: 2.minutes) }
|
|
102
|
+
Instrumentation.instrument(:budget_denied, priority: priority)
|
|
103
|
+
Decision.new(:denied, priority, reason)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def tenant_counter(tenant_key)
|
|
107
|
+
"tenant/#{tenant_key}/#{minute}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def user_counter(priority, user_key)
|
|
111
|
+
"user/#{priority}/#{user_key}/#{minute}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def minute
|
|
115
|
+
(@clock.call / 60).floor
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Truffler
|
|
5
|
+
# JSON with object keys sorted at every level, so key order never changes a
|
|
6
|
+
# digest. Cassettes, fingerprints, and cache keys all hash through here.
|
|
7
|
+
module Canonical
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def json(value)
|
|
11
|
+
JSON.generate(sort(value))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def digest(value)
|
|
15
|
+
Digest::SHA256.hexdigest(json(value))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def sort(value)
|
|
19
|
+
case value
|
|
20
|
+
when Hash then value.to_h { |key, item| [ key.to_s, sort(item) ] }.sort.to_h
|
|
21
|
+
when Array then value.map { |item| sort(item) }
|
|
22
|
+
when Symbol then value.to_s
|
|
23
|
+
else value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|