insika 0.9.0 → 1.0.1

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 (257) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +237 -2
  3. data/README.md +5 -3
  4. data/bin/insika +3 -0
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +90 -7
  7. data/docs/ARCHITECTURE.md +12 -7
  8. data/docs/ARTIFACTS.md +33 -3
  9. data/docs/BENCHMARK.md +50 -7
  10. data/docs/CONTEXT.md +63 -0
  11. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  12. data/docs/DEPLOY.md +57 -11
  13. data/docs/EMBEDDING.md +10 -0
  14. data/docs/EVALS.md +33 -4
  15. data/docs/FACTS.md +8 -6
  16. data/docs/HARVEST.md +8 -3
  17. data/docs/KNOWLEDGE.md +7 -0
  18. data/docs/MEDIA.md +6 -2
  19. data/docs/OBSERVABILITY.md +143 -22
  20. data/docs/PERFORMANCE.md +110 -0
  21. data/docs/PLUGINS.md +4 -4
  22. data/docs/POLICY.md +22 -13
  23. data/docs/RELEASING.md +5 -0
  24. data/docs/ROUTER.md +7 -0
  25. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  26. data/docs/SECURITY.md +7 -0
  27. data/docs/SHARED-CONVERSATIONS.md +40 -0
  28. data/docs/SKILLS.md +5 -3
  29. data/docs/STUDIO.md +122 -0
  30. data/docs/TOOLS.md +111 -9
  31. data/docs/operate.md +3 -0
  32. data/lib/insika/agent_defaults.rb +140 -0
  33. data/lib/insika/agent_file_store.rb +12 -0
  34. data/lib/insika/agent_profile.rb +78 -8
  35. data/lib/insika/artifact_signing.rb +11 -0
  36. data/lib/insika/chat_builder.rb +79 -25
  37. data/lib/insika/checkpoint.rb +6 -1
  38. data/lib/insika/checkpoint_store.rb +59 -7
  39. data/lib/insika/commands/agent_payload.rb +3 -3
  40. data/lib/insika/commands/delete_kit.rb +26 -0
  41. data/lib/insika/commands/gate_harvest.rb +3 -0
  42. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  43. data/lib/insika/commands/run_harvest.rb +17 -1
  44. data/lib/insika/commands/seed_session.rb +11 -1
  45. data/lib/insika/commands/send_message.rb +9 -3
  46. data/lib/insika/commands/update_settings.rb +23 -1
  47. data/lib/insika/commands/write_kit.rb +27 -0
  48. data/lib/insika/compaction.rb +33 -7
  49. data/lib/insika/config_store.rb +87 -7
  50. data/lib/insika/context/priority.rb +3 -0
  51. data/lib/insika/context/provider.rb +2 -2
  52. data/lib/insika/context/providers/knowledge.rb +25 -2
  53. data/lib/insika/context/providers/memory.rb +53 -4
  54. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  55. data/lib/insika/context/providers/prompt.rb +17 -4
  56. data/lib/insika/context/providers/session.rb +39 -3
  57. data/lib/insika/context/reranker.rb +75 -0
  58. data/lib/insika/distill.rb +22 -13
  59. data/lib/insika/doctor.rb +34 -4
  60. data/lib/insika/dsl/runtime.rb +36 -4
  61. data/lib/insika/dsl/server_boot.rb +4 -12
  62. data/lib/insika/dsl.rb +13 -5
  63. data/lib/insika/edge_limiter.rb +23 -15
  64. data/lib/insika/egress_guard.rb +11 -3
  65. data/lib/insika/env_schema.rb +33 -73
  66. data/lib/insika/evals/assertions.rb +171 -16
  67. data/lib/insika/evals/golden.rb +141 -4
  68. data/lib/insika/evals/judge.rb +19 -10
  69. data/lib/insika/evals/moderator_compare.rb +47 -0
  70. data/lib/insika/evals/report.rb +61 -9
  71. data/lib/insika/evals/runner.rb +44 -4
  72. data/lib/insika/evals/simulator.rb +10 -1
  73. data/lib/insika/evals/transport.rb +200 -0
  74. data/lib/insika/event_stream.rb +63 -7
  75. data/lib/insika/evidence.rb +29 -8
  76. data/lib/insika/executor.rb +668 -141
  77. data/lib/insika/harvest.rb +26 -14
  78. data/lib/insika/harvest_engine.rb +1 -0
  79. data/lib/insika/harvest_store.rb +12 -0
  80. data/lib/insika/http_client.rb +87 -4
  81. data/lib/insika/kits.rb +54 -0
  82. data/lib/insika/knowledge.rb +34 -20
  83. data/lib/insika/llm_configurator.rb +46 -4
  84. data/lib/insika/llm_provider_store.rb +1 -3
  85. data/lib/insika/llm_trace_store.rb +54 -0
  86. data/lib/insika/mcp_client.rb +27 -63
  87. data/lib/insika/mcp_live_tool.rb +55 -9
  88. data/lib/insika/mcp_store.rb +32 -1
  89. data/lib/insika/mcp_tool_registry.rb +69 -26
  90. data/lib/insika/media.rb +38 -25
  91. data/lib/insika/memory_store.rb +4 -3
  92. data/lib/insika/memory_trim.rb +60 -0
  93. data/lib/insika/middleware.rb +12 -3
  94. data/lib/insika/model_metrics_store.rb +285 -0
  95. data/lib/insika/model_registry.rb +110 -0
  96. data/lib/insika/model_resolver.rb +1 -1
  97. data/lib/insika/model_selection.rb +14 -21
  98. data/lib/insika/model_visible.rb +17 -6
  99. data/lib/insika/onboarding.rb +1 -0
  100. data/lib/insika/outcome_store.rb +1 -1
  101. data/lib/insika/overlay_tool_registry.rb +25 -2
  102. data/lib/insika/pack_importer.rb +54 -9
  103. data/lib/insika/packaging.rb +14 -5
  104. data/lib/insika/pending_action_store.rb +45 -18
  105. data/lib/insika/plugin/loader.rb +4 -2
  106. data/lib/insika/profile_source.rb +4 -0
  107. data/lib/insika/provider_error_classifier.rb +18 -3
  108. data/lib/insika/queue_policy.rb +7 -2
  109. data/lib/insika/refinement/proposer.rb +42 -17
  110. data/lib/insika/reliability.rb +12 -3
  111. data/lib/insika/router/app.rb +14 -5
  112. data/lib/insika/router/session_key.rb +35 -5
  113. data/lib/insika/safety/config.rb +20 -3
  114. data/lib/insika/safety/corpus.rb +10 -3
  115. data/lib/insika/safety/factory.rb +39 -1
  116. data/lib/insika/safety/input_guardrail.rb +21 -1
  117. data/lib/insika/safety/judge_moderator.rb +70 -0
  118. data/lib/insika/safety/output_validator.rb +29 -5
  119. data/lib/insika/safety/prompt_echo.rb +89 -0
  120. data/lib/insika/schema_guard.rb +53 -76
  121. data/lib/insika/server/app.rb +17 -8
  122. data/lib/insika/server/responses.rb +19 -1
  123. data/lib/insika/session_actor.rb +58 -28
  124. data/lib/insika/session_store.rb +159 -10
  125. data/lib/insika/settings_store.rb +47 -1
  126. data/lib/insika/shared_conversations.rb +305 -0
  127. data/lib/insika/skill_catalog.rb +28 -2
  128. data/lib/insika/skill_store.rb +6 -0
  129. data/lib/insika/soak/runner.rb +1 -1
  130. data/lib/insika/steer_injector.rb +7 -48
  131. data/lib/insika/store.rb +29 -1
  132. data/lib/insika/stores/json_value.rb +37 -0
  133. data/lib/insika/stores/memory.rb +22 -35
  134. data/lib/insika/stores/postgres.rb +295 -0
  135. data/lib/insika/stores/sqlite.rb +257 -43
  136. data/lib/insika/stores/turn_counter.rb +25 -0
  137. data/lib/insika/studio/app.rb +1453 -161
  138. data/lib/insika/studio/assets/dist/application.css +1 -1
  139. data/lib/insika/studio/assets/dist/application.js +25 -25
  140. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  141. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  142. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  143. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  144. data/lib/insika/studio/forms.rb +171 -9
  145. data/lib/insika/studio/nav_icons.rb +3 -0
  146. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  147. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  148. data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
  149. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  150. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  151. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  152. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  153. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  154. data/lib/insika/studio/views/_agents_master.erb +8 -12
  155. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  156. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  157. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  158. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  159. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  160. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  161. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  162. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  163. data/lib/insika/studio/views/_chats_master.erb +66 -0
  164. data/lib/insika/studio/views/_customers_master.erb +37 -0
  165. data/lib/insika/studio/views/_empty.erb +15 -0
  166. data/lib/insika/studio/views/_facts_master.erb +42 -0
  167. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  168. data/lib/insika/studio/views/_identity.erb +16 -0
  169. data/lib/insika/studio/views/_kpi.erb +17 -0
  170. data/lib/insika/studio/views/_message.erb +40 -2
  171. data/lib/insika/studio/views/_model_chart.erb +82 -0
  172. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  173. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
  174. data/lib/insika/studio/views/_tool_users.erb +17 -0
  175. data/lib/insika/studio/views/_why.erb +53 -0
  176. data/lib/insika/studio/views/agent_detail.erb +17 -10
  177. data/lib/insika/studio/views/agents.erb +28 -16
  178. data/lib/insika/studio/views/approvals.erb +16 -11
  179. data/lib/insika/studio/views/artifact.erb +20 -7
  180. data/lib/insika/studio/views/artifacts.erb +19 -14
  181. data/lib/insika/studio/views/chats.erb +22 -29
  182. data/lib/insika/studio/views/customer.erb +88 -43
  183. data/lib/insika/studio/views/customers.erb +16 -26
  184. data/lib/insika/studio/views/evals.erb +49 -31
  185. data/lib/insika/studio/views/fact_detail.erb +114 -0
  186. data/lib/insika/studio/views/facts.erb +15 -116
  187. data/lib/insika/studio/views/followups.erb +63 -52
  188. data/lib/insika/studio/views/funnel.erb +104 -43
  189. data/lib/insika/studio/views/harvest.erb +85 -59
  190. data/lib/insika/studio/views/home.erb +138 -23
  191. data/lib/insika/studio/views/inbox.erb +36 -0
  192. data/lib/insika/studio/views/kits.erb +176 -0
  193. data/lib/insika/studio/views/knowledge.erb +92 -80
  194. data/lib/insika/studio/views/layout.erb +42 -11
  195. data/lib/insika/studio/views/live.erb +45 -0
  196. data/lib/insika/studio/views/mcp.erb +43 -13
  197. data/lib/insika/studio/views/models.erb +187 -0
  198. data/lib/insika/studio/views/parity.erb +38 -32
  199. data/lib/insika/studio/views/playground.erb +13 -9
  200. data/lib/insika/studio/views/refinement.erb +15 -224
  201. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  202. data/lib/insika/studio/views/session.erb +152 -117
  203. data/lib/insika/studio/views/settings.erb +86 -22
  204. data/lib/insika/studio/views/skills.erb +53 -19
  205. data/lib/insika/studio/views/system_files.erb +7 -7
  206. data/lib/insika/studio/views/task.erb +45 -15
  207. data/lib/insika/studio/views/tasks.erb +35 -18
  208. data/lib/insika/studio/views/tools.erb +62 -73
  209. data/lib/insika/task_actor.rb +4 -1
  210. data/lib/insika/task_store.rb +67 -1
  211. data/lib/insika/telemetry/pricing.rb +16 -14
  212. data/lib/insika/telemetry/recorder.rb +103 -9
  213. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  214. data/lib/insika/telemetry.rb +12 -2
  215. data/lib/insika/templates/daily-digest/README.md +13 -0
  216. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  217. data/lib/insika/testing/store_contract.rb +65 -0
  218. data/lib/insika/text_tool_call.rb +66 -0
  219. data/lib/insika/tick.rb +7 -1
  220. data/lib/insika/token_store.rb +5 -0
  221. data/lib/insika/tool_batch.rb +1 -1
  222. data/lib/insika/tool_catalog.rb +12 -2
  223. data/lib/insika/tool_definition.rb +10 -4
  224. data/lib/insika/tool_envelope.rb +165 -28
  225. data/lib/insika/tool_manifest.rb +1 -0
  226. data/lib/insika/tool_store.rb +3 -0
  227. data/lib/insika/tool_trace_store.rb +15 -8
  228. data/lib/insika/tool_usage_report.rb +2 -2
  229. data/lib/insika/tools/a2a_remote.rb +1 -1
  230. data/lib/insika/tools/agent_enum.rb +1 -1
  231. data/lib/insika/tools/confirm_pending.rb +112 -0
  232. data/lib/insika/tools/data_defined_tool.rb +23 -15
  233. data/lib/insika/tools/generate_image.rb +2 -2
  234. data/lib/insika/tools/load_knowledge.rb +7 -3
  235. data/lib/insika/tools/load_skill.rb +3 -3
  236. data/lib/insika/tools/present.rb +1 -8
  237. data/lib/insika/tools/remember.rb +7 -2
  238. data/lib/insika/tools/run_persona_eval.rb +3 -5
  239. data/lib/insika/tools/save_artifact.rb +16 -6
  240. data/lib/insika/tools/schedule_followup.rb +3 -3
  241. data/lib/insika/tools/stuck_signal.rb +3 -3
  242. data/lib/insika/tools/subagent.rb +5 -5
  243. data/lib/insika/tools/subagents.rb +3 -3
  244. data/lib/insika/tools/tool_search.rb +7 -5
  245. data/lib/insika/tools/tts.rb +2 -2
  246. data/lib/insika/tools/update_briefing.rb +4 -4
  247. data/lib/insika/turn_output.rb +2 -2
  248. data/lib/insika/turn_state.rb +18 -0
  249. data/lib/insika/turn_timing.rb +51 -14
  250. data/lib/insika/version.rb +1 -1
  251. data/lib/insika/wiring/graph.rb +81 -16
  252. data/lib/insika/workflow.rb +25 -64
  253. data/lib/insika.rb +19 -3
  254. metadata +69 -13
  255. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  256. data/lib/insika/mcp_http_client.rb +0 -67
  257. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -32,11 +32,12 @@ module Insika
32
32
 
33
33
  # A tool call whose status is anything but a success ("ok"/2xx/"success"). A
34
34
  # BLOCKED call is not an error: the tool never ran, a gate held it — that is
35
- # the `blocked_gates` grader's business, not `must_not: tool_error`'s.
35
+ # the `blocked_gates` grader's business, not `must_not: tool_error`'s. Nor is a
36
+ # HELD one: it waits for the customer, and `phantom_action` reads the reply.
36
37
  def errored_tools
37
38
  Array(tool_calls).reject do |t|
38
39
  status = t["status"] || t[:status]
39
- Assertions.ok_status?(status) || status.to_s == "blocked"
40
+ Assertions.ok_status?(status) || %w[blocked held].include?(status.to_s)
40
41
  end
41
42
  end
42
43
 
@@ -46,7 +47,16 @@ module Insika
46
47
  end
47
48
 
48
49
  # A single check within a case (e.g. "tool:shipping_quote", "must_not:pii_leak").
49
- Check = Struct.new(:name, :pass, :detail, keyword_init: true)
50
+ #
51
+ # `skipped` is the third outcome, per check rather than per case: the transport
52
+ # could not observe what this grader reads (a harness that reports no tool
53
+ # calls), so the check neither held nor broke. It carries `pass: true` so it
54
+ # cannot fail a case it never examined, and the report prints it as skipped —
55
+ # the same discipline `requires` already applies to a whole case: reported,
56
+ # never a silent pass.
57
+ Check = Struct.new(:name, :pass, :detail, :skipped, keyword_init: true) do
58
+ def skipped? = skipped == true
59
+ end
50
60
 
51
61
  # The verdict for one golden case. `judge` (a Judge::Verdict) is attached AFTER
52
62
  # the deterministic pass when the case has a rubric and a judge is configured;
@@ -69,6 +79,7 @@ module Insika
69
79
  def skipped? = !skipped.nil?
70
80
  def pass? = !skipped? && error.nil? && checks.all?(&:pass) && (judge.nil? || judge.pass)
71
81
  def failures = checks.reject(&:pass)
82
+ def skipped_checks = checks.select(&:skipped?)
72
83
  # Has a rubric to score but no verdict yet (judge disabled / not run). A skipped
73
84
  # case is not pending anything — nobody is going to judge a turn that never ran.
74
85
  def judge_pending? = !skipped? && !rubric.to_s.strip.empty? && judge.nil?
@@ -100,6 +111,11 @@ module Insika
100
111
  # That is also the honest scope — a question nobody received is not a question.
101
112
  POLICIES = {
102
113
  # "UMA PERGUNTA POR VEZ" — the rule Insika broke twice under a 28 KB prompt.
114
+ # `max:` is the store's, default 1. Measured across six harnesses on one
115
+ # greeting task: with max 1 every reply that says "tudo bem?" fails and every
116
+ # reply that does not passes, which grades a courtesy formula rather than the
117
+ # rule. A store whose customers are greeted sets 2 and keeps the check for
118
+ # what it is for — the form ("qual seu nome, CPF e número do pedido?").
103
119
  "ask_once" => "at most one question per reply",
104
120
  # Establish the objective before acting on a vague opener.
105
121
  "investigate_first" => "asks before calling a tool, on the first turn",
@@ -156,7 +172,16 @@ module Insika
156
172
  # all of them — "one question per reply" is a rule about every reply, and the
157
173
  # violation that motivated this was on the FIRST turn. Defaults to the single
158
174
  # result so existing callers keep working.
159
- def evaluate(golden, result, turns: nil)
175
+ # `store` is the post-turn snapshot ({ collection => [rows] }) when the case
176
+ # declares a `store_state:` and the runner had a reader; nil otherwise.
177
+ #
178
+ # `tools_reported` is false when the TRANSPORT cannot see tool calls at all —
179
+ # a benched harness that answers text and nothing else. Its tool-shaped graders
180
+ # are then skipped with the reason instead of passing (they would: an empty
181
+ # list satisfies `never_calls`) or failing (it would: nothing satisfies
182
+ # `tools_called`). Both readings would be a claim about a harness nobody
183
+ # measured.
184
+ def evaluate(golden, result, turns: nil, store: nil, store_error: nil, tools_reported: true)
160
185
  if result.error
161
186
  return CaseResult.new(id: golden.id, agent: golden.agent, error: result.error, rubric: nil, judge: nil,
162
187
  checks: [Check.new(name: "turn", pass: false, detail: "turn error: #{result.error}")])
@@ -165,12 +190,101 @@ module Insika
165
190
  # NOT `Array(turns)`: TurnResult is a Struct, so Array() would explode a single
166
191
  # one into its members and hand the policy checks three strings.
167
192
  conversation = turns.nil? || turns.empty? ? [result] : turns
168
- checks = tool_checks(golden, result) + call_checks(golden, result) + reply_checks(golden, result) +
169
- ui_checks(golden, result) + must_not_checks(golden, result) + policy_checks(golden, conversation)
193
+ checks = tool_checks(golden, result) + turn_tool_checks(golden, conversation) + call_checks(golden, result) +
194
+ reply_checks(golden, result) + ui_checks(golden, result) +
195
+ must_not_checks(golden, result, conversation) + policy_checks(golden, conversation)
196
+ checks = skip_tool_shaped(checks) unless tools_reported
197
+ checks += store_state_checks(golden, store, error: store_error) if golden.store_state?
170
198
  CaseResult.new(id: golden.id, agent: golden.agent, error: nil, checks: checks,
171
199
  rubric: golden.rubric, judge: nil)
172
200
  end
173
201
 
202
+ # Graders that read the turn's tool calls. Named by prefix in ONE place: a
203
+ # grader added without a line here would be silently mis-skipped, and the
204
+ # policies are in the list because `investigate_first`/`act_fast` are questions
205
+ # about whether a tool was called, not about the text.
206
+ TOOL_SHAPED = /\A(?:tool:|never_calls:|calls_one_of\z|first_tool:|max_tool_calls:|blocked_gates:|
207
+ must_not:(?:tool_error|phantom_action)\z|policy:(?:investigate_first|act_fast)\z)/x
208
+
209
+ def skip_tool_shaped(checks)
210
+ checks.map do |c|
211
+ next c unless c.name.match?(TOOL_SHAPED)
212
+
213
+ Check.new(name: c.name, pass: true, skipped: true,
214
+ detail: "skipped: this harness does not report tool calls")
215
+ end
216
+ end
217
+
218
+ # WHAT THE STORE LOOKS LIKE AFTER THE TURN. A confident answer with no order
219
+ # is a fail, and no reply grader can say so. `snapshot` is
220
+ # { collection => [rows] } as the reader returned it; nil means the reader
221
+ # gave nothing back, which fails rather than passes — an unread store must
222
+ # never read as a satisfied one.
223
+ def store_state_checks(golden, snapshot, error: nil)
224
+ if snapshot.nil?
225
+ return [Check.new(name: "store_state", pass: false,
226
+ detail: "no store snapshot was read#{" — #{error}" if error}")]
227
+ end
228
+
229
+ rows = Insika::Coercion.deep_stringify(snapshot)
230
+ spec = golden.store_state
231
+ count_checks(spec["count"], rows) + record_checks(spec["records"], rows) +
232
+ absent_checks(spec["absent"], rows)
233
+ end
234
+
235
+ def count_checks(spec, rows)
236
+ Array(spec).map do |collection, expected|
237
+ actual = Array(rows[collection.to_s]).size
238
+ Check.new(name: "store_state:count:#{collection}", pass: actual == expected,
239
+ detail: "expected #{expected}, saw #{actual}")
240
+ end
241
+ end
242
+
243
+ def record_checks(spec, rows)
244
+ Array(spec).flat_map do |collection, expected_rows|
245
+ present = Array(rows[collection.to_s])
246
+ expected_rows.each_with_index.map do |expected, i|
247
+ hit = present.any? { |row| record_match?(expected, row) }
248
+ Check.new(name: "store_state:#{collection}[#{i}]", pass: hit,
249
+ detail: hit ? "matched" : "no row of #{present.size} in #{collection} matches #{expected.inspect}")
250
+ end
251
+ end
252
+ end
253
+
254
+ def absent_checks(spec, rows)
255
+ Array(spec).flat_map do |collection, expected_rows|
256
+ present = Array(rows[collection.to_s])
257
+ expected_rows.each_with_index.map do |expected, i|
258
+ hits = present.count { |row| record_match?(expected, row) }
259
+ Check.new(name: "store_state:absent:#{collection}[#{i}]", pass: hits.zero?,
260
+ detail: hits.zero? ? "absent" : "#{hits} row(s) in #{collection} match #{expected.inspect}")
261
+ end
262
+ end
263
+ end
264
+
265
+ # A row matches when every field the case NAMES matches — a store row carries
266
+ # ids, timestamps and totals nobody wrote the case about, and demanding the
267
+ # whole row would make every task un-authorable.
268
+ def record_match?(expected, row)
269
+ row.is_a?(Hash) && expected.all? { |k, v| value_match?(v, row[k.to_s]) }
270
+ end
271
+
272
+ # Numbers compare as numbers (189.9 == 189.90, and a store that answers a
273
+ # string "189.90" is still the same money); a list means "each of these is in
274
+ # there", not "these are all of them" — `count:` is where exactness is stated.
275
+ def value_match?(expected, actual)
276
+ case expected
277
+ when Hash then record_match?(expected, actual)
278
+ when Array then actual.is_a?(Array) && expected.all? { |e| actual.any? { |a| value_match?(e, a) } }
279
+ when Numeric then numeric(actual) && (expected.to_f - actual.to_f).abs < 1e-6
280
+ else expected.to_s == actual.to_s
281
+ end
282
+ end
283
+
284
+ def numeric(value)
285
+ value.is_a?(Numeric) || (value.is_a?(String) && !Float(value, exception: false).nil?)
286
+ end
287
+
174
288
  # Each REQUIRED expected tool must appear in the turn's tool calls. Optional
175
289
  # ("name?") tools are informational — present or not, they never fail.
176
290
  def tool_checks(golden, result)
@@ -187,6 +301,20 @@ module Insika
187
301
  # The graders over the turn's CALLS. Each is its own Check, so the report names
188
302
  # the one that failed instead of "tool checks failed". All read the LAST turn,
189
303
  # like `tools_called`.
304
+ # The calls a specific turn had to make (`turns[i].tools_called`). The case-level
305
+ # grader reads the last turn; a two-turn case where turn one claimed an action
306
+ # and turn two repaired the store passes it, and this is what catches turn one.
307
+ def turn_tool_checks(golden, turns)
308
+ turns.each_with_index.flat_map do |turn, i|
309
+ names = turn.tool_names
310
+ golden.turn_tools_called(i).reject { |t| t[:optional] }.map do |t|
311
+ present = names.include?(t[:name])
312
+ Check.new(name: "tool:#{t[:name]}@turn#{i + 1}", pass: present,
313
+ detail: present ? "called on turn #{i + 1}" : "turn #{i + 1} called #{names.empty? ? 'nothing' : names.join(', ')}")
314
+ end
315
+ end
316
+ end
317
+
190
318
  def call_checks(golden, result)
191
319
  names = result.tool_names
192
320
  checks = golden.never_calls.map do |name|
@@ -249,14 +377,40 @@ module Insika
249
377
  detail: shown.empty? ? "nothing shown" : "shown: #{shown.join(', ')}")
250
378
  end
251
379
 
252
- # `must_not` detectors. "tool_error" is special (inspects statuses); the rest
253
- # are content detectors over the output text.
254
- def must_not_checks(golden, result)
255
- golden.must_not.map do |name|
380
+ # A reply that says it did something the store never saw happen. The case's
381
+ # `claims:` names each mutation and the words a reply uses to report it — the
382
+ # store's language, so it lives in the case, not here. Read after the turn,
383
+ # cumulative over the conversation ("já adicionei" on turn two is true when
384
+ # turn one did it) — and "ran" means a call that ENDED ok: a claim over a
385
+ # failed or blocked call is the same lie. Store-level arithmetic and duplicates
386
+ # are `store_state:`'s business; this is only whether the words match the calls.
387
+ def phantom_action_checks(golden, turns)
388
+ claims = golden.claims.transform_values { |pattern| Regexp.new(pattern, Regexp::IGNORECASE) }
389
+ ran = []
390
+ offences = turns.each_with_index.filter_map do |turn, i|
391
+ ran += Array(turn.tool_calls).select { |t| Assertions.ok_status?(t["status"] || t[:status]) }
392
+ .map { |t| (t["name"] || t[:name]).to_s }
393
+ claimed = claims.find { |tool, verbs| turn.output_text.to_s.match?(verbs) && !ran.include?(tool) }
394
+ next unless claimed
395
+
396
+ tool = claimed.first
397
+ how = turn.tool_names.include?(tool) ? "#{tool} did not succeed" : "#{tool} never ran"
398
+ "turn #{i + 1} claims an action but #{how}: #{turn.output_text.to_s.strip[0, 120].inspect}"
399
+ end
400
+ [Check.new(name: "must_not:phantom_action", pass: offences.empty?,
401
+ detail: offences.empty? ? "every claimed action ran" : offences.join(" | "))]
402
+ end
403
+
404
+ # `must_not` detectors. "tool_error" and "phantom_action" are special (they read
405
+ # the calls); the rest are content detectors over the output text.
406
+ def must_not_checks(golden, result, turns = [result])
407
+ golden.must_not.flat_map do |name|
256
408
  if name == "tool_error"
257
409
  bad = result.errored_tools
258
410
  Check.new(name: "must_not:tool_error", pass: bad.empty?,
259
411
  detail: bad.empty? ? "no tool errors" : "errored: #{bad.map { |t| t['name'] || t[:name] }.join(', ')}")
412
+ elsif name == "phantom_action"
413
+ phantom_action_checks(golden, turns)
260
414
  else
261
415
  hit = detect(name, result.output_text.to_s)
262
416
  Check.new(name: "must_not:#{name}", pass: hit.nil?,
@@ -274,7 +428,7 @@ module Insika
274
428
  # Exhaustive on purpose: a policy added to POLICIES without a rule here would
275
429
  # otherwise fall into whichever branch was last and check the wrong thing.
276
430
  pass, detail = case name
277
- when "ask_once" then ask_once(turns)
431
+ when "ask_once" then ask_once(turns, max: golden.policy_options.fetch("max", 1).to_i)
278
432
  when "investigate_first" then investigate_first(turns.first)
279
433
  when "act_fast" then act_fast(turns.first)
280
434
  else raise ArgumentError, "policy #{name.inspect} has no rule"
@@ -282,14 +436,15 @@ module Insika
282
436
  [Check.new(name: "policy:#{name}", pass: pass, detail: detail)]
283
437
  end
284
438
 
285
- # Every reply asks at most one question. Reported with the offending turn and
439
+ # Every reply asks at most `max` questions. Reported with the offending turn and
286
440
  # the reply itself — "2 questions" alone sends the reader digging.
287
- def ask_once(turns)
288
- offender = turns.each_with_index.find { |t, _| count_questions(t.output_text) > 1 }
289
- return [true, "at most one question per reply"] unless offender
441
+ def ask_once(turns, max: 1)
442
+ max = 1 if max < 1
443
+ offender = turns.each_with_index.find { |t, _| count_questions(t.output_text) > max }
444
+ return [true, "at most #{max} question(s) per reply"] unless offender
290
445
 
291
446
  turn, i = offender
292
- [false, "turn #{i + 1} asked #{count_questions(turn.output_text)} questions: " \
447
+ [false, "turn #{i + 1} asked #{count_questions(turn.output_text)} questions (max #{max}): " \
293
448
  "#{turn.output_text.to_s.strip[0, 160].inspect}"]
294
449
  end
295
450
 
@@ -29,7 +29,7 @@ module Insika
29
29
  # contradiction from six turns ago) becomes reproducible. {} = the case starts
30
30
  # empty, as every case did before the key existed.
31
31
  Golden = Struct.new(:id, :agent, :turns, :expect, :requires, :reference, :source, :persona, :tenant,
32
- :state, keyword_init: true) do
32
+ :state, :store_state, keyword_init: true) do
33
33
  # The user messages to replay, in order. Empty for a persona case: a generated
34
34
  # conversation has no scripted turns.
35
35
  def user_turns = turns.map { |t| t["user"] }
@@ -49,9 +49,23 @@ module Insika
49
49
  end
50
50
  end
51
51
 
52
+ # The tools turn `i` (0-based) must call, same `name?` optional marker as above.
53
+ # [] for a turn that pins nothing.
54
+ def turn_tools_called(i)
55
+ Array(turns.dig(i, "tools_called")).map do |ref|
56
+ s = ref.to_s
57
+ optional = s.end_with?("?")
58
+ { name: optional ? s[0..-2] : s, optional: optional }
59
+ end
60
+ end
61
+
52
62
  # Names of the negative assertions to run (e.g. "pii_leak", "tool_error").
53
63
  def must_not = Array(expect["must_not"]).map(&:to_s)
54
64
 
65
+ # `claims:` — tool name => the words a reply uses to say it did that (a regex
66
+ # source, matched case-insensitively). What `must_not: phantom_action` reads.
67
+ def claims = (expect["claims"] || {}).transform_keys(&:to_s)
68
+
55
69
  # The graders over the turn's CALLS and its published REPLY — each optional,
56
70
  # each deterministic. Every positive has a negative: `never_calls` pins what a
57
71
  # correct turn does NOT do, which `tools_called` alone can never say.
@@ -72,9 +86,40 @@ module Insika
72
86
  def state = self[:state] || {}
73
87
  def seeded? = !state.empty?
74
88
 
89
+ # What the STORE must look like after the turn — the half of a commerce case
90
+ # no reply can prove. `records` are rows that must be there, `count` the exact
91
+ # size of a collection (a duplicated order fails it), `absent` rows that must
92
+ # not exist. Read after the last turn by a store reader the runner is given;
93
+ # {} = the case says nothing about the store, as every case did before.
94
+ def store_state = self[:store_state] || {}
95
+ def store_state? = !store_state.empty?
96
+
97
+ # The collections this case talks about — what the reader is asked for, and
98
+ # nothing else: a bench task about orders should not pull the catalogue.
99
+ def store_collections
100
+ (Array(store_state["records"]&.keys) + Array(store_state["count"]&.keys) +
101
+ Array(store_state["absent"]&.keys)).map(&:to_s).uniq
102
+ end
103
+
75
104
  # How much the agent should ask before acting. nil = the store
76
105
  # has no opinion and only the rubric decides.
77
- def policy = GoldenLoader.presence(expect["policy"])
106
+ def policy = policy_pair.first
107
+
108
+ # What the store set the policy TO. The engine ships the mechanism (count the
109
+ # questions); the number is the store's, because "one question per reply" is a
110
+ # tolerance, not a law — a greeting that says "tudo bem?" spends one on
111
+ # courtesy in half the languages a store sells in.
112
+ def policy_options = policy_pair.last
113
+
114
+ # `policy: ask_once` and `policy: { ask_once: { max: 2 } }` are the same
115
+ # declaration with and without a number. -> [name, options]
116
+ def policy_pair
117
+ value = expect["policy"]
118
+ return [GoldenLoader.presence(value), {}] unless value.is_a?(Hash)
119
+
120
+ name, options = value.first
121
+ [GoldenLoader.presence(name), (options || {}).transform_keys(&:to_s)]
122
+ end
78
123
 
79
124
  # What the DEPLOYMENT must have for this case to mean anything.
80
125
  # Empty = runs everywhere.
@@ -141,6 +186,7 @@ module Insika
141
186
  validate_policy!(expect["policy"], id: id, source: source)
142
187
  validate_graders!(expect, id: id, source: source)
143
188
  state = normalize_state(raw["state"], id: id, source: source)
189
+ store_state = normalize_store_state(raw["store_state"], id: id, source: source)
144
190
  requires = raw["requires"] || {}
145
191
  unless requires.is_a?(Hash)
146
192
  raise InvalidGolden, "#{source}: 'requires' must be a mapping (case '#{id}')"
@@ -151,7 +197,7 @@ module Insika
151
197
 
152
198
  Golden.new(id: id, agent: agent, turns: turns, expect: expect,
153
199
  requires: requires, reference: reference, source: source, persona: persona,
154
- tenant: tenant, state: state)
200
+ tenant: tenant, state: state, store_state: store_state)
155
201
  end
156
202
 
157
203
  # `persona:` is the alternative shape to `turns:`: the
@@ -234,6 +280,55 @@ module Insika
234
280
  state
235
281
  end
236
282
 
283
+ STORE_STATE_KEYS = %w[records count absent].freeze
284
+
285
+ # store_state: what the store must look like AFTER the turn, graded by code
286
+ # against a snapshot the runner reads. Three keys, each a mapping keyed by
287
+ # collection: `records` (rows that must exist), `count` (the exact size of a
288
+ # collection) and `absent` (rows that must not exist). A closed vocabulary on
289
+ # purpose — a case that wrote `orders:` at the top level would grade nothing
290
+ # and pass, which is the failure this key exists to catch.
291
+ def normalize_store_state(raw, id:, source:)
292
+ return {} if raw.nil?
293
+
294
+ where = "#{source}: store_state (case '#{id}')"
295
+ raise InvalidGolden, "#{where} must be a mapping" unless raw.is_a?(Hash)
296
+
297
+ unknown = raw.keys.map(&:to_s) - STORE_STATE_KEYS
298
+ unless unknown.empty?
299
+ raise InvalidGolden, "#{where}: unknown key(s) #{unknown.join(', ')} — " \
300
+ "known: #{STORE_STATE_KEYS.join(', ')}"
301
+ end
302
+
303
+ state = raw.compact
304
+ rows_by_collection!(state["records"], "#{where}.records")
305
+ rows_by_collection!(state["absent"], "#{where}.absent")
306
+ counts!(state["count"], "#{where}.count")
307
+ state
308
+ end
309
+
310
+ def rows_by_collection!(value, where)
311
+ return if value.nil?
312
+ raise InvalidGolden, "#{where} must be a mapping of collection -> rows" unless value.is_a?(Hash)
313
+
314
+ value.each do |collection, rows|
315
+ next if rows.is_a?(Array) && !rows.empty? && rows.all?(Hash)
316
+
317
+ raise InvalidGolden, "#{where}.#{collection} must be a non-empty list of mappings"
318
+ end
319
+ end
320
+
321
+ def counts!(value, where)
322
+ return if value.nil?
323
+ raise InvalidGolden, "#{where} must be a mapping of collection -> integer" unless value.is_a?(Hash)
324
+
325
+ value.each do |collection, n|
326
+ next if n.is_a?(Integer) && n >= 0
327
+
328
+ raise InvalidGolden, "#{where}.#{collection} must be a non-negative integer (got #{n.inspect})"
329
+ end
330
+ end
331
+
237
332
  def mapping_with!(value, key, type, where)
238
333
  return if value.nil?
239
334
  raise InvalidGolden, "#{where} must be a mapping" unless value.is_a?(Hash)
@@ -261,6 +356,28 @@ module Insika
261
356
 
262
357
  raise InvalidGolden, "#{source}: blocked_gates entries are 'tool:gate' (got #{pair.inspect}, case '#{id}')"
263
358
  end
359
+
360
+ validate_claims!(expect, id: id, source: source)
361
+ end
362
+
363
+ # A phantom-action check with no words to look for would pass everything, and a
364
+ # pattern that does not compile would fail every cell at grading time instead of
365
+ # the one author at load time.
366
+ def validate_claims!(expect, id:, source:)
367
+ claims = expect["claims"]
368
+ phantom = Array(expect["must_not"]).map(&:to_s).include?("phantom_action")
369
+ if phantom && !(claims.is_a?(Hash) && !claims.empty?)
370
+ raise InvalidGolden, "#{source}: must_not: phantom_action needs a non-empty 'claims:' map (case '#{id}')"
371
+ end
372
+ return if claims.nil?
373
+
374
+ raise InvalidGolden, "#{source}: 'claims' must be a map of tool => pattern (case '#{id}')" unless claims.is_a?(Hash)
375
+
376
+ claims.each do |tool, pattern|
377
+ Regexp.new(pattern.to_s)
378
+ rescue RegexpError => e
379
+ raise InvalidGolden, "#{source}: claims.#{tool} is not a valid pattern — #{e.message} (case '#{id}')"
380
+ end
264
381
  end
265
382
 
266
383
  # A typo'd policy must not silently mean "no policy" — the case would go on
@@ -268,6 +385,16 @@ module Insika
268
385
  # `Assertions` constant is resolved at CALL time (this file loads first, and
269
386
  # assertions.rb touches `Safety::Detectors` at load time).
270
387
  def validate_policy!(value, id:, source:)
388
+ if value.is_a?(Hash)
389
+ raise InvalidGolden, "#{source}: policy takes ONE name (case '#{id}')" unless value.size == 1
390
+
391
+ name, options = value.first
392
+ unless options.nil? || options.is_a?(Hash)
393
+ raise InvalidGolden, "#{source}: policy #{name.inspect} options must be a map (case '#{id}')"
394
+ end
395
+
396
+ value = name
397
+ end
271
398
  name = presence(value)
272
399
  return if name.nil? || Assertions::POLICIES.key?(name)
273
400
 
@@ -277,6 +404,11 @@ module Insika
277
404
 
278
405
  # turns: a non-empty array of { "user" => String }. Rejects anything else so a
279
406
  # typo (e.g. `users:`) surfaces at load time, not as an empty replay.
407
+ #
408
+ # A turn may also carry `tools_called: [names]` — the calls THAT turn must make.
409
+ # The case-level `tools_called` reads the last turn only, and the bench found a
410
+ # reply that said "adicionado ao carrinho" on turn one without ever calling
411
+ # add_to_cart, then recovered on turn two so the final store looked right.
280
412
  def normalize_turns(turns, id:, source:)
281
413
  unless turns.is_a?(Array) && !turns.empty?
282
414
  raise InvalidGolden, "#{source}: 'turns' must be a non-empty array (case '#{id}')"
@@ -285,7 +417,12 @@ module Insika
285
417
  turns.each_with_index.map do |t, i|
286
418
  user = t.is_a?(Hash) ? presence(t["user"]) : nil
287
419
  user || (raise InvalidGolden, "#{source}: turns[#{i}] needs a non-empty 'user' (case '#{id}')")
288
- { "user" => user }
420
+ tools = t["tools_called"]
421
+ unless tools.nil? || (tools.is_a?(Array) && tools.all? { |n| presence(n) })
422
+ raise InvalidGolden, "#{source}: turns[#{i}].tools_called must be a list of tool names (case '#{id}')"
423
+ end
424
+
425
+ tools.nil? ? { "user" => user } : { "user" => user, "tools_called" => tools.map(&:to_s) }
289
426
  end
290
427
  end
291
428
 
@@ -37,8 +37,13 @@ module Insika
37
37
  # rubric alone decides, and inventing a default here would be inventing an
38
38
  # opinion for someone else's store.
39
39
  POLICY_INSTRUCTIONS = {
40
- "ask_once" => "This store allows AT MOST ONE question per reply. Two questions in " \
41
- "one message is a failure even if the content is otherwise good.",
40
+ # %<max>s is the store's number, not ours. Stated to the judge in the same
41
+ # words and with the same threshold the deterministic layer uses — a judge
42
+ # told "at most one" while the counter allows two grades a different rule.
43
+ "ask_once" => "This store allows AT MOST %<max>s question(s) per reply. One more " \
44
+ "than that in a single message is a failure even if the content is " \
45
+ "otherwise good. A greeting formula that asks nothing the assistant " \
46
+ "needs (\"tudo bem?\", \"how are you?\") is courtesy, not a question.",
42
47
  "investigate_first" => "This store wants the objective established BEFORE acting: on a " \
43
48
  "vague request the assistant should ask (once or twice, not a " \
44
49
  "form), not search immediately.",
@@ -66,7 +71,8 @@ module Insika
66
71
  rubric = golden.rubric.to_s.strip
67
72
  return nil if rubric.empty?
68
73
 
69
- prompt = build_prompt(rubric, golden.user_turns, result.output_text.to_s, golden.policy)
74
+ prompt = build_prompt(rubric, golden.user_turns, result.output_text.to_s, golden.policy,
75
+ golden.respond_to?(:policy_options) ? golden.policy_options : {})
70
76
  score_prompt(prompt, golden.min_score || DEFAULT_MIN_SCORE)
71
77
  end
72
78
 
@@ -75,11 +81,12 @@ module Insika
75
81
  # [{ role: "user"|"assistant", text: }] in order. -> Verdict, or nil without
76
82
  # a rubric. Same panel, same rules as `score`: the rubric that scores a
77
83
  # scripted replay scores a generated one.
78
- def score_conversation(rubric:, transcript:, policy: nil, min_score: DEFAULT_MIN_SCORE)
84
+ def score_conversation(rubric:, transcript:, policy: nil, policy_options: {},
85
+ min_score: DEFAULT_MIN_SCORE)
79
86
  rubric = rubric.to_s.strip
80
87
  return nil if rubric.empty?
81
88
 
82
- prompt = conversation_prompt(rubric, transcript, policy)
89
+ prompt = conversation_prompt(rubric, transcript, policy, policy_options)
83
90
  score_prompt(prompt, min_score)
84
91
  end
85
92
 
@@ -102,7 +109,7 @@ module Insika
102
109
  # user turn and every assistant reply interleaved. A rubric about the whole
103
110
  # exchange ("does it discover the objective before recommending?") is
104
111
  # unanswerable from the last reply alone.
105
- def conversation_prompt(rubric, transcript, policy)
112
+ def conversation_prompt(rubric, transcript, policy, policy_options = {})
106
113
  lines = Array(transcript).map do |m|
107
114
  role = m[:role].to_s == "user" ? "customer" : "assistant"
108
115
  "#{role}: #{m[:text].to_s.strip}"
@@ -114,7 +121,7 @@ module Insika
114
121
 
115
122
  RUBRIC:
116
123
  #{rubric}
117
- #{policy_clause(policy)}
124
+ #{policy_clause(policy, policy_options)}
118
125
  CONVERSATION (in order):
119
126
  #{lines}
120
127
 
@@ -144,14 +151,14 @@ module Insika
144
151
  # much this store wants the agent to ask before acting is a per-store decision,
145
152
  # and a judge that is not TOLD it will guess — half the time wrongly. The
146
153
  # deterministic half is `Assertions.policy_checks`; this is the other half.
147
- def build_prompt(rubric, user_turns, reply, policy = nil)
154
+ def build_prompt(rubric, user_turns, reply, policy = nil, policy_options = {})
148
155
  <<~PROMPT
149
156
  You are a strict QA judge for a customer-service AI assistant. Judge the
150
157
  ASSISTANT REPLY against the RUBRIC — nothing else.
151
158
 
152
159
  RUBRIC:
153
160
  #{rubric}
154
- #{policy_clause(policy)}
161
+ #{policy_clause(policy, policy_options)}
155
162
  CONVERSATION (user turns, in order):
156
163
  #{user_turns.map { |t| "- #{t}" }.join("\n")}
157
164
 
@@ -164,10 +171,12 @@ module Insika
164
171
  PROMPT
165
172
  end
166
173
 
167
- def policy_clause(policy)
174
+ def policy_clause(policy, policy_options = {})
168
175
  instruction = POLICY_INSTRUCTIONS[policy.to_s]
169
176
  return "" unless instruction
170
177
 
178
+ instruction = format(instruction, max: (policy_options || {}).fetch("max", 1)) if
179
+ instruction.include?("%<max>s")
171
180
  "\nSTORE POLICY (weigh this as part of the rubric):\n#{instruction}\n"
172
181
  end
173
182
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Evals
5
+ # Runs each moderator over the same labeled messages and counts how they
6
+ # differ from the label. Pure: the moderators are injected.
7
+ class ModeratorCompare
8
+ BLOCKS = %w[refuse escalate].freeze
9
+
10
+ def initialize(moderators:)
11
+ @moderators = moderators
12
+ end
13
+
14
+ def run(cases)
15
+ @moderators.to_h { |name, mod| [name, score(mod, cases)] }
16
+ end
17
+
18
+ private
19
+
20
+ def score(mod, cases)
21
+ r = { "correct" => 0, "total" => cases.size, "false_blocks" => 0, "missed_blocks" => 0,
22
+ "unavailable" => 0, "misses" => [], "verdicts" => {} }
23
+ times = cases.map do |c|
24
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ v = mod.classify(c["message"])
26
+ tally(r, c, v)
27
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
28
+ end.sort
29
+ r.merge("p50_ms" => pct(times, 0.5), "p95_ms" => pct(times, 0.95))
30
+ end
31
+
32
+ def tally(r, c, v)
33
+ r["verdicts"][c["id"]] = { "action" => v.action.to_s, "category" => v.category.to_s, "reason" => v.reason.to_s }
34
+ r["unavailable"] += 1 if v.unavailable?
35
+ want_block = BLOCKS.include?(c["expected"])
36
+ ok = want_block ? v.block? : !v.block? && !v.unavailable?
37
+ return r["correct"] += 1 if ok
38
+
39
+ r["misses"] << c["id"]
40
+ r["false_blocks"] += 1 if !want_block && v.block?
41
+ r["missed_blocks"] += 1 if want_block && !v.block?
42
+ end
43
+
44
+ def pct(sorted, q) = sorted.empty? ? 0 : sorted[((sorted.size - 1) * q).round].round(1)
45
+ end
46
+ end
47
+ end