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,61 @@
1
+ module Truffler
2
+ module Jobs
3
+ # A periodic sweep hosts schedule (every few minutes) so labeling resumes
4
+ # after a Jev outage or a crashed worker. It returns failed rows and rows
5
+ # stuck in labeling to pending, reschedules a flush for tenants whose live
6
+ # rows have waited past `resume_pending_after`, and starts a backfill for
7
+ # rows waiting at backfill priority. For models with gem-managed
8
+ # embeddings it also enqueues up to `embedding_sweep_limit` missing or
9
+ # stale embeddings, at most once per `embedding_sweep_interval`, so an
10
+ # embedder outage cannot pile duplicate jobs onto the queue. Pass a record
11
+ # type to sweep one model.
12
+ class ResumeJob < ActiveJob::Base
13
+ queue_as { Truffler.config.queue_name }
14
+
15
+ class_attribute :embedding_sweep_limit, default: 1_000
16
+ class_attribute :embedding_sweep_interval, default: 1.hour
17
+
18
+ def perform(record_type = nil)
19
+ models = record_type ? [ record_type.safe_constantize ].compact : Truffler.registry.models
20
+ models.select { |model| model.respond_to?(:truffler_definition) && model.truffler_definition }.each { |model| sweep(model) }
21
+ end
22
+
23
+ private
24
+
25
+ def sweep(model)
26
+ states = Records::RecordState.for_model(model)
27
+ cutoff = Time.current - Truffler.config.resume_pending_after
28
+ requeued = requeue(states.where(status: "failed").or(states.where(status: "labeling").where(claimed_at: ...cutoff)))
29
+ waiting = states.where(status: "pending").where(updated_at: ...cutoff)
30
+
31
+ live = (requeued.select { |_, priority| priority == "live" }.map(&:first) +
32
+ waiting.where(priority: "live").distinct.pluck(:tenant_key)).uniq
33
+ queue = Labeling::Queue.new(model)
34
+ live.each do |tenant_key|
35
+ queue.clear_marker(tenant_key)
36
+ queue.schedule(tenant_key)
37
+ end
38
+
39
+ backfill = requeued.any? { |_, priority| priority == "backfill" } || waiting.exists?(priority: "backfill")
40
+ BackfillJob.perform_later(model.polymorphic_name) if backfill
41
+ sweep_embeddings(model)
42
+ end
43
+
44
+ def sweep_embeddings(model)
45
+ return unless Embeddings.managed?(model.truffler_definition)
46
+
47
+ marker = "truffler:embedding_sweep:#{model.polymorphic_name}"
48
+ return unless Truffler.config.cache_store.write(marker, true, unless_exist: true, expires_in: embedding_sweep_interval)
49
+
50
+ Embeddings::Backfill.new(model).enqueue(limit: embedding_sweep_limit)
51
+ end
52
+
53
+ # Returns the distinct [tenant_key, priority] pairs it moved to pending.
54
+ def requeue(scope)
55
+ pairs = scope.distinct.pluck(:tenant_key, :priority)
56
+ scope.update_all(status: "pending", attempts: 0, claimed_at: nil, updated_at: Time.current) if pairs.any?
57
+ pairs
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,14 @@
1
+ module Truffler
2
+ module Jobs
3
+ # Plans one Smart run: budget, encoding wait, filters, chunk fan-out.
4
+ # The only argument is the run id; the query waits in the cache
5
+ # (encrypted on encrypted models).
6
+ class SmartSearchJob < ActiveJob::Base
7
+ queue_as { Truffler.config.queue_name }
8
+
9
+ def perform(run_id)
10
+ SmartSearch::Dispatcher.new.call(SmartSearch::Run.load(run_id))
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,153 @@
1
+ module Truffler
2
+ # One typed label question. Nouls store their probability, scores their
3
+ # normalized position, and choices one row per option ("label:option") with
4
+ # that option's probability. Choice options may be a callable of the tenant
5
+ # key, which makes the vocabulary per-tenant.
6
+ #
7
+ # A label with `from:` is supplied by the host: its answer is read from the
8
+ # record in the shape Jev answers normalize to and Jev is never asked. Its
9
+ # question is optional, `watch:` names extra columns that refresh it, and
10
+ # `version:` forces a refresh when its logic changes.
11
+ class LabelDefinition
12
+ TYPES = %i[noul choice score].freeze
13
+ KEY = /\A[a-z][a-z0-9_]*\z/
14
+
15
+ attr_reader :key, :type, :instructions, :filter_at, :boost, :watch, :version
16
+ # Intent weight a filter decision adds to the KTD20 query vector (default 0).
17
+ attr_reader :filter_weight
18
+
19
+ def initialize(key, type, question: nil, criteria: nil, options: nil, legend: nil, filter_at: nil, boost: nil, filter_weight: 0.0,
20
+ description: nil, from: nil, watch: nil, version: nil)
21
+ @key = key.to_s
22
+ @type = type.to_sym
23
+ @instructions = question
24
+ @criteria = criteria
25
+ @options = options
26
+ @legend = legend
27
+ @filter_at = filter_at&.to_f
28
+ @boost = boost&.to_f
29
+ @filter_weight = Float(filter_weight)
30
+ @description = description
31
+ @from = from
32
+ @watch = Array(watch).map(&:to_s)
33
+ @version = version
34
+ validate!
35
+ end
36
+
37
+ def supplied?
38
+ !@from.nil?
39
+ end
40
+
41
+ # The label's wording for query encoding: the description, else the question, else the key.
42
+ def description
43
+ @description.presence || instructions.presence || key
44
+ end
45
+
46
+ def per_tenant?
47
+ @options.respond_to?(:call)
48
+ end
49
+
50
+ def options(tenant_key = nil)
51
+ options = per_tenant? ? @options.call(tenant_key) : @options
52
+ options = Array(options).to_h { |option| [ option, nil ] } unless options.is_a?(Hash)
53
+ raise DefinitionError, "#{key}: choice options for tenant #{tenant_key.inspect} are empty" if options.empty?
54
+
55
+ options.to_h { |option, description| [ option.to_s, description ] }
56
+ end
57
+
58
+ def question(tenant_key = nil)
59
+ wording = instructions.presence || description
60
+ Questions.build do |questions|
61
+ case type
62
+ when :noul then questions.noul(key, instructions: wording, criteria: @criteria)
63
+ when :choice then questions.choice(key, instructions: wording, criteria: options(tenant_key))
64
+ when :score then questions.score(key, instructions: wording, criteria: @legend)
65
+ end
66
+ end.fetch(key)
67
+ end
68
+
69
+ # Stored supplied values change with their shape and version, never with
70
+ # the Jev model. The description and option wording are digested too
71
+ # because query encoding asks about them.
72
+ def supplied_fingerprint(tenant_key = nil)
73
+ Canonical.digest(supplied: true, type: type, options: (options(tenant_key) if type == :choice),
74
+ levels: (levels if type == :score), description: description, version: version)
75
+ end
76
+
77
+ # {storage_key => value} read from the record, or nil when the host has
78
+ # no answer. Raises InvalidSuppliedAnswer for a value out of shape.
79
+ def supplied_values(record, tenant_key = nil)
80
+ value = @from.call(record)
81
+ return if value.nil?
82
+
83
+ case type
84
+ when :noul then { key => probability(value) }
85
+ when :score then { key => level(value) }
86
+ when :choice then choice_values(value, options(tenant_key).keys)
87
+ end
88
+ end
89
+
90
+ def storage_keys(tenant_key = nil)
91
+ type == :choice ? options(tenant_key).keys.map { |option| "#{key}:#{option}" } : [ key ]
92
+ end
93
+
94
+ # The label part of its Jev question id ("<tag>__<question_key>").
95
+ def question_key
96
+ key
97
+ end
98
+
99
+ private
100
+
101
+ def levels
102
+ Array(@legend).size
103
+ end
104
+
105
+ def probability(value)
106
+ return value ? 1.0 : 0.0 if [ true, false ].include?(value)
107
+ return value.to_f if value.is_a?(Numeric) && value.between?(0, 1)
108
+
109
+ raise InvalidSuppliedAnswer, "#{key}: expected a probability from 0 to 1 or true/false, got #{value.class}"
110
+ end
111
+
112
+ def level(value)
113
+ return value.to_f / (levels - 1) if value.is_a?(Integer) && value.between?(0, levels - 1)
114
+
115
+ raise InvalidSuppliedAnswer, "#{key}: expected a level index from 0 to #{levels - 1}"
116
+ end
117
+
118
+ def choice_values(value, options)
119
+ probabilities = value.is_a?(Hash) ? value.to_h { |option, share| [ option.to_s, share ] } : { value.to_s => 1.0 }
120
+ unknown = probabilities.keys - options
121
+ raise InvalidSuppliedAnswer, "#{key}: #{unknown.size} answer option(s) are not declared options" if unknown.any?
122
+
123
+ options.to_h { |option| [ "#{key}:#{option}", probabilities.key?(option) ? probability(probabilities[option]) : 0.0 ] }
124
+ end
125
+
126
+ def validate!
127
+ raise DefinitionError, "label #{key.inspect} must match #{KEY.source} without a double underscore" unless valid_key?
128
+ raise DefinitionError, "label #{key.inspect} is reserved for lens dimensions" if key == Lenses::KEY_PREFIX
129
+ raise DefinitionError, "#{key}: type must be one of #{TYPES.join(', ')}" unless TYPES.include?(type)
130
+ validate_supplied!
131
+ raise DefinitionError, "#{key}: choice labels need options:" if type == :choice && @options.blank?
132
+ raise DefinitionError, "#{key}: score labels need a legend: of at least two levels" if type == :score && levels < 2
133
+
134
+ question unless per_tenant?
135
+ rescue ArgumentError => error
136
+ raise DefinitionError, error.message
137
+ end
138
+
139
+ def validate_supplied!
140
+ unless supplied?
141
+ raise DefinitionError, "#{key}: a question is required" if instructions.blank?
142
+ raise DefinitionError, "#{key}: watch: and version: need from:" if @watch.any? || !@version.nil?
143
+
144
+ return
145
+ end
146
+ raise DefinitionError, "#{key}: from: must be callable with the record" unless @from.respond_to?(:call)
147
+ end
148
+
149
+ def valid_key?
150
+ KEY.match?(key) && !key.include?(Questions::SEPARATOR)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,189 @@
1
+ module Truffler
2
+ module Labeling
3
+ # Relabels one model's records whose labels are missing, stale (labeled
4
+ # under another vocabulary version), failed, or demoted to backfill
5
+ # priority. It walks newest-first below an id cursor, splits each page by
6
+ # tenant, packs batch_size records per request at backfill priority, and
7
+ # asks only the stale questions. Live pending rows belong to the flush job
8
+ # and are never touched.
9
+ #
10
+ # Resumable: the cursor moves past a page only once the whole page is
11
+ # done, and records already current are skipped, so a rerun never asks
12
+ # Jev about them again. A spend cap stops the run before a request would
13
+ # exceed it; host-supplied labels cost nothing, so they are still written
14
+ # once the cap is reached. A Jev error releases the claimed rows and ends
15
+ # the run with `:client_error`, so the caller keeps the spend metered so far.
16
+ class Backfill
17
+ Result = Data.define(:status, :labeled, :requests, :cost, :cursor)
18
+
19
+ STATES = Records::RecordState.table_name
20
+
21
+ class SpendCapReached < StandardError; end
22
+
23
+ # Wraps the client to meter spend per request and refuse a request whose
24
+ # estimated cost would push spend past the cap.
25
+ class SpendMeter
26
+ attr_reader :spent, :requests
27
+
28
+ def initialize(client, cap:, spent:, config: Truffler.config)
29
+ @client = client
30
+ @cap = cap
31
+ @spent = spent.to_f
32
+ @requests = 0
33
+ @config = config
34
+ end
35
+
36
+ def ask(state:, questions:, **options)
37
+ raise SpendCapReached if @cap && @spent + estimate(state, questions) > @cap
38
+
39
+ answers = @client.ask(state: state, questions: questions, **options)
40
+ @requests += 1
41
+ @spent += answers.usage&.cost.to_f
42
+ answers
43
+ end
44
+
45
+ private
46
+
47
+ def estimate(state, questions)
48
+ @config.cost_for(Tokens.estimate({ state: state, questions: questions }))
49
+ end
50
+ end
51
+
52
+ def self.status(model)
53
+ new(model).status
54
+ end
55
+
56
+ attr_reader :model, :batch_size, :page_size
57
+
58
+ def initialize(model, spend_cap: Truffler.config.backfill_spend_cap, batch_size: Truffler.config.batch_size,
59
+ page_size: nil, cursor: nil, spent: 0.0, client: Truffler.config.client, budget: Budget.new)
60
+ @model = model
61
+ @batch_size = batch_size
62
+ @page_size = page_size || batch_size * 5
63
+ @cursor = cursor
64
+ @meter = SpendMeter.new(client, cap: spend_cap, spent: spent)
65
+ @budget = budget
66
+ @versions = {}
67
+ end
68
+
69
+ def run(max_pages: nil)
70
+ @labeled = 0
71
+ @started_spent = @meter.spent
72
+ cursor = @cursor
73
+ pages = 0
74
+
75
+ loop do
76
+ scanned, rows = page(cursor)
77
+ return result(:complete, nil) if scanned.empty?
78
+ return result(:paused, cursor) if max_pages && pages >= max_pages
79
+
80
+ rows.group_by(&:last).each do |tenant_key, tenant_rows|
81
+ tenant_rows.map(&:first).each_slice(batch_size) do |ids|
82
+ stop = label(ids, tenant_key)
83
+ return result(stop, cursor) if stop
84
+ end
85
+ end
86
+
87
+ cursor = scanned.last
88
+ pages += 1
89
+ return result(:complete, nil) if scanned.size < page_size
90
+ end
91
+ end
92
+
93
+ def status
94
+ counts = states.group(:status).count
95
+ labeled = states.where(status: "labeled").group(:tenant_key, :vocabulary_version).count
96
+ stale = labeled.sum { |(tenant_key, version), count| version == version_for(tenant_key) ? 0 : count }
97
+ { total: model.count, missing: model.joins(state_join).where("#{STATES}.id IS NULL").count,
98
+ pending: counts["pending"].to_i, labeling: counts["labeling"].to_i, labeled: counts["labeled"].to_i,
99
+ failed: counts["failed"].to_i, stale: stale, current: labeled.values.sum - stale }
100
+ end
101
+
102
+ private
103
+
104
+ def definition
105
+ model.truffler_definition
106
+ end
107
+
108
+ def record_type
109
+ model.polymorphic_name
110
+ end
111
+
112
+ def states
113
+ Records::RecordState.for_model(model)
114
+ end
115
+
116
+ def queue
117
+ @queue ||= Queue.new(model)
118
+ end
119
+
120
+ def version_for(tenant_key)
121
+ @versions[tenant_key] ||= definition.vocabulary.version(tenant_key: tenant_key, all_users: true)
122
+ end
123
+
124
+ def result(status, cursor)
125
+ Result.new(status: status, labeled: @labeled, requests: @meter.requests, cost: @meter.spent - @started_spent,
126
+ cursor: cursor)
127
+ end
128
+
129
+ # Returns the scanned ids (for the cursor) and the [id, tenant_key] rows
130
+ # that still need labels. Per-tenant vocabularies (per-tenant choices or
131
+ # lenses) compare versions here because each tenant has its own.
132
+ def page(cursor)
133
+ pk = model.primary_key
134
+ scope = model.joins(state_join).where(needs_labeling_sql)
135
+ scope = scope.where(model.arel_table[pk].lt(cursor)) if cursor
136
+ tenant = definition.scoped? ? model.arel_table[definition.tenant_column] : Arel.sql("NULL")
137
+ plucked = scope.reorder(pk => :desc).limit(page_size)
138
+ .pluck(model.arel_table[pk], tenant, Arel.sql("#{STATES}.status"), Arel.sql("#{STATES}.vocabulary_version"))
139
+
140
+ rows = plucked.filter_map do |id, tenant_key, status, version|
141
+ tenant_key = tenant_key&.to_s
142
+ [ id, tenant_key ] unless status == "labeled" && version == version_for(tenant_key)
143
+ end
144
+ [ plucked.map(&:first), rows ]
145
+ end
146
+
147
+ def state_join
148
+ pk = "#{model.quoted_table_name}.#{model.connection.quote_column_name(model.primary_key)}"
149
+ ActiveRecord::Base.sanitize_sql_array([
150
+ "LEFT OUTER JOIN #{STATES} ON #{STATES}.record_type = ? AND #{STATES}.record_id = #{pk}", record_type
151
+ ])
152
+ end
153
+
154
+ def needs_labeling_sql
155
+ stale = if definition.per_tenant_vocabulary? || Lenses::Lens.active.for_model(model).exists?
156
+ "#{STATES}.status = 'labeled'"
157
+ else
158
+ ActiveRecord::Base.sanitize_sql_array([
159
+ "(#{STATES}.status = 'labeled' AND (#{STATES}.vocabulary_version IS NULL OR #{STATES}.vocabulary_version <> ?))",
160
+ version_for(nil)
161
+ ])
162
+ end
163
+ "#{STATES}.id IS NULL OR #{STATES}.status = 'failed' OR " \
164
+ "(#{STATES}.status = 'pending' AND #{STATES}.priority = 'backfill') OR #{stale}"
165
+ end
166
+
167
+ # Labels one tenant chunk. Returns nil when done, or the status that
168
+ # stops the run; claimed rows that were not labeled go back to pending
169
+ # at backfill priority.
170
+ def label(ids, tenant_key)
171
+ claimed = queue.claim_backfill(ids, tenant_key)
172
+ return if claimed.empty?
173
+
174
+ begin
175
+ Labeler.new(model, client: @meter, budget: @budget).label(claimed, priority: :backfill)
176
+ rescue BudgetExhausted, SpendCapReached => error
177
+ queue.demote(claimed)
178
+ return error.is_a?(SpendCapReached) ? :spend_cap_reached : :budget_denied
179
+ rescue ClientError, IncompleteAnswers => error
180
+ queue.release(claimed, error)
181
+ return :client_error
182
+ ensure
183
+ @labeled += Records::RecordState.where(id: claimed.map(&:id), status: "labeled").count
184
+ end
185
+ nil
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,161 @@
1
+ module Truffler
2
+ module Labeling
3
+ # Labels claimed record states of one tenant. It asks Jev only the labels
4
+ # that are missing or whose fingerprint is stale, packs records into as
5
+ # few requests as the budget allows, stores each answer as numeric label
6
+ # rows, and marks states labeled as each request lands, so a failure
7
+ # partway leaves finished records finished. Stale host-supplied labels
8
+ # are written first, before any budget slot or Jev call, so a Jev outage
9
+ # or denial never holds them back.
10
+ class Labeler
11
+ Result = Data.define(:labeled, :requests, :cost, :demoted)
12
+
13
+ attr_reader :model, :client, :budget
14
+
15
+ def initialize(model, client: Truffler.config.client, budget: Budget.new)
16
+ @model = model
17
+ @client = client
18
+ @budget = budget
19
+ end
20
+
21
+ def label(states, priority:)
22
+ return Result.new(labeled: 0, requests: 0, cost: 0.0, demoted: false) if states.empty?
23
+
24
+ tenant_key = tenant_key_of(states)
25
+ records = load_records(states)
26
+ vocabulary = definition.vocabulary
27
+ @labels = vocabulary.labels_for(tenant_key: tenant_key, all_users: true)
28
+ fingerprints = vocabulary.fingerprints(tenant_key: tenant_key, all_users: true)
29
+ version = vocabulary.version(tenant_key: tenant_key, all_users: true)
30
+ states_by_id = states.index_by { |state| state.record_id.to_s }
31
+
32
+ stored = stored_fingerprints(records)
33
+ askable = askable_labels
34
+ stale = records.map { |record| [ record, stale_keys(stored[record.id.to_s].to_h, fingerprints, tenant_key, askable) ] }
35
+ supplied = askable.select(&:supplied?).map(&:key)
36
+ supplier = Supplied.new(model)
37
+ written = supplier.write(stale.map { |record, keys| [ record, keys & supplied ] }, tenant_key: tenant_key)
38
+ current, pending = stale.map { |record, keys| [ record, keys - supplied ] }.partition { |_, keys| keys.empty? }
39
+ settled = current.reject { |record, _| supplier.failed_ids.include?(record.id) }
40
+ Records::RecordState.mark_labeled(settled.map { |record, _| states_by_id[record.id.to_s].id }, version: version)
41
+ Embeddings::LabelVector.new(model).write(current.map { |record, _| record.id } - written, tenant_key: tenant_key)
42
+
43
+ requests = RequestBuilder.new(definition, tenant_key: tenant_key, labels: @labels).build(pending)
44
+ cost = 0.0
45
+ requests.each_with_index do |request, index|
46
+ decision = budget.acquire(priority: priority, tenant_key: (tenant_key if index.zero?),
47
+ records: index.zero? ? pending.size : 1)
48
+ if decision.demoted?
49
+ retry_failed_supplied(supplier.failed_ids, states_by_id)
50
+ return Result.new(labeled: current.size, requests: index, cost: cost, demoted: true)
51
+ end
52
+ raise BudgetExhausted, "no Jev budget for #{priority} labeling" if decision.denied?
53
+
54
+ answers = client.ask(state: request.state, questions: request.questions, priority: decision.priority)
55
+ cost += answers.usage&.cost.to_f
56
+ charge_lenses(request, answers.usage&.cost.to_f)
57
+ store(request, answers, fingerprints, tenant_key, version, states_by_id)
58
+ end
59
+
60
+ retry_failed_supplied(supplier.failed_ids, states_by_id)
61
+ Result.new(labeled: records.size, requests: requests.size, cost: cost, demoted: false)
62
+ end
63
+
64
+ private
65
+
66
+ # A failed `from` must not leave its record looking current, or backfill
67
+ # would never ask again. Supplied labels cost nothing, so retrying at
68
+ # backfill priority is free.
69
+ def retry_failed_supplied(record_ids, states_by_id)
70
+ ids = record_ids.filter_map { |id| states_by_id[id.to_s]&.id }
71
+ Records::RecordState.mark_supplied_failed(ids, error_class: SuppliedLabelFailed.name) if ids.any?
72
+ end
73
+
74
+ def definition
75
+ model.truffler_definition
76
+ end
77
+
78
+ def record_type
79
+ model.polymorphic_name
80
+ end
81
+
82
+ def tenant_key_of(states)
83
+ tenants = states.map(&:tenant_key).uniq
84
+ raise TenantMismatch, "labeling holds records from exactly one tenant" if tenants.size > 1
85
+
86
+ tenants.first
87
+ end
88
+
89
+ def load_records(states)
90
+ records = model.where(model.primary_key => states.map(&:record_id)).to_a
91
+ found = records.map { |record| record.id.to_s }
92
+ gone = states.reject { |state| found.include?(state.record_id.to_s) }
93
+ Records::RecordState.where(id: gone.map(&:id)).delete_all if gone.any?
94
+ records
95
+ end
96
+
97
+ def stale_keys(stored, fingerprints, tenant_key, askable)
98
+ askable.reject do |label|
99
+ label.storage_keys(tenant_key).all? { |key| stored[key] == fingerprints[label.key] }
100
+ end.map(&:key)
101
+ end
102
+
103
+ # Every label except those of lenses at their spend cap (R43).
104
+ def askable_labels
105
+ lens_labels = @labels.values.grep(Lenses::LensLabel)
106
+ @lenses = nil
107
+ return @labels.values if lens_labels.empty?
108
+
109
+ @lenses = Lenses::Lens.where(id: lens_labels.map(&:lens_id).uniq).index_by(&:id)
110
+ @labels.values.reject { |label| label.is_a?(Lenses::LensLabel) && @lenses[label.lens_id]&.spend_cap_reached? }
111
+ end
112
+
113
+ # Splits a request's cost over its questions and adds each lens's share
114
+ # to that lens's spend.
115
+ def charge_lenses(request, cost)
116
+ return if @lenses.blank? || cost.zero?
117
+
118
+ keys = request.entries.values.flat_map { |_, keys| keys }
119
+ keys.filter_map { |key| @labels[key] }.grep(Lenses::LensLabel).group_by(&:lens_id).each do |lens_id, asked|
120
+ @lenses[lens_id]&.record_spend!(cost * asked.size / keys.size)
121
+ end
122
+ end
123
+
124
+ def stored_fingerprints(records)
125
+ rows = Records::Label.where(record_type: record_type, record_id: records.map(&:id)).pluck(:record_id, :label_key, :fingerprint)
126
+ rows.each_with_object({}) { |(id, key, print), map| (map[id.to_s] ||= {})[key] = print }
127
+ end
128
+
129
+ def store(request, answers, fingerprints, tenant_key, version, states_by_id)
130
+ now = Time.current
131
+ rows = request.entries.flat_map do |tag, (record, keys)|
132
+ keys.flat_map do |key|
133
+ label = @labels.fetch(key)
134
+ label_rows(label, Questions.tagged_id(tag, label.question_key), answers, tenant_key).map do |label_key, value|
135
+ { record_type: record_type, record_id: record.id, tenant_key: tenant_key, label_key: label_key,
136
+ value: value, fingerprint: fingerprints[key], labeled_at: now }
137
+ end
138
+ end
139
+ end
140
+
141
+ Records::Label.transaction do
142
+ request.entries.each_value do |record, keys|
143
+ keys.each { |key| Records::Label.where(record_type: record_type, record_id: record.id).for_label(key).delete_all }
144
+ end
145
+ Records::Label.insert_all!(rows) if rows.any?
146
+ Embeddings::LabelVector.new(model).write(request.entries.values.map { |record, _| record.id }, tenant_key: tenant_key)
147
+ ids = request.entries.values.map { |record, _| states_by_id[record.id.to_s].id }
148
+ Records::RecordState.mark_labeled(ids, version: version)
149
+ end
150
+ end
151
+
152
+ def label_rows(label, id, answers, tenant_key)
153
+ case label.type
154
+ when :noul then [ [ label.key, answers.noul(id) ] ]
155
+ when :score then [ [ label.key, answers.score(id) ] ]
156
+ when :choice then label.options(tenant_key).keys.map { |option| [ "#{label.key}:#{option}", answers.probability(id, option) ] }
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end