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,49 @@
1
+ module Truffler
2
+ module Clients
3
+ # Every adapter answers `ask(state:, questions:, model:, priority:)` with
4
+ # Truffler::Answers. Subclasses implement `perform(state:, questions:,
5
+ # model:)`, returning the parsed TypeSafe body: {"answers", "model",
6
+ # "usage" => {"input_tokens"}}. Only "answers" is required; missing token
7
+ # counts are estimated from the request size and flagged.
8
+ class Base
9
+ def ask(state:, questions:, model: nil, priority: nil)
10
+ model ||= Truffler.config.model
11
+ started = Instrumentation.monotonic_ms
12
+ payload = { priority: priority, question_count: questions.size, model: model }
13
+
14
+ response = normalize(perform(state: state, questions: questions, model: model))
15
+ usage = usage_for(response, state, questions)
16
+ answers = Answers.new(response["answers"], requested: questions.keys, model: response["model"] || model, usage: usage)
17
+ payload.merge!(model: answers.model, input_tokens: usage.input_tokens, tokens_estimated: usage.estimated, cost: usage.cost)
18
+ answers
19
+ rescue Truffler::Error => error
20
+ payload[:error_class] = error.class.name
21
+ raise
22
+ rescue StandardError => error
23
+ wrapped = ClientError.from(error)
24
+ payload.merge!(error_class: wrapped.error_class, status: wrapped.status)
25
+ raise wrapped, cause: nil
26
+ ensure
27
+ Instrumentation.instrument(:jev_call, payload.merge(latency_ms: Instrumentation.monotonic_ms - started))
28
+ end
29
+
30
+ def perform(state:, questions:, model:)
31
+ raise NotImplementedError, "#{self.class.name}#perform"
32
+ end
33
+
34
+ private
35
+
36
+ def normalize(response)
37
+ response = response.to_h.deep_stringify_keys
38
+ response.key?("answers") ? response : { "answers" => response }
39
+ end
40
+
41
+ def usage_for(response, state, questions)
42
+ tokens = response.dig("usage", "input_tokens")
43
+ return Usage.new(input_tokens: tokens.to_i, estimated: false) if tokens
44
+
45
+ Usage.new(input_tokens: Tokens.estimate({ state: state, questions: questions }), estimated: true)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,26 @@
1
+ module Truffler
2
+ module Clients
3
+ # Wraps a host client that responds to `evaluate(state:, schema:)`, such as
4
+ # Cora's TypeSafeClient. The schema is the question hash in wire shape. The
5
+ # pinned model is passed only when the host's method accepts `model:`.
6
+ class Callable < Base
7
+ def initialize(host)
8
+ @host = host
9
+ end
10
+
11
+ def perform(state:, questions:, model:)
12
+ if accepts_model?
13
+ @host.evaluate(state: state, schema: questions, model: model)
14
+ else
15
+ @host.evaluate(state: state, schema: questions)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def accepts_model?
22
+ @host.method(:evaluate).parameters.any? { |kind, name| name == :model || kind == :keyrest }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,44 @@
1
+ require "fileutils"
2
+
3
+ module Truffler
4
+ module Clients
5
+ # Records or replays any adapter's responses, keyed by the SHA-256 of the
6
+ # canonical request JSON ({model, state, questions}). A recording stores
7
+ # only the answers, the answering model, and usage, never the request.
8
+ #
9
+ # Modes: :replay (a miss raises CassetteMiss), :record (always calls the
10
+ # inner client), :auto (replays hits, records misses).
11
+ class Cassette < Base
12
+ MODES = %i[replay record auto].freeze
13
+
14
+ def initialize(inner, dir:, mode: :replay)
15
+ raise ArgumentError, "cassette mode must be one of #{MODES.join(', ')}" unless MODES.include?(mode.to_sym)
16
+
17
+ @inner = inner
18
+ @dir = dir.to_s
19
+ @mode = mode.to_sym
20
+ end
21
+
22
+ def perform(state:, questions:, model:)
23
+ hash = Canonical.digest(model: model, state: state, questions: questions)
24
+ path = File.join(@dir, "#{hash}.json")
25
+ return JSON.parse(File.read(path)) if @mode != :record && File.exist?(path)
26
+ raise CassetteMiss, "no recording #{hash} in #{@dir}" if @mode == :replay || @inner.nil?
27
+
28
+ record(path, hash, @inner.perform(state: state, questions: questions, model: model))
29
+ end
30
+
31
+ private
32
+
33
+ def record(path, hash, response)
34
+ response = normalize(response)
35
+ stored = response.slice("answers", "model", "usage").merge("request_hash" => hash)
36
+ FileUtils.mkdir_p(@dir)
37
+ temp = "#{path}.#{Process.pid}.tmp"
38
+ File.write(temp, "#{Canonical.json(stored)}\n")
39
+ File.rename(temp, path)
40
+ stored
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,85 @@
1
+ module Truffler
2
+ module Clients
3
+ # Deterministic scripted answers for tests and benchmarks. Script by exact
4
+ # question id or by label suffix (`:spam` matches `r001__spam`):
5
+ #
6
+ # fake.answer(:spam, 0.9) # noul probability
7
+ # fake.answer(:tone, "angry") # choice option
8
+ # fake.answer(:tone, { "calm" => 0.2, "angry" => 0.8 })
9
+ # fake.answer(:urgency, 2) # score level
10
+ # fake.answer(:spam) { |tag, state| state.dig("records", tag, "body").include?("$$$") ? 0.9 : 0.1 }
11
+ #
12
+ # Unscripted questions answer no, the first option, or the lowest level.
13
+ class Fake < Base
14
+ attr_reader :calls
15
+
16
+ def initialize(model: nil, &default)
17
+ @model = model
18
+ @default = default
19
+ @scripts = {}
20
+ @omitted = []
21
+ @error = nil
22
+ @calls = []
23
+ end
24
+
25
+ def answer(key, value = nil, &block)
26
+ @scripts[key.to_s] = block || value
27
+ self
28
+ end
29
+
30
+ def answer_without(*ids)
31
+ @omitted = ids.map(&:to_s)
32
+ self
33
+ end
34
+
35
+ def fail_with(error)
36
+ @error = error
37
+ self
38
+ end
39
+
40
+ def perform(state:, questions:, model:)
41
+ @calls << { state: state, questions: questions, model: model }
42
+ raise @error if @error
43
+
44
+ answers = questions.except(*@omitted).to_h { |id, question| [ id, answer_for(id, question, state) ] }
45
+ { "answers" => answers, "model" => @model || model }
46
+ end
47
+
48
+ private
49
+
50
+ def answer_for(id, question, state)
51
+ tag, label = Questions.split_id(id)
52
+ script = @scripts.fetch(id) { @scripts[label] if label }
53
+ script = @default if script.nil?
54
+ value = script.respond_to?(:call) ? script.call(tag, state.deep_stringify_keys, id) : script
55
+ shape(question, value)
56
+ end
57
+
58
+ def shape(question, value)
59
+ case question["type"]
60
+ when "noul" then { "type" => "noul", "noul" => value.to_f }
61
+ when "choice" then choice(question["criteria"].keys, value)
62
+ when "score" then score(question["criteria"].size, value.to_f)
63
+ end
64
+ end
65
+
66
+ def choice(options, value)
67
+ probabilities = options.to_h { |option| [ option, 0.0 ] }
68
+ case value
69
+ when Hash then probabilities.merge!(value.transform_keys(&:to_s).transform_values(&:to_f))
70
+ when nil then probabilities[options.first] = 1.0
71
+ else probabilities[value.to_s] = 1.0
72
+ end
73
+ pick, confidence = probabilities.max_by { |_, probability| probability }
74
+ { "type" => "choice", "choice" => pick, "probabilities" => probabilities, "confidence" => confidence }
75
+ end
76
+
77
+ def score(levels, level)
78
+ legend = Array.new(levels) { |index| [ index.to_s, index.to_s ] }.to_h
79
+ probabilities = legend.keys.to_h { |key| [ key, key.to_i == level.round ? 1.0 : 0.0 ] }
80
+ { "type" => "score", "score" => level, "legend" => legend, "probabilities" => probabilities,
81
+ "confidence" => 1.0 }
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,32 @@
1
+ require "ruby_llm"
2
+ require "ruby_llm-typesafe"
3
+
4
+ module Truffler
5
+ module Clients
6
+ # The default adapter: TypeSafe through RubyLLM 2 and ruby_llm-typesafe.
7
+ # Neither gem is a truffler dependency; this file loads only when the
8
+ # adapter is selected.
9
+ class RubyLLMTypeSafe < Base
10
+ def perform(state:, questions:, model:)
11
+ message = chat(model: model, provider: :typesafe).with_schema(schema_for(questions)).ask(JSON.generate(state))
12
+ { "answers" => message.parsed, "model" => message.model, "usage" => { "input_tokens" => message.tokens&.input } }
13
+ end
14
+
15
+ private
16
+
17
+ def chat(**options)
18
+ RubyLLM.chat(**options)
19
+ end
20
+
21
+ def schema_for(questions)
22
+ RubyLLM::Providers::TypeSafe::Schema.new do |schema|
23
+ questions.each do |id, question|
24
+ options = { instructions: question["instructions"] }
25
+ options[:criteria] = question["criteria"] if question.key?("criteria")
26
+ schema.public_send(question["type"], id, **options)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,89 @@
1
+ module Truffler
2
+ class Configuration
3
+ DEFAULT_REQUESTS_PER_MINUTE = 1_200
4
+ DEFAULT_PRIORITY_CEILINGS = { live: 1.0, encode: 0.9, rerank: 0.75, backfill: 0.5 }.freeze
5
+ DEFAULT_USER_CAPS = { encode: 30, rerank: 10 }.freeze
6
+
7
+ attr_accessor :model, :cost_per_million_tokens, :requests_per_minute, :headroom, :priority_ceilings,
8
+ :user_caps, :tenant_live_cap, :max_wait, :batch_size, :grouping_window, :max_attempts,
9
+ :max_field_chars, :request_token_budget, :max_questions_per_request, :queue_name,
10
+ :embedder, :encryptor, :backfill_spend_cap, :resume_pending_after
11
+ attr_writer :client, :cache_store, :logger
12
+ attr_accessor :miss_retention, :miss_min_distinct_users
13
+ attr_writer :secret_key_base
14
+ attr_accessor :vector_store, :embedding_cost_per_million_tokens
15
+ attr_accessor :encoding_prefetch
16
+ attr_reader :lenses
17
+ attr_accessor :encoding_deadline, :rerank_depth, :rerank_chunk_size, :rerank_max_field_chars, :smart_thresholds,
18
+ :smart_run_ttl, :smart_candidate_pool, :broadcaster
19
+
20
+ def initialize(env: ENV)
21
+ @model = "jev-latest"
22
+ @cost_per_million_tokens = 0.042
23
+ @requests_per_minute = Integer(env["TYPESAFE_REQUESTS_PER_MINUTE"].presence || DEFAULT_REQUESTS_PER_MINUTE)
24
+ @headroom = 0.25
25
+ @priority_ceilings = DEFAULT_PRIORITY_CEILINGS.dup
26
+ @user_caps = DEFAULT_USER_CAPS.dup
27
+ @tenant_live_cap = 120
28
+ @max_wait = 5.0
29
+ @batch_size = 10
30
+ @grouping_window = 0
31
+ @max_attempts = 5
32
+ @max_field_chars = 4_000
33
+ @request_token_budget = 48_000
34
+ @max_questions_per_request = 200
35
+ @queue_name = :default
36
+ @miss_retention = 30.days
37
+ @miss_min_distinct_users = 5
38
+ @backfill_spend_cap = nil
39
+ @resume_pending_after = 5.minutes
40
+ @vector_store = :auto
41
+ @embedding_cost_per_million_tokens = 0.02
42
+ @lenses = Lenses::Settings.new
43
+ @encoding_prefetch = QueryEncoding::Prefetch.new
44
+ @encoding_deadline = 1.0
45
+ @rerank_depth = 30
46
+ @rerank_chunk_size = 10
47
+ @rerank_max_field_chars = 1_200
48
+ @smart_thresholds = { strong: 0.70, possible: 0.35 }
49
+ @smart_run_ttl = 15.minutes
50
+ @smart_candidate_pool = 200
51
+ @broadcaster = nil
52
+ end
53
+
54
+ def client
55
+ @client ||= Clients::RubyLLMTypeSafe.new
56
+ end
57
+
58
+ def cache_store
59
+ @cache_store || rails_cache || (@fallback_cache ||= ActiveSupport::Cache::MemoryStore.new)
60
+ end
61
+
62
+ def logger
63
+ @logger || rails_logger || (@fallback_logger ||= ActiveSupport::Logger.new(nil))
64
+ end
65
+
66
+ def cost_for(tokens)
67
+ tokens.to_i * cost_per_million_tokens / 1_000_000.0
68
+ end
69
+
70
+ def secret_key_base
71
+ @secret_key_base.presence || rails_secret_key_base.presence ||
72
+ raise(Error, "Truffler needs config.secret_key_base (or a Rails secret_key_base) to digest query misses")
73
+ end
74
+
75
+ private
76
+
77
+ def rails_cache
78
+ Rails.cache if defined?(Rails) && Rails.respond_to?(:cache)
79
+ end
80
+
81
+ def rails_logger
82
+ Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
83
+ end
84
+
85
+ def rails_secret_key_base
86
+ Rails.application&.secret_key_base if defined?(Rails) && Rails.respond_to?(:application)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,176 @@
1
+ module Truffler
2
+ # Everything a model declares in its `truffler do ... end` block.
3
+ class Definition
4
+ EXPLICIT_ACTIONS = %i[enter key row].freeze
5
+ DEFAULT_EMBEDDINGS = { model: "text-embedding-3-small", dimensions: 256 }.freeze
6
+
7
+ attr_reader :model, :fields, :labels, :exact_sources, :providers, :surfaces
8
+ attr_accessor :tenant_column, :keyword, :embeddings, :order, :arrived_at_column
9
+
10
+ def initialize(model)
11
+ @model = model
12
+ @fields = []
13
+ @labels = {}
14
+ @exact_sources = {}
15
+ @providers = {}
16
+ @surfaces = {}
17
+ @arrived_at_column = "created_at"
18
+ end
19
+
20
+ def add_label(label)
21
+ raise DefinitionError, "label #{label.key} is declared twice" if labels.key?(label.key)
22
+
23
+ labels[label.key] = label
24
+ end
25
+
26
+ def label(key)
27
+ labels.fetch(key.to_s)
28
+ end
29
+
30
+ def label_keys
31
+ labels.keys
32
+ end
33
+
34
+ def scoped?
35
+ tenant_column.present?
36
+ end
37
+
38
+ def supplied_labels
39
+ labels.values.select(&:supplied?)
40
+ end
41
+
42
+ def per_tenant_vocabulary?
43
+ labels.each_value.any?(&:per_tenant?)
44
+ end
45
+
46
+ def vocabulary
47
+ Vocabulary.new(self)
48
+ end
49
+
50
+ def tenant_key_for(record)
51
+ record.public_send(tenant_column)&.to_s if scoped?
52
+ end
53
+
54
+ def field_values(record)
55
+ fields.index_with { |field| record.public_send(field) }
56
+ end
57
+
58
+ # Field values as Jev request state: strings cut to max_chars so one long
59
+ # record cannot crowd out a batch, everything else as JSON.
60
+ def request_fields(record, max_chars:)
61
+ field_values(record).transform_values { |value| value.is_a?(String) ? value[0, max_chars] : value.as_json }
62
+ end
63
+
64
+ # Newest first: by arrival when the table has that column, then by primary key.
65
+ def arrival_order
66
+ order = model.column_names.include?(arrived_at_column) ? { arrived_at_column => :desc } : {}
67
+ order.merge(model.primary_key => :desc)
68
+ end
69
+
70
+ def encrypted_fields
71
+ Array(model.try(:encrypted_attributes)).map(&:to_s) & fields
72
+ end
73
+
74
+ def validate!
75
+ raise DefinitionError, "#{model.name}: declare the fields Jev reads with `reads`" if fields.empty?
76
+
77
+ check_columns([ tenant_column, *fields, *Array(keyword).grep(String), *supplied_labels.flat_map(&:watch) ].compact.uniq)
78
+ check_embeddings if embeddings
79
+ end
80
+
81
+ DEFAULT_RANKING = { label: 1.0, text: 1.0, keyword: 0.5, exact: 1.0, min_similarity: 0.0 }.freeze
82
+ DEFAULT_WEAK_BELOW = 3
83
+
84
+ attr_writer :ranking, :weak_below
85
+
86
+ # KTD20 blend weights for keystroke scoring, tuned by the benchmark (R36).
87
+ def ranking
88
+ DEFAULT_RANKING.merge(@ranking || {})
89
+ end
90
+
91
+ # Fewer keystroke results than this count as weak (R19, R21).
92
+ def weak_below
93
+ @weak_below || DEFAULT_WEAK_BELOW
94
+ end
95
+
96
+ private
97
+
98
+ def check_columns(names)
99
+ columns = model.attribute_names
100
+ missing = names.reject { |name| columns.include?(name) || model.method_defined?(name) }
101
+ raise DefinitionError, "#{model.name}: unknown attributes #{missing.join(', ')}" if missing.any?
102
+ rescue ActiveRecord::ActiveRecordError
103
+ nil
104
+ end
105
+
106
+ def check_embeddings
107
+ return if embeddings.key?(:column) || embeddings[:allow_encrypted] || encrypted_fields.empty?
108
+
109
+ raise DefinitionError, "#{model.name}: embeddings would send encrypted fields #{encrypted_fields.join(', ')} " \
110
+ "to the embedding provider; pass allow_encrypted: true to opt in"
111
+ end
112
+
113
+ # The block API inside `truffler do ... end`.
114
+ class DSL
115
+ def initialize(definition)
116
+ @definition = definition
117
+ end
118
+
119
+ def tenant(column)
120
+ @definition.tenant_column = column.to_s
121
+ end
122
+
123
+ def reads(*fields)
124
+ @definition.fields.concat(fields.map(&:to_s))
125
+ end
126
+
127
+ def label(key, type, **options)
128
+ @definition.add_label(LabelDefinition.new(key, type, **options))
129
+ end
130
+
131
+ def keyword(*columns_or_callable)
132
+ callable = columns_or_callable.first if columns_or_callable.one? && columns_or_callable.first.respond_to?(:call)
133
+ @definition.keyword = callable || columns_or_callable.map(&:to_s)
134
+ end
135
+
136
+ def exact(name, callable)
137
+ @definition.exact_sources[name.to_s] = callable
138
+ end
139
+
140
+ def embeddings(column: nil, **options)
141
+ @definition.embeddings = column ? { column: column.to_s } : DEFAULT_EMBEDDINGS.merge(options)
142
+ end
143
+
144
+ def provider(name, label:, search:)
145
+ @definition.providers[name.to_s] = { label: label, search: search }
146
+ end
147
+
148
+ def order(column, direction = :desc)
149
+ @definition.order = [ column.to_s, direction.to_sym ]
150
+ end
151
+
152
+ def surface(name, explicit_action: :enter)
153
+ unless EXPLICIT_ACTIONS.include?(explicit_action)
154
+ raise DefinitionError, "surface #{name}: explicit_action must be one of #{EXPLICIT_ACTIONS.join(', ')}"
155
+ end
156
+
157
+ @definition.surfaces[name.to_s] = { explicit_action: explicit_action }
158
+ end
159
+
160
+ def arrived_at(column)
161
+ @definition.arrived_at_column = column.to_s
162
+ end
163
+
164
+ def ranking(**weights)
165
+ unknown = weights.keys - DEFAULT_RANKING.keys
166
+ raise DefinitionError, "ranking: unknown weights #{unknown.join(', ')}" if unknown.any?
167
+
168
+ @definition.ranking = weights.transform_values { |weight| Float(weight) }
169
+ end
170
+
171
+ def weak_below(count)
172
+ @definition.weak_below = Integer(count)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,48 @@
1
+ module Truffler
2
+ module Embeddings
3
+ # Finds records whose embedding is missing or was made under another
4
+ # fingerprint (model, width, or fields changed) and enqueues EmbedJob for
5
+ # them, newest first, `batch_size` jobs at a time behind an id cursor.
6
+ # ResumeJob runs a bounded pass on every sweep; hosts call `enqueue` with
7
+ # no limit after enabling embeddings or changing the model, width, or
8
+ # fields.
9
+ class Backfill
10
+ BATCH_SIZE = 1_000
11
+
12
+ attr_reader :model
13
+
14
+ def initialize(model)
15
+ @model = model
16
+ end
17
+
18
+ def stale_ids(limit: nil, before: nil)
19
+ definition = model.truffler_definition
20
+ return [] unless Embeddings.managed?(definition)
21
+
22
+ current = Records::RecordState.for_model(model).where(embedding_fingerprint: Embeddings.fingerprint(definition))
23
+ .where.not(embedded_at: nil).select(:record_id)
24
+ scope = model.where.not(model.primary_key => current)
25
+ scope = scope.where(model.primary_key => ...before) if before
26
+ scope.order(model.primary_key => :desc).limit(limit).pluck(model.primary_key)
27
+ end
28
+
29
+ def enqueue(limit: nil, batch_size: BATCH_SIZE)
30
+ count = 0
31
+ cursor = nil
32
+ loop do
33
+ take = limit ? [ batch_size, limit - count ].min : batch_size
34
+ break unless take.positive?
35
+
36
+ ids = stale_ids(limit: take, before: cursor)
37
+ break if ids.empty?
38
+
39
+ ActiveJob.perform_all_later(ids.map { |id| Jobs::EmbedJob.new(model.polymorphic_name, id) })
40
+ count += ids.size
41
+ cursor = ids.last
42
+ break if ids.size < take
43
+ end
44
+ count
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,65 @@
1
+ module Truffler
2
+ module Embeddings
3
+ # Searches a vector column the host already maintains (R11). The gem never
4
+ # writes it and never calls the embedder for it. Similarity runs in SQL
5
+ # when the column is pgvector or sqlite-vec is loaded, and otherwise as
6
+ # exact cosine in Ruby over the tenant's rows (arrays, JSON text, or
7
+ # float32 blobs).
8
+ class ColumnStore < VectorStore
9
+ attr_reader :column
10
+
11
+ def initialize(column)
12
+ @column = column.to_s
13
+ end
14
+
15
+ def write(model, *)
16
+ raise Error, "#{model.name} searches its own #{column} column; truffler does not write it"
17
+ end
18
+
19
+ def nearest(model, tenant_key:, vector:, k: DEFAULT_K)
20
+ scope = records(model, tenant_key)
21
+ if (dialect = dialect(model))
22
+ distance = NeighborStore.distance_sql(dialect, model.connection, column_sql(model), vector)
23
+ scope.order(Arel.sql(distance)).limit(k).pluck(model.primary_key, Arel.sql("1 - #{distance}"))
24
+ .map { |id, similarity| [ id, similarity.to_f ] }
25
+ else
26
+ scope.pluck(model.primary_key, column).filter_map do |id, value|
27
+ stored = Records::Embedding.unpack(value)
28
+ [ id, self.class.cosine(vector, stored) ] if stored.size == vector.size
29
+ end.max_by(k) { |_, similarity| similarity }
30
+ end
31
+ end
32
+
33
+ def similarity_sql(model, tenant_key:, vector:, k: DEFAULT_K)
34
+ dialect = dialect(model)
35
+ return super unless dialect
36
+
37
+ check_tenant!(model, tenant_key)
38
+ Arel.sql("COALESCE(1 - #{NeighborStore.distance_sql(dialect, model.connection, column_sql(model), vector)}, 0.0)")
39
+ end
40
+
41
+ def inline_sql?(model)
42
+ !dialect(model).nil?
43
+ end
44
+
45
+ private
46
+
47
+ def records(model, tenant_key)
48
+ check_tenant!(model, tenant_key)
49
+ definition = model.truffler_definition
50
+ scope = model.where.not(column => nil)
51
+ definition.scoped? ? scope.where(definition.tenant_column => tenant_key) : scope
52
+ end
53
+
54
+ def dialect(model)
55
+ return @dialect if defined?(@dialect)
56
+
57
+ @dialect = NeighborStore.dialect(model.connection, table: model.table_name, column: column)
58
+ end
59
+
60
+ def column_sql(model)
61
+ "#{model.connection.quote_table_name(model.table_name)}.#{model.connection.quote_column_name(column)}"
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,53 @@
1
+ module Truffler
2
+ module Embeddings
3
+ # The embedder seam. `embed(texts, model:, dimensions:)` returns one vector
4
+ # per text plus usage, and emits `truffler.embed_call` with tokens, cost,
5
+ # and latency but never the texts. Subclasses implement `perform`,
6
+ # returning {vectors:, model:, input_tokens:}; a missing token count is
7
+ # estimated and flagged.
8
+ class Embedder
9
+ Result = Data.define(:vectors, :model, :input_tokens, :tokens_estimated, :cost)
10
+
11
+ def embed(texts, model:, dimensions:)
12
+ texts = Array(texts)
13
+ started = Instrumentation.monotonic_ms
14
+ payload = { model: model, record_count: texts.size }
15
+
16
+ response = perform(texts, model: model, dimensions: dimensions)
17
+ result = result_for(response, texts, model)
18
+ payload.merge!(model: result.model, input_tokens: result.input_tokens, tokens_estimated: result.tokens_estimated,
19
+ cost: result.cost)
20
+ result
21
+ rescue Truffler::Error => error
22
+ payload[:error_class] = error.class.name
23
+ raise
24
+ rescue StandardError => error
25
+ wrapped = ClientError.from(error)
26
+ payload.merge!(error_class: wrapped.error_class, status: wrapped.status)
27
+ raise wrapped, cause: nil
28
+ ensure
29
+ Instrumentation.instrument(:embed_call, payload.merge(latency_ms: Instrumentation.monotonic_ms - started))
30
+ end
31
+
32
+ def perform(texts, model:, dimensions:)
33
+ raise NotImplementedError, "#{self.class.name}#perform"
34
+ end
35
+
36
+ private
37
+
38
+ def result_for(response, texts, model)
39
+ vectors = response.fetch(:vectors)
40
+ unless vectors.size == texts.size
41
+ raise IncompleteAnswers, "embedder returned #{vectors.size} vectors for #{texts.size} texts"
42
+ end
43
+
44
+ tokens = response[:input_tokens]
45
+ estimated = tokens.nil?
46
+ tokens = estimated ? texts.sum { |text| Tokens.estimate(text) } : tokens.to_i
47
+ cost = tokens * Truffler.config.embedding_cost_per_million_tokens / 1_000_000.0
48
+ Result.new(vectors: vectors.map { |vector| vector.map(&:to_f) }, model: response[:model] || model,
49
+ input_tokens: tokens, tokens_estimated: estimated, cost: cost)
50
+ end
51
+ end
52
+ end
53
+ end