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,45 @@
1
+ require "digest"
2
+
3
+ module Truffler
4
+ module Embeddings
5
+ # Deterministic vectors for tests and benchmarks: each token adds a signed
6
+ # unit at a position chosen by its hash, and the sum is normalized, so
7
+ # texts that share words point the same way.
8
+ class FakeEmbedder < Embedder
9
+ attr_reader :calls
10
+
11
+ def initialize(model: nil)
12
+ @model = model
13
+ @error = nil
14
+ @calls = []
15
+ end
16
+
17
+ def fail_with(error)
18
+ @error = error
19
+ self
20
+ end
21
+
22
+ def recover!
23
+ @error = nil
24
+ self
25
+ end
26
+
27
+ def perform(texts, model:, dimensions:)
28
+ @calls << { texts: texts, model: model, dimensions: dimensions }
29
+ raise @error if @error
30
+
31
+ { vectors: texts.map { |text| vector_for(text, dimensions) }, model: @model || model }
32
+ end
33
+
34
+ def vector_for(text, dimensions)
35
+ vector = Array.new(dimensions, 0.0)
36
+ text.to_s.downcase.scan(/[[:alnum:]]+/).each do |token|
37
+ digest = Digest::SHA256.digest(token)
38
+ vector[digest.unpack1("N") % dimensions] += digest.getbyte(4).even? ? 1.0 : -1.0
39
+ end
40
+ norm = Math.sqrt(vector.sum { |value| value * value })
41
+ norm.zero? ? vector : vector.map { |value| value / norm }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,81 @@
1
+ module Truffler
2
+ module Embeddings
3
+ # A record's labels as a named-dimension vector (KTD20): one float per
4
+ # storage key in sorted order, choice options expanded, stored in
5
+ # truffler_embeddings beside the optional text vector and tagged with the
6
+ # vocabulary version that fixed the key order. Unlabeled keys are 0.0.
7
+ # Vectors are built from truffler_labels only, so a vocabulary change
8
+ # rebuilds them without asking Jev. Lens labels in the record's labeling
9
+ # scope (KTD21) extend the vector after the declared ones.
10
+ class LabelVector
11
+ attr_reader :model
12
+
13
+ def initialize(model)
14
+ @model = model
15
+ end
16
+
17
+ def keys(tenant_key = nil)
18
+ vocabulary.labels_for(tenant_key: tenant_key, all_users: true).values.flat_map { |label| label.storage_keys(tenant_key) }.sort
19
+ end
20
+
21
+ def write(record_ids, tenant_key:)
22
+ return 0 if record_ids.empty?
23
+
24
+ keys = keys(tenant_key)
25
+ version = version_for(tenant_key)
26
+ values = Records::Label.where(record_type: record_type, record_id: record_ids, label_key: keys)
27
+ .pluck(:record_id, :label_key, :value)
28
+ .each_with_object(Hash.new { |hash, id| hash[id] = {} }) { |(id, key, value), map| map[id.to_s][key] = value }
29
+ now = Time.current
30
+ rows = record_ids.uniq.map do |id|
31
+ vector = keys.map { |key| values[id.to_s].fetch(key, 0.0) }
32
+ { record_type: record_type, record_id: id, tenant_key: tenant_key, label_vector: Records::Embedding.pack(vector),
33
+ label_vocabulary_version: version, created_at: now, updated_at: now }
34
+ end
35
+ Records::Embedding.upsert_all(rows, unique_by: %i[record_type record_id],
36
+ update_only: %i[tenant_key label_vector label_vocabulary_version updated_at])
37
+ rows.size
38
+ end
39
+
40
+ # Rewrites every vector whose vocabulary version is not current, for
41
+ # one tenant or all of them. Returns the number of records rewritten.
42
+ def rebuild(tenant_key: :all, batch_size: 500)
43
+ tenants = tenant_key == :all ? Records::Label.where(record_type: record_type).distinct.pluck(:tenant_key) : [ tenant_key ]
44
+ tenants.sum do |tenant|
45
+ version = version_for(tenant)
46
+ current = Records::Embedding.for_model(model).where(tenant_key: tenant, label_vocabulary_version: version).select(:record_id)
47
+ ids = Records::Label.where(record_type: record_type, tenant_key: tenant).where.not(record_id: current).distinct.pluck(:record_id)
48
+ ids.each_slice(batch_size).sum { |slice| write(slice, tenant_key: tenant) }
49
+ end
50
+ end
51
+
52
+ # The stored vector keyed by label, or nil when it is missing or was
53
+ # written under another vocabulary version.
54
+ def read(record)
55
+ tenant_key = definition.tenant_key_for(record)
56
+ row = Records::Embedding.for_model(model).find_by(record_id: record.id)
57
+ return unless row&.label_vector && row.label_vocabulary_version == version_for(tenant_key)
58
+
59
+ keys(tenant_key).zip(row.label_values).to_h
60
+ end
61
+
62
+ private
63
+
64
+ def definition
65
+ model.truffler_definition
66
+ end
67
+
68
+ def vocabulary
69
+ definition.vocabulary
70
+ end
71
+
72
+ def version_for(tenant_key)
73
+ vocabulary.version(tenant_key: tenant_key, all_users: true)
74
+ end
75
+
76
+ def record_type
77
+ model.polymorphic_name
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,66 @@
1
+ module Truffler
2
+ module Embeddings
3
+ # Cosine distance computed by the database: pgvector's `<=>` on a vector
4
+ # column, or sqlite-vec's `vec_distance_cosine` on float32 blobs (the
5
+ # host loads the extension). Because similarity is plain SQL, search can
6
+ # score text inline in its one query as an exact scan of the tenant's
7
+ # rows; an approximate (HNSW) index is an opt-in host migration.
8
+ class NeighborStore < VectorStore
9
+ TABLE = "truffler_embeddings".freeze
10
+
11
+ def self.available?(connection, table: TABLE, column: "embedding")
12
+ !dialect(connection, table: table, column: column).nil?
13
+ end
14
+
15
+ def self.dialect(connection, table: TABLE, column: "embedding")
16
+ case connection.adapter_name
17
+ when /postg/i
18
+ :postgres if connection.columns(table).find { |item| item.name == column.to_s }&.sql_type.to_s.start_with?("vector")
19
+ when /sqlite/i
20
+ :sqlite if sqlite_vec?(connection)
21
+ end
22
+ end
23
+
24
+ def self.sqlite_vec?(connection)
25
+ connection.select_value("SELECT vec_version()").present?
26
+ rescue ActiveRecord::StatementInvalid
27
+ false
28
+ end
29
+
30
+ def self.distance_sql(dialect, connection, column_sql, vector)
31
+ case dialect
32
+ when :sqlite then "vec_distance_cosine(#{column_sql}, X'#{Records::Embedding.pack(vector).unpack1('H*')}')"
33
+ when :postgres then "(#{column_sql} <=> #{connection.quote("[#{vector.map(&:to_f).join(',')}]")}::vector)"
34
+ else raise Error, "the neighbor vector store needs pgvector or the sqlite-vec extension"
35
+ end
36
+ end
37
+
38
+ def initialize(dialect: nil)
39
+ @dialect = dialect
40
+ end
41
+
42
+ def nearest(model, tenant_key:, vector:, k: DEFAULT_K)
43
+ distance = distance_sql(model, "#{TABLE}.embedding", vector)
44
+ embeddings(model, tenant_key, vector.size).order(Arel.sql(distance)).limit(k)
45
+ .pluck(:record_id, Arel.sql("1 - #{distance}")).map { |id, similarity| [ id, similarity.to_f ] }
46
+ end
47
+
48
+ def similarity_sql(model, tenant_key:, vector:, k: nil)
49
+ similarity = "1 - #{distance_sql(model, "#{TABLE}.embedding", vector)}"
50
+ subquery = embeddings(model, tenant_key, vector.size).where("#{TABLE}.record_id = #{primary_key_sql(model)}")
51
+ Arel.sql("COALESCE((#{subquery.select(Arel.sql(similarity)).to_sql}), 0.0)")
52
+ end
53
+
54
+ def inline_sql?(_model)
55
+ true
56
+ end
57
+
58
+ private
59
+
60
+ def distance_sql(model, column_sql, vector)
61
+ @dialect ||= self.class.dialect(model.connection)
62
+ self.class.distance_sql(@dialect, model.connection, column_sql, vector)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,25 @@
1
+ require "ruby_llm"
2
+
3
+ module Truffler
4
+ module Embeddings
5
+ # The default embedder: `RubyLLM.embed`, which ruby_llm 1.x and 2 share.
6
+ # ruby_llm is not a truffler dependency; this file loads only when the
7
+ # default embedder is used.
8
+ class RubyLLMEmbedder < Embedder
9
+ def perform(texts, model:, dimensions:)
10
+ embedding = RubyLLM.embed(texts, model: model, dimensions: dimensions)
11
+ vectors = embedding.vectors
12
+ vectors = [ vectors ] if vectors.first.is_a?(Numeric)
13
+ { vectors: vectors, model: embedding.model, input_tokens: input_tokens(embedding) }
14
+ end
15
+
16
+ private
17
+
18
+ def input_tokens(embedding)
19
+ return embedding.input_tokens if embedding.respond_to?(:input_tokens)
20
+
21
+ embedding.tokens&.input if embedding.respond_to?(:tokens)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,12 @@
1
+ module Truffler
2
+ module Embeddings
3
+ # Packed float blobs in truffler_embeddings with exact cosine computed in
4
+ # Ruby over the tenant's rows. For tests and small sets.
5
+ class RubyStore < VectorStore
6
+ def nearest(model, tenant_key:, vector:, k: DEFAULT_K)
7
+ rows = embeddings(model, tenant_key, vector.size).pluck(:record_id, :embedding)
8
+ rows.map { |id, blob| [ id, self.class.cosine(vector, Records::Embedding.unpack(blob)) ] }.max_by(k) { |_, similarity| similarity }
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,84 @@
1
+ module Truffler
2
+ module Embeddings
3
+ # Nearest-neighbor search over one tenant's vectors. `nearest` returns
4
+ # `[record_id, cosine_similarity]` pairs, most similar first.
5
+ # `similarity_sql` returns a scalar SQL expression over the model's table
6
+ # that search can put in its SELECT or ORDER BY: stores that compute
7
+ # similarity in the database (`inline_sql?`) scan every row exactly, and
8
+ # the rest fall back to a CASE over the top-K neighbors.
9
+ class VectorStore
10
+ ADAPTERS = %i[auto ruby neighbor].freeze
11
+ DEFAULT_K = 200
12
+
13
+ def self.for(model, config: Truffler.config)
14
+ embeddings = model.truffler_definition.embeddings
15
+ return unless embeddings
16
+ return ColumnStore.new(embeddings[:column]) if embeddings.key?(:column)
17
+
18
+ case config.vector_store&.to_sym
19
+ when :ruby then RubyStore.new
20
+ when :neighbor then NeighborStore.new
21
+ when :auto then NeighborStore.available?(model.connection) ? NeighborStore.new : RubyStore.new
22
+ else raise Error, "config.vector_store must be one of #{ADAPTERS.join(', ')}"
23
+ end
24
+ end
25
+
26
+ def self.cosine(left, right)
27
+ dot = left_norm = right_norm = 0.0
28
+ left.each_with_index do |value, index|
29
+ other = right[index]
30
+ dot += value * other
31
+ left_norm += value * value
32
+ right_norm += other * other
33
+ end
34
+ left_norm.zero? || right_norm.zero? ? 0.0 : dot / Math.sqrt(left_norm * right_norm)
35
+ end
36
+
37
+ def nearest(model, tenant_key:, vector:, k: DEFAULT_K)
38
+ raise NotImplementedError, "#{self.class.name}#nearest"
39
+ end
40
+
41
+ def similarity_sql(model, tenant_key:, vector:, k: DEFAULT_K)
42
+ pairs = nearest(model, tenant_key: tenant_key, vector: vector, k: k)
43
+ return Arel.sql("0.0") if pairs.empty?
44
+
45
+ connection = model.connection
46
+ whens = pairs.map { |id, similarity| "WHEN #{connection.quote(id)} THEN #{Float(similarity)}" }
47
+ Arel.sql("(CASE #{primary_key_sql(model)} #{whens.join(' ')} ELSE 0.0 END)")
48
+ end
49
+
50
+ def inline_sql?(_model)
51
+ false
52
+ end
53
+
54
+ def write(model, record, vector, fingerprint:)
55
+ now = Time.current
56
+ Records::Embedding.upsert(
57
+ { record_type: model.polymorphic_name, record_id: record.id, tenant_key: model.truffler_definition.tenant_key_for(record),
58
+ fingerprint: fingerprint, embedding: Records::Embedding.encode(vector), dimensions: vector.size,
59
+ created_at: now, updated_at: now },
60
+ unique_by: %i[record_type record_id],
61
+ update_only: %i[tenant_key fingerprint embedding dimensions updated_at]
62
+ )
63
+ end
64
+
65
+ private
66
+
67
+ def embeddings(model, tenant_key, dimensions)
68
+ check_tenant!(model, tenant_key)
69
+ Records::Embedding.for_model(model).with_vector.where(tenant_key: tenant_key&.to_s, dimensions: dimensions)
70
+ end
71
+
72
+ def check_tenant!(model, tenant_key)
73
+ return unless model.truffler_definition.scoped? && tenant_key.nil?
74
+
75
+ raise MissingScope, "#{model.name}: vector search needs a tenant"
76
+ end
77
+
78
+ def primary_key_sql(model)
79
+ connection = model.connection
80
+ "#{connection.quote_table_name(model.table_name)}.#{connection.quote_column_name(model.primary_key)}"
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,30 @@
1
+ module Truffler
2
+ # Optional text embeddings (R10, R11) and the label vectors of KTD20. The
3
+ # embedding text is built from the declared fields inside the embed step
4
+ # only and is never stored.
5
+ module Embeddings
6
+ module_function
7
+
8
+ def embedder
9
+ Truffler.config.embedder || RubyLLMEmbedder.new
10
+ end
11
+
12
+ # Gem-managed embeddings, as opposed to a host column the gem only reads.
13
+ def managed?(definition)
14
+ definition.embeddings.present? && !definition.embeddings.key?(:column)
15
+ end
16
+
17
+ # Changes when the embedding model, the width, or the embedded fields
18
+ # change, which marks every stored vector stale.
19
+ def fingerprint(definition)
20
+ settings = definition.embeddings
21
+ Canonical.digest(model: settings[:model], dimensions: settings[:dimensions], fields: definition.fields)
22
+ end
23
+
24
+ def text_for(definition, record, max_chars: Truffler.config.max_field_chars)
25
+ definition.field_values(record).filter_map do |field, value|
26
+ "#{field}: #{value.to_s.truncate(max_chars, omission: '')}" if value.present?
27
+ end.join("\n")
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,40 @@
1
+ module Truffler
2
+ class Error < StandardError; end
3
+
4
+ class DefinitionError < Error; end
5
+ class SuppliedLabelFailed < Error; end
6
+ class MissingScope < Error; end
7
+ class LiveCallInTest < Error; end
8
+ class IncompleteAnswers < Error; end
9
+ class CassetteMiss < Error; end
10
+ class BudgetExhausted < Error; end
11
+ class TenantMismatch < Error; end
12
+ class NotAuthorized < Error; end
13
+ class InvalidLens < Error; end
14
+ class LensSpendCapExceeded < Error; end
15
+ class InvalidSuppliedAnswer < Error; end
16
+
17
+ # Carries the HTTP status and the original error's class name only, never a
18
+ # response body, which can echo record text back.
19
+ class ClientError < Error
20
+ attr_reader :status, :error_class
21
+
22
+ def self.from(error)
23
+ new(status: status_of(error), error_class: error.class.name)
24
+ end
25
+
26
+ def self.status_of(error)
27
+ status = error.status if error.respond_to?(:status)
28
+ response = error.response if error.respond_to?(:response)
29
+ status ||= response.status if response.respond_to?(:status)
30
+ status ||= response[:status] if response.is_a?(Hash)
31
+ status&.to_i
32
+ end
33
+
34
+ def initialize(status: nil, error_class: nil)
35
+ @status = status
36
+ @error_class = error_class
37
+ super([ "Jev request failed", status && "(status #{status})", error_class ].compact.join(" "))
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,17 @@
1
+ module Truffler
2
+ module Instrumentation
3
+ module_function
4
+
5
+ def instrument(event, payload = {})
6
+ ActiveSupport::Notifications.instrument("truffler.#{event}", Redaction.safe(payload))
7
+ end
8
+
9
+ def monotonic_ms
10
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
11
+ end
12
+
13
+ def elapsed_ms(started)
14
+ (monotonic_ms - started).round(2)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,45 @@
1
+ module Truffler
2
+ module Jobs
3
+ # Backfills one model's stale, missing, failed, and demoted labels at
4
+ # backfill priority. Arguments are the record type, the id cursor, the
5
+ # spend so far, the cap, and the retry attempt, never record text. A
6
+ # budget denial or a page limit reschedules the job from its cursor; the
7
+ # spend cap ends it. A Jev error reschedules it with backoff and the spend
8
+ # already made, so retries cannot push past the cap; after MAX_ATTEMPTS
9
+ # the released rows wait for the ResumeJob sweep.
10
+ class BackfillJob < ActiveJob::Base
11
+ MAX_PAGES = 20
12
+ MAX_ATTEMPTS = 10
13
+ BUDGET_WAIT = 30.seconds
14
+
15
+ queue_as { Truffler.config.queue_name }
16
+
17
+ def perform(record_type, cursor: nil, spent: 0.0, spend_cap: Truffler.config.backfill_spend_cap, max_pages: MAX_PAGES,
18
+ attempt: 0)
19
+ model = record_type.safe_constantize
20
+ return unless model.respond_to?(:truffler_definition) && model.truffler_definition
21
+
22
+ result = Labeling::Backfill.new(model, cursor: cursor, spent: spent, spend_cap: spend_cap).run(max_pages: max_pages)
23
+ Instrumentation.instrument(:backfill, record_type: record_type, outcome: result.status,
24
+ labeled_count: result.labeled, request_count: result.requests, cost: result.cost)
25
+
26
+ follow_up = { cursor: result.cursor, spent: spent + result.cost, spend_cap: spend_cap, max_pages: max_pages }
27
+ case result.status
28
+ when :budget_denied then self.class.set(wait: BUDGET_WAIT).perform_later(record_type, **follow_up)
29
+ when :paused then self.class.perform_later(record_type, **follow_up)
30
+ when :client_error then retry_later(record_type, follow_up, attempt + 1)
31
+ when :complete, :spend_cap_reached then nil
32
+ else raise ArgumentError, "unknown backfill status #{result.status.inspect}"
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def retry_later(record_type, follow_up, attempt)
39
+ return if attempt >= MAX_ATTEMPTS
40
+
41
+ self.class.set(wait: (attempt**4) + 2).perform_later(record_type, **follow_up, attempt: attempt)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,33 @@
1
+ module Truffler
2
+ module Jobs
3
+ # Embeds one record's declared fields and stores the vector. Arguments are
4
+ # the record type and id only. On an embedder failure nothing is written,
5
+ # `embedded_at` stays as it was, and the job retries; labels are untouched.
6
+ class EmbedJob < ActiveJob::Base
7
+ queue_as { Truffler.config.queue_name }
8
+
9
+ retry_on ClientError, IncompleteAnswers, wait: :polynomially_longer, attempts: 10 do
10
+ nil
11
+ end
12
+
13
+ def perform(record_type, record_id)
14
+ model = record_type.safe_constantize
15
+ definition = model.try(:truffler_definition)
16
+ return unless definition && Embeddings.managed?(definition)
17
+
18
+ record = model.find_by(model.primary_key => record_id)
19
+ return Records::Embedding.where(record_type: record_type, record_id: record_id).delete_all unless record
20
+
21
+ settings = definition.embeddings
22
+ fingerprint = Embeddings.fingerprint(definition)
23
+ text = Embeddings.text_for(definition, record)
24
+ vector = Embeddings.embedder.embed([ text ], model: settings[:model], dimensions: settings[:dimensions]).vectors.first
25
+ Embeddings::VectorStore.for(model).write(model, record, vector, fingerprint: fingerprint)
26
+
27
+ now = Time.current
28
+ Records::RecordState.where(record_type: record_type, record_id: record.id)
29
+ .update_all(embedding_fingerprint: fingerprint, embedded_at: now, updated_at: now)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,17 @@
1
+ module Truffler
2
+ module Jobs
3
+ # Encodes one pending query at encode priority. The only argument is the
4
+ # cache key; the query itself waits in the cache (encrypted on encrypted
5
+ # models). A Jev failure is dropped: the in-flight marker is released,
6
+ # so the next keystroke of that query prefetches again.
7
+ class EncodeQueryJob < ActiveJob::Base
8
+ queue_as { Truffler.config.queue_name }
9
+
10
+ discard_on ClientError, IncompleteAnswers
11
+
12
+ def perform(cache_key)
13
+ QueryEncoding::Encoder.new.encode(cache_key)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ module Truffler
2
+ module Jobs
3
+ # Expires lenses unused for `config.lenses.expire_after` (R43). Takes no
4
+ # arguments; hosts schedule it, for example daily.
5
+ class ExpireLensesJob < ActiveJob::Base
6
+ queue_as { Truffler.config.queue_name }
7
+
8
+ def perform
9
+ expired = Lenses::Lens.expire_unused!
10
+ Instrumentation.instrument("lens_expire", expired_count: expired)
11
+ expired
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,39 @@
1
+ module Truffler
2
+ module Jobs
3
+ # Labels one tenant's pending live records. Arguments are the record type
4
+ # and tenant key only. On a Jev or budget failure the claimed rows go back
5
+ # to pending (or failed after max_attempts) and the job retries. Rows over
6
+ # the tenant's live cap drop to backfill priority and a delayed backfill
7
+ # is scheduled for them.
8
+ class LabelFlushJob < ActiveJob::Base
9
+ RETRYABLE = [ ClientError, BudgetExhausted, IncompleteAnswers ].freeze
10
+
11
+ queue_as { Truffler.config.queue_name }
12
+
13
+ retry_on(*RETRYABLE, wait: :polynomially_longer, attempts: 10) { nil }
14
+
15
+ def perform(record_type, tenant_key)
16
+ model = record_type.safe_constantize
17
+ return unless model.respond_to?(:truffler_definition) && model.truffler_definition
18
+
19
+ queue = Labeling::Queue.new(model)
20
+ queue.clear_marker(tenant_key)
21
+ states = queue.claim(tenant_key, priority: :live, limit: Truffler.config.batch_size)
22
+ return if states.empty?
23
+
24
+ begin
25
+ result = Labeling::Labeler.new(model).label(states, priority: :live)
26
+ rescue *RETRYABLE => error
27
+ queue.release(states, error)
28
+ raise
29
+ end
30
+
31
+ if result.demoted
32
+ queue.demote(states)
33
+ queue.schedule_backfill
34
+ end
35
+ queue.schedule(tenant_key) if queue.pending?(tenant_key, priority: :live)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,33 @@
1
+ module Truffler
2
+ module Jobs
3
+ # Backfills one lens's scope after it is activated or restored (R41,
4
+ # R45). Its only argument is the lens id. A budget denial or the batch
5
+ # limit reschedules it; the spend cap, expiry, or completion ends it.
6
+ class LensBackfillJob < ActiveJob::Base
7
+ RETRYABLE = [ ClientError, IncompleteAnswers ].freeze
8
+ MAX_BATCHES = 20
9
+ BUDGET_WAIT = 30.seconds
10
+
11
+ queue_as { Truffler.config.queue_name }
12
+
13
+ retry_on(*RETRYABLE, wait: :polynomially_longer, attempts: 10) { nil }
14
+
15
+ def perform(lens_id, max_batches: MAX_BATCHES)
16
+ lens = Lenses::Lens.find_by(id: lens_id)
17
+ return unless lens&.active?
18
+
19
+ result = Lenses::Backfill.new(lens, max_batches: max_batches).run
20
+ Instrumentation.instrument(:lens_backfill, record_type: lens.record_type, tenant_key: lens.tenant_key, lens_id: lens.id,
21
+ outcome: result.status, labeled_count: result.labeled, request_count: result.requests, cost: result.spent_usd)
22
+
23
+ case result.status
24
+ when :budget_denied then self.class.set(wait: BUDGET_WAIT).perform_later(lens_id, max_batches: max_batches)
25
+ when :paused then self.class.perform_later(lens_id, max_batches: max_batches)
26
+ when :complete, :spend_cap_reached, :inactive then nil
27
+ else raise ArgumentError, "unknown lens backfill status #{result.status.inspect}"
28
+ end
29
+ result
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ module Truffler
2
+ module Jobs
3
+ # Runs the provider backup search for one Smart run (R19). Arguments are
4
+ # the run id plus the searching tenant and user keys; the query text is
5
+ # read from the run, never carried in the job. See Truffler::Providers for
6
+ # the run interface. An expired run is a no-op.
7
+ class ProviderSearchJob < ActiveJob::Base
8
+ queue_as { Truffler.config.queue_name }
9
+
10
+ def perform(run_id, tenant_key, user_key)
11
+ run = Providers.find_run(run_id)
12
+ return unless run
13
+
14
+ Providers::Runner.new(run, tenant_key: tenant_key, user_key: user_key).call
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ module Truffler
2
+ module Jobs
3
+ # Enforces the miss log retention window (R29). Takes no arguments; hosts
4
+ # schedule it, for example daily.
5
+ class PruneQueryMissesJob < ActiveJob::Base
6
+ queue_as { Truffler.config.queue_name }
7
+
8
+ def perform
9
+ deleted = Records::QueryMiss.expired.in_batches(of: 1_000).delete_all
10
+ Instrumentation.instrument("miss_prune", deleted_count: deleted)
11
+ deleted
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ module Truffler
2
+ module Jobs
3
+ # Reranks one chunk of a Smart run and pings the searcher. Arguments are
4
+ # the run id and the chunk index only. A Jev failure marks the chunk
5
+ # failed so its buckets still resolve; it is not retried.
6
+ class RerankChunkJob < ActiveJob::Base
7
+ queue_as { Truffler.config.queue_name }
8
+
9
+ def perform(run_id, index)
10
+ SmartSearch::Reranker.new.call(SmartSearch::Run.load(run_id), Integer(index))
11
+ end
12
+ end
13
+ end
14
+ end