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.
Files changed (214) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +18 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +425 -0
  5. data/bench/cassettes/004cad52c71fc8d847bf388eb9c2c288e1169b65ec4a28ce040cb7fea7c55f04.json +1 -0
  6. data/bench/cassettes/0547d3d23c3fbaae86772ffa0e4e96475aa152a2556b8fa4bc7568effcb81a28.json +1 -0
  7. data/bench/cassettes/07aa23d166ab3500178e3f3c0838183051b093507bef88e1e2d29efdf5759ee6.json +1 -0
  8. data/bench/cassettes/0858c5006c0bfac3e970ddaff3c139f6c110dbe3d8539fd95263706c637cdc8e.json +1 -0
  9. data/bench/cassettes/0c0eb7c27d76bd0b9263690a63cd92d6b68c305826318555fd5b084a09970b78.json +1 -0
  10. data/bench/cassettes/0fada0c0a103882e2895f7f455d73d2cbe2b4a81bf743bb19b2b4f6e0130126b.json +1 -0
  11. data/bench/cassettes/1117d79ac7c43b21beaf75e510461f91cd4ecc3cdbbe461f1794644af4aaf8f3.json +1 -0
  12. data/bench/cassettes/11b7444a074cca4d0ef1535db0cf0edefe4c34eac4be3c593c9c2f33d3b038b8.json +1 -0
  13. data/bench/cassettes/11d00371d1b993675be00463159164536f1f8ed90a48652b0935eb9c36f8b684.json +1 -0
  14. data/bench/cassettes/1456ebe12616ce961d68a14eada62778a7271ebd3d1b4cb6eefd6857651c7afc.json +1 -0
  15. data/bench/cassettes/14c3693f64b0e9ea7e470277fccae6ab501d404185eec5bb9074ef4b75c572c0.json +1 -0
  16. data/bench/cassettes/14fe9f6d8055d41f5d970dc32ab640c213ea8782d365c82d32b9884ba4f28c67.json +1 -0
  17. data/bench/cassettes/1a1d8557e4698426c2467aa573a240357715d3d9e3fa77bf066672a8b6bf9a86.json +1 -0
  18. data/bench/cassettes/1b5a8aaaf98305560588246cd51f53c6c185a951f8018c26bf588954d9de1cea.json +1 -0
  19. data/bench/cassettes/1fe596be747e33e9c7e4d7c50de32613957ac66bee43679b4bed96c551b1fd0a.json +1 -0
  20. data/bench/cassettes/21ebce6db6d39b1747dccf25bd3ceb298e1285425190b4d78830c395a7ad786a.json +1 -0
  21. data/bench/cassettes/23066253621e9cb00fd93cbe95ef1124ff0715585fcb6325b3abbd0802b2c303.json +1 -0
  22. data/bench/cassettes/2f6576325dfab696cec8d8b35fa89c29960bee15d10dab31c91cef507f4842be.json +1 -0
  23. data/bench/cassettes/33ec27d22c0f63dbbe93008f50af07fa8667a5c806a9fbfc7897683915412b40.json +1 -0
  24. data/bench/cassettes/351f745b0c47e0cf3272b0fea7fc4941b6aa733f013c55a9a19707b542543200.json +1 -0
  25. data/bench/cassettes/37d974fdc0c1876413111c244d363c8d2306e3229fec668c158b5c924ffdb983.json +1 -0
  26. data/bench/cassettes/39524467463dd612cbf03e314b9cfd8cbe1c5f06969da2289359cf7cf83de5da.json +1 -0
  27. data/bench/cassettes/3f2c758c111ab7f1163ba579f2329718afe15620507d356d3ebe2a053df237ea.json +1 -0
  28. data/bench/cassettes/44bbc3a3f0ceb0edb443b080b939af80dd145795505bd13cac113a35365d0f04.json +1 -0
  29. data/bench/cassettes/45bf701a13c348acb45a3b3a56990570e3caf797c3daee5d1358a477593ce587.json +1 -0
  30. data/bench/cassettes/4676a0f2df29493fdfa0b7cd75c097e647373862d88373dc0e1fa9fca26707c2.json +1 -0
  31. data/bench/cassettes/49708a50e3c78f45beed2f1b8d8d5375b99ea4ab3497fed7650e6ece4ec81ba8.json +1 -0
  32. data/bench/cassettes/54ea30ecca8fa49f607d5c8265296eb16edb48d0620a7399c6480d4cb0f43ae1.json +1 -0
  33. data/bench/cassettes/550e73a8291f53d0f2b008f166560eff4d10cc7c09a927d3c4f70544f2578300.json +1 -0
  34. data/bench/cassettes/570039823d5af1b6c1d4bcbe14f9802d40e851f8e4946278a46884c76c0ceee1.json +1 -0
  35. data/bench/cassettes/5ae9dce82fa7b9084b592f657a24acfd5852c4fcb091a6a8e22f2cbbf947bcdd.json +1 -0
  36. data/bench/cassettes/5c40794d3bf6e229c6ffd26922388776121f971ef27279e02a35ce004e0794dd.json +1 -0
  37. data/bench/cassettes/62f2e71a2d9cfdc18fdd6600f5a1450519b25a75c2f799b7695258627250497d.json +1 -0
  38. data/bench/cassettes/642becd7ad9cd4cc2ac6d389ef8ff14fd9fe125beca834afee625ea2c31476be.json +1 -0
  39. data/bench/cassettes/656cc1158890e928c129d9cd54c1e08af1b6a2c0f6f94bacf395b13a198dc81f.json +1 -0
  40. data/bench/cassettes/67b30efd5681046fb5f2252d73f9aedf603be1b7c7895e4e71c6cfb88238309e.json +1 -0
  41. data/bench/cassettes/691958dd39537d72c4b0ab4ba69fea3c51b8e06a49a772f3ffd5bb5747dfe99d.json +1 -0
  42. data/bench/cassettes/6931e51f0455b5e3260165aa22a445d3a4f782ae5233a961384149cfbb045fe0.json +1 -0
  43. data/bench/cassettes/6b79cf1a80fc9edd09bb4606613ff2de2ab2bc61487983dffac1174025270997.json +1 -0
  44. data/bench/cassettes/6d683884d5d039db072ac18c9420bdcc989838f08a71096076cccc2edcd48234.json +1 -0
  45. data/bench/cassettes/6df49dc7eae4104adf1b020fd846d893da3e49f4f13a4c6fd1f87a78c410ba31.json +1 -0
  46. data/bench/cassettes/75cc5d99bbc1d48ace6095d41daae1e2e2c829291f01d40f0ce40d6eea52c0bf.json +1 -0
  47. data/bench/cassettes/771a86cbb6c025101384a50d01a4745683e1c2b17bbfff73e40e475dbf016efb.json +1 -0
  48. data/bench/cassettes/7a2a93c357473f4a289a6ec250e3bfe6f51152a0c448e5420ffebee7872f9d11.json +1 -0
  49. data/bench/cassettes/7a4774a02c3eebe24d4fbd443ea90661711fe5cb8248bce7a2dedfedf6ddbc03.json +1 -0
  50. data/bench/cassettes/7c1fa04f8eb43251ca3f6652977b698e4e6ba5e120da8301b4dbce79ef01ccb9.json +1 -0
  51. data/bench/cassettes/7c4dea48c2bc2c413da81307ce3cbb2749b20e16e4d831fca5861decf11ec55b.json +1 -0
  52. data/bench/cassettes/7dd499da389eaeeb59eba533695c20fd1a43e25f89f21e3456b826f9953b2010.json +1 -0
  53. data/bench/cassettes/80438d0bbfe106baa236eef358e46260693e6491f67e7a9f8ea1076c6fbfd449.json +1 -0
  54. data/bench/cassettes/80db562534181514f243ac74d7ddaec1fafc28b2b72ae35482cfc2de8f2d9e61.json +1 -0
  55. data/bench/cassettes/825a51543665a468a55c823788c68eb5785c90be8131988f72bf98f4f0294177.json +1 -0
  56. data/bench/cassettes/83d8276c227b73e542bd70c023d9a8ffa1c2cf4dd78e71d61457f097bc743d19.json +1 -0
  57. data/bench/cassettes/891405ffd7727b6d4c4186e2c60a08aa8b62c7b711bd1558c8033fcf8e4178a4.json +1 -0
  58. data/bench/cassettes/89fabff05dc4c0abdf630781a324b7802cbc0dc8bd09b20ce59643465a2aeaec.json +1 -0
  59. data/bench/cassettes/8cbceece3ca6136b6c6bb71c9bf28f409a5fa070596ebfc0f3187aba287bf6d3.json +1 -0
  60. data/bench/cassettes/8d56da27350d9dea20468eae30fa51cbbb10637c06d977b3e1a00734966be46f.json +1 -0
  61. data/bench/cassettes/8fb43b452b5e15248f34bf48e41cfc227129cfe349fe4b3fc4ab7133d66914bc.json +1 -0
  62. data/bench/cassettes/92e5426bd0c1f31d7acca92f77d7f2586e74054467729f0be28d139f515354d5.json +1 -0
  63. data/bench/cassettes/938be225155e1952b817652778b7cab377017fc4a219628ae89ad01a9c1151bc.json +1 -0
  64. data/bench/cassettes/9519ad701b28e7b2c5f1e1cad5df1ec3e5254c8cec969b9bee1361af8803d39d.json +1 -0
  65. data/bench/cassettes/9536e8ed68330eda400d765c3ca16a4dd3bfe0d026b569599a33e7e4951f4860.json +1 -0
  66. data/bench/cassettes/99c6e5aefd97722f4028bacc11d299eb9036805cd5cf7bf75f879ebe3f27c00a.json +1 -0
  67. data/bench/cassettes/9cdfd2f3ef8dc4cde917a9d196e3137159067c10ec8d2132f32da034865f9959.json +1 -0
  68. data/bench/cassettes/a1e3030984d4e1982fb5a2b2068664187358c88c6b71f768841d3e78406e9535.json +1 -0
  69. data/bench/cassettes/a2140f51b8fa25da73efd387a41f9eb84ffbc1f1ab47dec0c3b37b249ff373a0.json +1 -0
  70. data/bench/cassettes/a36d248ce5c26fd66430b780c8ecdd176f29830ac4edb2bf0b2e07cb93ffb865.json +1 -0
  71. data/bench/cassettes/a64329816ca1e537e90b89862a86be119e1e6236c6f3cf0141a96505b0734364.json +1 -0
  72. data/bench/cassettes/aba24c67dcaefa51c204a529b5ea3fe01333181a608a8ab8899568c731677e7d.json +1 -0
  73. data/bench/cassettes/aed233524926f2b4ff8c2ec21224bd284595c91eb57e0fb6981cdc8ac7e7d8d3.json +1 -0
  74. data/bench/cassettes/b00e9da6403e48aa1f4f9346419f88b2a336e1a2ac4a28515d84b2337eb777b2.json +1 -0
  75. data/bench/cassettes/b02f4c76c073882ff0fd12fddc28e4db52d9e6486a130194bf1057c7693de831.json +1 -0
  76. data/bench/cassettes/b6a9cb89ae4afd08a6919eaf7f609790c895d4cf22d47dad6c3b287b9278be87.json +1 -0
  77. data/bench/cassettes/b71b982225eab48d02c81913877571dfa53717c0983d1d72cbe3787242dda055.json +1 -0
  78. data/bench/cassettes/bda5e115b566e7f955aa45222859fe4f725949d5f91e3d794821701491448510.json +1 -0
  79. data/bench/cassettes/c0ac703268faf8d49dbc1ad0e877693a9f52181ae6070de844fcd4218d33d5e4.json +1 -0
  80. data/bench/cassettes/c0b57bcbb4004edd44c2d8c7e1f8cfc3567d954a3d5b21b2cda7257e3bc07437.json +1 -0
  81. data/bench/cassettes/d1ade9adde20981211c470a77b4d560264cae290941e692be6876a79227e3d18.json +1 -0
  82. data/bench/cassettes/d28de3038544a5bf9c8425b6976e360fae71c5ca96409784ad6642703701986a.json +1 -0
  83. data/bench/cassettes/d6418acb9f7f58ffdf3049481cbd17cfb3f0581064e157c9c58c19a3fc26b4d4.json +1 -0
  84. data/bench/cassettes/d71e8cf7c2be4b6e4089eb40c5a108c0c45fc58398af5138f905129f640afbb8.json +1 -0
  85. data/bench/cassettes/d9672c45b5358b92f7740abfb20b287235cf949990c88558f41d3a9c00eb6d3b.json +1 -0
  86. data/bench/cassettes/da0c9ce695111f6acf3cfb58dc0494f79230739d1773377db420bf438fd2e244.json +1 -0
  87. data/bench/cassettes/db57e3a15971cd2832313172c0d7df6e6a48b13a276ba9877eeb7d39389d74d3.json +1 -0
  88. data/bench/cassettes/db9c8c4d0a8d855a2578572afe5edc5eebb39c0fd5f04489614ea2557ec4f0cd.json +1 -0
  89. data/bench/cassettes/e2c396b0475a07a615dde9208b088e9e479720c1ec5889ade07fbfd0d28fb517.json +1 -0
  90. data/bench/cassettes/e3006ccc97646f43f4ea23694665526e47982fa1ea413359b9669071f6cb990d.json +1 -0
  91. data/bench/cassettes/e623737636e797d22f80d128cbff46101837044e159f41fa62a8d3d0cc6bb996.json +1 -0
  92. data/bench/cassettes/ea0cefaaac1a62e8eac962391df025a3b14c3a3d5b69fe57d60b65811010908c.json +1 -0
  93. data/bench/cassettes/ee3e06456cf4201ab46f579dd53bdea4071ce43bfe02e9b870bcb4b9c1c680b4.json +1 -0
  94. data/bench/cassettes/f26e675da9088f08cb76dd6b3fd89230b84fd1b9be3767c36857217bda2da15b.json +1 -0
  95. data/bench/cassettes/f396f34b2356d42af385eac9f5760e97e553a584c3f93da7bd618ad311e04d93.json +1 -0
  96. data/bench/cassettes/fce02c5c04cb8c27e868f6c54cfcbbbf69bde2f7f612d50ff8fb6d0aec9df3e3.json +1 -0
  97. data/bench/cassettes/ffc09a04b083822c1597dd96bb27ee4cde365bc8346175ebf6e378ab2ab1b598.json +1 -0
  98. data/bench/cassettes/manifest.json +1 -0
  99. data/bench/fixtures/gold.jsonl +27 -0
  100. data/bench/fixtures/injection.jsonl +6 -0
  101. data/bench/fixtures/records.jsonl +90 -0
  102. data/bench/params.yml +30 -0
  103. data/lib/generators/truffler/install/install_generator.rb +51 -0
  104. data/lib/generators/truffler/install/templates/channel.rb.tt +17 -0
  105. data/lib/generators/truffler/install/templates/initializer.rb.tt +23 -0
  106. data/lib/generators/truffler/install/templates/migration.rb.tt +109 -0
  107. data/lib/tasks/truffler/bench.rake +30 -0
  108. data/lib/tasks/truffler/suggestions.rake +8 -0
  109. data/lib/tasks/truffler.rake +26 -0
  110. data/lib/truffler/answers.rb +66 -0
  111. data/lib/truffler/benchmark/adapters.rb +80 -0
  112. data/lib/truffler/benchmark/database.rb +31 -0
  113. data/lib/truffler/benchmark/dataset.rb +65 -0
  114. data/lib/truffler/benchmark/email.rb +50 -0
  115. data/lib/truffler/benchmark/generator.rb +188 -0
  116. data/lib/truffler/benchmark/metrics.rb +62 -0
  117. data/lib/truffler/benchmark/params.rb +91 -0
  118. data/lib/truffler/benchmark/runner.rb +370 -0
  119. data/lib/truffler/benchmark/synthetic_jev.rb +85 -0
  120. data/lib/truffler/benchmark.rb +29 -0
  121. data/lib/truffler/broadcaster.rb +46 -0
  122. data/lib/truffler/budget.rb +118 -0
  123. data/lib/truffler/canonical.rb +27 -0
  124. data/lib/truffler/clients/base.rb +49 -0
  125. data/lib/truffler/clients/callable.rb +26 -0
  126. data/lib/truffler/clients/cassette.rb +44 -0
  127. data/lib/truffler/clients/fake.rb +85 -0
  128. data/lib/truffler/clients/ruby_llm_typesafe.rb +32 -0
  129. data/lib/truffler/configuration.rb +89 -0
  130. data/lib/truffler/definition.rb +176 -0
  131. data/lib/truffler/embeddings/backfill.rb +48 -0
  132. data/lib/truffler/embeddings/column_store.rb +65 -0
  133. data/lib/truffler/embeddings/embedder.rb +53 -0
  134. data/lib/truffler/embeddings/fake_embedder.rb +45 -0
  135. data/lib/truffler/embeddings/label_vector.rb +81 -0
  136. data/lib/truffler/embeddings/neighbor_store.rb +66 -0
  137. data/lib/truffler/embeddings/ruby_llm_embedder.rb +25 -0
  138. data/lib/truffler/embeddings/ruby_store.rb +12 -0
  139. data/lib/truffler/embeddings/vector_store.rb +84 -0
  140. data/lib/truffler/embeddings.rb +30 -0
  141. data/lib/truffler/errors.rb +40 -0
  142. data/lib/truffler/instrumentation.rb +17 -0
  143. data/lib/truffler/jobs/backfill_job.rb +45 -0
  144. data/lib/truffler/jobs/embed_job.rb +33 -0
  145. data/lib/truffler/jobs/encode_query_job.rb +17 -0
  146. data/lib/truffler/jobs/expire_lenses_job.rb +15 -0
  147. data/lib/truffler/jobs/label_flush_job.rb +39 -0
  148. data/lib/truffler/jobs/lens_backfill_job.rb +33 -0
  149. data/lib/truffler/jobs/provider_search_job.rb +18 -0
  150. data/lib/truffler/jobs/prune_query_misses_job.rb +15 -0
  151. data/lib/truffler/jobs/rerank_chunk_job.rb +14 -0
  152. data/lib/truffler/jobs/resume_job.rb +61 -0
  153. data/lib/truffler/jobs/smart_search_job.rb +14 -0
  154. data/lib/truffler/label_definition.rb +153 -0
  155. data/lib/truffler/labeling/backfill.rb +189 -0
  156. data/lib/truffler/labeling/labeler.rb +161 -0
  157. data/lib/truffler/labeling/queue.rb +125 -0
  158. data/lib/truffler/labeling/request_builder.rb +87 -0
  159. data/lib/truffler/labeling/supplied.rb +70 -0
  160. data/lib/truffler/lenses/activator.rb +51 -0
  161. data/lib/truffler/lenses/backfill.rb +112 -0
  162. data/lib/truffler/lenses/draft.rb +16 -0
  163. data/lib/truffler/lenses/drafter.rb +147 -0
  164. data/lib/truffler/lenses/fake_generator.rb +58 -0
  165. data/lib/truffler/lenses/lens.rb +161 -0
  166. data/lib/truffler/lenses/lens_label.rb +51 -0
  167. data/lib/truffler/lenses/policy.rb +46 -0
  168. data/lib/truffler/lenses/previewer.rb +217 -0
  169. data/lib/truffler/lenses/proposer.rb +53 -0
  170. data/lib/truffler/lenses/ruby_llm_generator.rb +42 -0
  171. data/lib/truffler/lenses/scope.rb +35 -0
  172. data/lib/truffler/lenses/sealed_description.rb +29 -0
  173. data/lib/truffler/lenses/settings.rb +45 -0
  174. data/lib/truffler/lenses/validation.rb +69 -0
  175. data/lib/truffler/lenses/version.rb +34 -0
  176. data/lib/truffler/lenses.rb +138 -0
  177. data/lib/truffler/misses/clusterer.rb +69 -0
  178. data/lib/truffler/misses/recorder.rb +29 -0
  179. data/lib/truffler/misses/suggestions.rb +49 -0
  180. data/lib/truffler/misses.rb +83 -0
  181. data/lib/truffler/model.rb +111 -0
  182. data/lib/truffler/providers/backup.rb +58 -0
  183. data/lib/truffler/providers/runner.rb +67 -0
  184. data/lib/truffler/providers.rb +57 -0
  185. data/lib/truffler/query_encoding/cache.rb +71 -0
  186. data/lib/truffler/query_encoding/encoder.rb +183 -0
  187. data/lib/truffler/query_encoding/prefetch.rb +34 -0
  188. data/lib/truffler/query_encoding.rb +12 -0
  189. data/lib/truffler/questions.rb +78 -0
  190. data/lib/truffler/railtie.rb +11 -0
  191. data/lib/truffler/records/embedding.rb +39 -0
  192. data/lib/truffler/records/label.rb +11 -0
  193. data/lib/truffler/records/query_miss.rb +20 -0
  194. data/lib/truffler/records/record_state.rb +30 -0
  195. data/lib/truffler/redaction.rb +24 -0
  196. data/lib/truffler/registry.rb +16 -0
  197. data/lib/truffler/search/encoding.rb +84 -0
  198. data/lib/truffler/search/encoding_cache.rb +68 -0
  199. data/lib/truffler/search/keystroke.rb +149 -0
  200. data/lib/truffler/search/query.rb +67 -0
  201. data/lib/truffler/search/result.rb +100 -0
  202. data/lib/truffler/search/sql.rb +197 -0
  203. data/lib/truffler/smart_search/dispatcher.rb +88 -0
  204. data/lib/truffler/smart_search/reranker.rb +105 -0
  205. data/lib/truffler/smart_search/run.rb +306 -0
  206. data/lib/truffler/smart_search/starter.rb +73 -0
  207. data/lib/truffler/smart_search/store.rb +89 -0
  208. data/lib/truffler/smart_search.rb +43 -0
  209. data/lib/truffler/tokens.rb +12 -0
  210. data/lib/truffler/usage.rb +7 -0
  211. data/lib/truffler/version.rb +3 -0
  212. data/lib/truffler/vocabulary.rb +48 -0
  213. data/lib/truffler.rb +41 -0
  214. 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