insika 0.1.0 → 0.3.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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -5
  3. data/README.md +8 -2
  4. data/bin/insika +231 -13
  5. data/docs/AGENTS.md +505 -6
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +100 -10
  8. data/docs/CONTEXT.md +147 -19
  9. data/docs/DEPLOY.md +34 -11
  10. data/docs/EMBEDDING.md +11 -7
  11. data/docs/EVALS.md +20 -1
  12. data/docs/FACTS.md +135 -0
  13. data/docs/HARVEST.md +117 -0
  14. data/docs/LOADTEST.md +17 -10
  15. data/docs/OBSERVABILITY.md +65 -2
  16. data/docs/REFINEMENT.md +9 -9
  17. data/docs/RELEASING.md +34 -7
  18. data/docs/RUNNING-LOCAL.md +4 -4
  19. data/docs/SECURITY.md +85 -11
  20. data/docs/SKILLS.md +189 -3
  21. data/docs/SOAK.md +127 -0
  22. data/docs/TOOLS.md +70 -2
  23. data/docs/WHY.md +1 -1
  24. data/docs/WORKFLOWS.md +2 -2
  25. data/docs/domain.md +115 -0
  26. data/docs/index.md +2 -2
  27. data/docs/onboarding/start.md +1 -1
  28. data/lib/insika/agent_profile.rb +228 -26
  29. data/lib/insika/alert_dispatcher.rb +139 -0
  30. data/lib/insika/balloon_splitter.rb +102 -0
  31. data/lib/insika/baseline_store.rb +2 -2
  32. data/lib/insika/budget_ledger.rb +166 -0
  33. data/lib/insika/cache_series_store.rb +49 -0
  34. data/lib/insika/channel_delivery.rb +132 -24
  35. data/lib/insika/channel_registry.rb +1 -1
  36. data/lib/insika/channels/relay.rb +80 -6
  37. data/lib/insika/channels/web/widget.js +2 -2
  38. data/lib/insika/channels/web.rb +9 -9
  39. data/lib/insika/channels/webhook.rb +58 -0
  40. data/lib/insika/chat_builder.rb +145 -13
  41. data/lib/insika/checkpoint_store.rb +16 -0
  42. data/lib/insika/circuit_state.rb +114 -0
  43. data/lib/insika/coercion.rb +8 -0
  44. data/lib/insika/commands/agent_payload.rb +6 -4
  45. data/lib/insika/commands/cancel_followup.rb +49 -0
  46. data/lib/insika/commands/create_agent.rb +2 -2
  47. data/lib/insika/commands/create_session.rb +1 -1
  48. data/lib/insika/commands/delete_llm_provider.rb +1 -1
  49. data/lib/insika/commands/delete_skill.rb +43 -0
  50. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  51. data/lib/insika/commands/export_customer_memory.rb +48 -0
  52. data/lib/insika/commands/forget_customer.rb +117 -0
  53. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  54. data/lib/insika/commands/gate_harvest.rb +138 -0
  55. data/lib/insika/commands/gate_refinement.rb +12 -12
  56. data/lib/insika/commands/import_mcp_tools.rb +1 -1
  57. data/lib/insika/commands/import_tools.rb +4 -4
  58. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  59. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  60. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  61. data/lib/insika/commands/memory_put_fact.rb +23 -4
  62. data/lib/insika/commands/promote_harvest.rb +130 -0
  63. data/lib/insika/commands/record_outcome.rb +46 -0
  64. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  65. data/lib/insika/commands/reject_harvest.rb +38 -0
  66. data/lib/insika/commands/resolve_proposal.rb +108 -0
  67. data/lib/insika/commands/resolve_refinement.rb +1 -1
  68. data/lib/insika/commands/revoke_contact.rb +49 -0
  69. data/lib/insika/commands/revoke_token.rb +39 -0
  70. data/lib/insika/commands/rollback_harvest.rb +86 -0
  71. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  72. data/lib/insika/commands/run_distillation.rb +186 -0
  73. data/lib/insika/commands/run_harvest.rb +393 -0
  74. data/lib/insika/commands/run_refinement.rb +5 -5
  75. data/lib/insika/commands/send_message.rb +112 -15
  76. data/lib/insika/commands/session_purge.rb +67 -0
  77. data/lib/insika/commands/set_agent_tools.rb +1 -1
  78. data/lib/insika/commands/set_skill_agents.rb +60 -19
  79. data/lib/insika/commands/trigger_workflow.rb +1 -1
  80. data/lib/insika/commands/update_agent.rb +1 -1
  81. data/lib/insika/commands/write_data_tool.rb +1 -1
  82. data/lib/insika/commands/write_golden.rb +1 -1
  83. data/lib/insika/commands/write_skill.rb +19 -9
  84. data/lib/insika/config_store.rb +8 -4
  85. data/lib/insika/contact_store.rb +183 -0
  86. data/lib/insika/context/builder.rb +23 -5
  87. data/lib/insika/context/fragment.rb +31 -3
  88. data/lib/insika/context/priority.rb +6 -2
  89. data/lib/insika/context/provider.rb +17 -3
  90. data/lib/insika/context/providers/briefing.rb +96 -0
  91. data/lib/insika/context/providers/memory.rb +16 -7
  92. data/lib/insika/context/providers/prompt.rb +30 -2
  93. data/lib/insika/context/providers/request.rb +1 -1
  94. data/lib/insika/context/providers/session.rb +17 -2
  95. data/lib/insika/context/providers/skill.rb +7 -1
  96. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  97. data/lib/insika/context/providers/tool_search.rb +2 -0
  98. data/lib/insika/context_trace_store.rb +128 -0
  99. data/lib/insika/delegation_store.rb +2 -2
  100. data/lib/insika/distill.rb +224 -0
  101. data/lib/insika/distill_engine.rb +169 -0
  102. data/lib/insika/doctor.rb +962 -7
  103. data/lib/insika/dsl/runtime.rb +20 -11
  104. data/lib/insika/dsl/server_boot.rb +74 -4
  105. data/lib/insika/dsl/system.rb +1 -1
  106. data/lib/insika/dsl.rb +152 -15
  107. data/lib/insika/edge_limiter.rb +167 -8
  108. data/lib/insika/egress_guard.rb +3 -3
  109. data/lib/insika/env_schema.rb +22 -12
  110. data/lib/insika/errors.rb +72 -5
  111. data/lib/insika/evals/assertions.rb +15 -14
  112. data/lib/insika/evals/baseline.rb +3 -3
  113. data/lib/insika/evals/golden.rb +8 -8
  114. data/lib/insika/evals/judge.rb +7 -7
  115. data/lib/insika/evals/pairwise.rb +21 -9
  116. data/lib/insika/evals/report.rb +2 -2
  117. data/lib/insika/evals/runner.rb +6 -6
  118. data/lib/insika/evals/transport.rb +2 -2
  119. data/lib/insika/event_stream.rb +23 -5
  120. data/lib/insika/evidence.rb +183 -0
  121. data/lib/insika/executor.rb +1092 -160
  122. data/lib/insika/followup_engine.rb +207 -0
  123. data/lib/insika/followup_policy.rb +221 -0
  124. data/lib/insika/followup_store.rb +306 -0
  125. data/lib/insika/frontmatter.rb +1 -1
  126. data/lib/insika/funnel_declaration.rb +106 -0
  127. data/lib/insika/funnel_fold.rb +179 -0
  128. data/lib/insika/funnel_store.rb +163 -0
  129. data/lib/insika/golden_store.rb +3 -3
  130. data/lib/insika/grounding/matcher.rb +69 -0
  131. data/lib/insika/grounding.rb +44 -0
  132. data/lib/insika/harvest/conversion_gate.rb +159 -0
  133. data/lib/insika/harvest/criterion.rb +98 -0
  134. data/lib/insika/harvest/gate.rb +194 -0
  135. data/lib/insika/harvest/negative_list.rb +199 -0
  136. data/lib/insika/harvest.rb +241 -0
  137. data/lib/insika/harvest_engine.rb +193 -0
  138. data/lib/insika/harvest_store.rb +548 -0
  139. data/lib/insika/http_client.rb +3 -3
  140. data/lib/insika/inbound_log.rb +1 -1
  141. data/lib/insika/llm_configurator.rb +3 -3
  142. data/lib/insika/loop_detector.rb +143 -0
  143. data/lib/insika/mcp_http_client.rb +4 -4
  144. data/lib/insika/mcp_tool_ingestor.rb +6 -6
  145. data/lib/insika/media.rb +298 -0
  146. data/lib/insika/memory_audit_store.rb +85 -0
  147. data/lib/insika/memory_store.rb +264 -23
  148. data/lib/insika/message_origin.rb +8 -3
  149. data/lib/insika/model_resolver.rb +1 -1
  150. data/lib/insika/model_selection.rb +5 -4
  151. data/lib/insika/model_visible.rb +87 -0
  152. data/lib/insika/model_visible_trace_store.rb +66 -0
  153. data/lib/insika/onboarding.rb +8 -3
  154. data/lib/insika/outbox_store.rb +44 -6
  155. data/lib/insika/outcome_store.rb +147 -0
  156. data/lib/insika/overlay_tool_registry.rb +3 -4
  157. data/lib/insika/pack.rb +3 -3
  158. data/lib/insika/pack_importer.rb +17 -15
  159. data/lib/insika/packaging.rb +163 -0
  160. data/lib/insika/parity/criterion.rb +79 -0
  161. data/lib/insika/parity/verdict.rb +318 -0
  162. data/lib/insika/pending_action_store.rb +1 -1
  163. data/lib/insika/plugin/loader.rb +2 -2
  164. data/lib/insika/policy/policy.rb +1 -1
  165. data/lib/insika/prefix_fingerprint.rb +58 -0
  166. data/lib/insika/profile_source.rb +34 -7
  167. data/lib/insika/proposal_store.rb +271 -0
  168. data/lib/insika/provider_error_classifier.rb +160 -0
  169. data/lib/insika/queue_policy.rb +6 -3
  170. data/lib/insika/recovery.rb +47 -6
  171. data/lib/insika/refinement/candidate.rb +4 -4
  172. data/lib/insika/refinement/evidence_collector.rb +6 -6
  173. data/lib/insika/refinement/gate.rb +7 -7
  174. data/lib/insika/refinement/panel.rb +7 -7
  175. data/lib/insika/refinement/proposer.rb +10 -10
  176. data/lib/insika/refinement_store.rb +12 -12
  177. data/lib/insika/reliability.rb +211 -0
  178. data/lib/insika/retention.rb +281 -0
  179. data/lib/insika/routing.rb +101 -0
  180. data/lib/insika/safety/config.rb +46 -6
  181. data/lib/insika/safety/corpus.rb +255 -0
  182. data/lib/insika/safety/detectors.rb +34 -115
  183. data/lib/insika/safety/factory.rb +18 -5
  184. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  185. data/lib/insika/safety/grounding_validator.rb +49 -0
  186. data/lib/insika/safety/input_guardrail.rb +20 -5
  187. data/lib/insika/safety/moderator.rb +19 -11
  188. data/lib/insika/safety/output_filter.rb +10 -6
  189. data/lib/insika/safety/output_validator.rb +13 -7
  190. data/lib/insika/safety/safe_responses.rb +1 -1
  191. data/lib/insika/sandbox/boundary.rb +2 -2
  192. data/lib/insika/sandbox.rb +1 -1
  193. data/lib/insika/schema_guard.rb +35 -0
  194. data/lib/insika/server/app.rb +366 -54
  195. data/lib/insika/server/boot.rb +4 -4
  196. data/lib/insika/server/rack_app.rb +31 -7
  197. data/lib/insika/server/responses.rb +58 -9
  198. data/lib/insika/server/tenant_auth.rb +61 -0
  199. data/lib/insika/session_actor.rb +11 -7
  200. data/lib/insika/session_store.rb +66 -3
  201. data/lib/insika/settings_store.rb +15 -5
  202. data/lib/insika/shadow_pair_store.rb +258 -0
  203. data/lib/insika/shutdown.rb +4 -4
  204. data/lib/insika/skill_catalog.rb +131 -20
  205. data/lib/insika/skill_store.rb +70 -22
  206. data/lib/insika/soak/envelope.rb +140 -0
  207. data/lib/insika/soak/report.rb +392 -0
  208. data/lib/insika/soak/runner.rb +554 -0
  209. data/lib/insika/steer_injector.rb +1 -1
  210. data/lib/insika/store.rb +11 -2
  211. data/lib/insika/stores/memory.rb +6 -0
  212. data/lib/insika/stores/sqlite.rb +8 -0
  213. data/lib/insika/studio/app.rb +1058 -75
  214. data/lib/insika/studio/assets/dist/application.css +1 -1
  215. data/lib/insika/studio/assets/dist/application.js +27 -26
  216. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  217. data/lib/insika/studio/forms.rb +274 -22
  218. data/lib/insika/studio/nav_icons.rb +7 -2
  219. data/lib/insika/studio/views/_message.erb +2 -2
  220. data/lib/insika/studio/views/agent_detail.erb +629 -86
  221. data/lib/insika/studio/views/agents.erb +11 -7
  222. data/lib/insika/studio/views/approvals.erb +4 -1
  223. data/lib/insika/studio/views/chats.erb +4 -1
  224. data/lib/insika/studio/views/customer.erb +94 -0
  225. data/lib/insika/studio/views/customers.erb +32 -0
  226. data/lib/insika/studio/views/evals.erb +4 -1
  227. data/lib/insika/studio/views/facts.erb +133 -0
  228. data/lib/insika/studio/views/followups.erb +125 -0
  229. data/lib/insika/studio/views/funnel.erb +106 -0
  230. data/lib/insika/studio/views/harvest.erb +234 -0
  231. data/lib/insika/studio/views/home.erb +2 -1
  232. data/lib/insika/studio/views/layout.erb +1 -0
  233. data/lib/insika/studio/views/parity.erb +147 -0
  234. data/lib/insika/studio/views/playground.erb +7 -1
  235. data/lib/insika/studio/views/refinement.erb +4 -4
  236. data/lib/insika/studio/views/session.erb +133 -3
  237. data/lib/insika/studio/views/settings.erb +9 -12
  238. data/lib/insika/studio/views/skills.erb +66 -12
  239. data/lib/insika/studio/views/system_files.erb +1 -1
  240. data/lib/insika/studio/views/task.erb +13 -0
  241. data/lib/insika/studio/views/tasks.erb +4 -1
  242. data/lib/insika/studio/views/tools.erb +0 -1
  243. data/lib/insika/subagent_graph.rb +3 -3
  244. data/lib/insika/task_actor.rb +3 -3
  245. data/lib/insika/task_store.rb +22 -2
  246. data/lib/insika/telemetry/pricing.rb +3 -3
  247. data/lib/insika/telemetry/recorder.rb +1 -1
  248. data/lib/insika/telemetry.rb +2 -2
  249. data/lib/insika/testing/store_contract.rb +54 -33
  250. data/lib/insika/tick.rb +146 -0
  251. data/lib/insika/token_store.rb +168 -0
  252. data/lib/insika/tool_assembly.rb +5 -5
  253. data/lib/insika/tool_definition.rb +25 -15
  254. data/lib/insika/tool_envelope.rb +70 -1
  255. data/lib/insika/tool_manifest.rb +11 -7
  256. data/lib/insika/tool_output_compressor.rb +100 -0
  257. data/lib/insika/tool_store.rb +1 -1
  258. data/lib/insika/tool_trace_store.rb +1 -1
  259. data/lib/insika/tools/concurrency.rb +2 -2
  260. data/lib/insika/tools/data_defined_tool.rb +14 -5
  261. data/lib/insika/tools/generate_image.rb +44 -0
  262. data/lib/insika/tools/load_skill.rb +61 -3
  263. data/lib/insika/tools/schedule_followup.rb +164 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +4 -4
  266. data/lib/insika/tools/subagents.rb +1 -1
  267. data/lib/insika/tools/tts.rb +47 -0
  268. data/lib/insika/tools/update_briefing.rb +126 -0
  269. data/lib/insika/turn_output.rb +2 -2
  270. data/lib/insika/turn_state.rb +54 -13
  271. data/lib/insika/turn_timing.rb +24 -4
  272. data/lib/insika/usage_ledger.rb +1 -1
  273. data/lib/insika/version.rb +1 -1
  274. data/lib/insika/vitals.rb +84 -0
  275. data/lib/insika/wiring/graph.rb +372 -34
  276. data/lib/insika/workflow.rb +1 -1
  277. data/lib/insika/workflow_registry.rb +1 -1
  278. data/lib/insika.rb +122 -16
  279. metadata +95 -2
  280. data/lib/insika/server/admin_auth.rb +0 -29
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Parity
7
+ # Both halves of an exchange formatted IDENTICALLY, so the judge cannot tell
8
+ # them apart by shape (anonymity by construction).
9
+ def self.transcript(inbound, reply)
10
+ "customer: #{inbound.to_s.strip}\nassistant: #{reply.to_s.strip}"
11
+ end
12
+
13
+ # C6 — the mechanical fold. Given the window's pairs and the frozen criterion,
14
+ # produce ONE verdict and show its arithmetic. Pure: no store, no clock beyond
15
+ # the injected `now`, no LLM. Every number the Studio prints comes from here,
16
+ # so two people reading the same pairs get the same answer (E3).
17
+ #
18
+ # The order of the steps IS the rule:
19
+ # window -> pre-registration -> buckets -> volume -> sanity -> primary -> guards
20
+ module Verdict
21
+ module_function
22
+
23
+ Report = Data.define(
24
+ :verdict, # :pass | :fail | :insufficient | :invalid
25
+ :window, # { from:, to:, days: }
26
+ :counts, # judged/decided/better/comparable/worse/split/unknown/
27
+ # silent/incomplete/human_assisted/open
28
+ :daily, # [{ date:, pairs:, decided: }]
29
+ :per_agent, # { agent => { decided:, win_or_tie:, worse:, meets: } }
30
+ :win_or_tie, :win_or_tie_lower, :worse_rate,
31
+ :undecided_rate, :incomplete_rate,
32
+ :checks, # [{ id:, met:, actual:, required:, note: }]
33
+ :criterion_sha, :reason
34
+ )
35
+
36
+ DECIDED = %w[better comparable worse].freeze
37
+
38
+ Check = Data.define(:id, :met, :actual, :required, :note)
39
+
40
+ def fold(pairs:, criterion:, now: Time.now.utc)
41
+ rule = criterion.rule
42
+ from = now - (rule.window_days * 86_400)
43
+ windowed = Array(pairs).select { |p| in_window?(p, from, now) }
44
+
45
+ checks = []
46
+
47
+ # 2. Pre-registration: a window whose pairs disagree about which file was
48
+ # frozen is :invalid — a post-hoc edit produces NO verdict, not a worse one.
49
+ sha = sha_check(windowed, criterion.sha)
50
+ if sha && !sha.met
51
+ checks << sha
52
+ return build(verdict: :invalid, criterion: criterion, from: from, to: now,
53
+ rule: rule, counts: nil, daily: [], per_agent: {},
54
+ checks: checks, reason: sha.note)
55
+ end
56
+ checks << sha if sha
57
+
58
+ # 3. Buckets: decided is better+comparable+worse against the incumbent's
59
+ # model (`vs: agent`). human-assisted/silent/incomplete/open/split/unknown
60
+ # are counted and reported, never in the denominator.
61
+ counts = count(windowed)
62
+ daily = daily_of(windowed, from, now, rule)
63
+ per_agent = per_agent_of(windowed)
64
+
65
+ # 4. Volume: every day at the floor, and enough decided pairs in total —
66
+ # short volume is :insufficient (an honest "keep running"), never a fail.
67
+ volume = volume_check(daily, rule)
68
+ checks << volume
69
+ decided_check = Check.new(id: :min_decided, met: counts[:decided] >= rule.min_decided,
70
+ actual: counts[:decided], required: rule.min_decided,
71
+ note: "decided #{counts[:decided]} < #{rule.min_decided}")
72
+ checks << decided_check
73
+ unless volume.met && decided_check.met
74
+ notes = [volume.met ? nil : volume.note, decided_check.met ? nil : decided_check.note].compact
75
+ return build(verdict: :insufficient, criterion: criterion, from: from, to: now,
76
+ rule: rule, counts: counts, daily: daily, per_agent: per_agent,
77
+ checks: checks, reason: notes.join("; "))
78
+ end
79
+
80
+ # 5. Sanity: a panel that cannot decide, or a mirror that cannot pair,
81
+ # is not measuring anything -> :invalid. The denominator is the panel's
82
+ # own work: judged pairs against a model — human-assisted pairs are
83
+ # counted and reported, never in a denominator.
84
+ judged = counts[:judged] - counts[:human_assisted]
85
+ undecided_rate = judged.positive? ? (counts[:split] + counts[:unknown]).fdiv(judged) : 0.0
86
+ undecided = Check.new(id: :undecided, met: undecided_rate <= rule.undecided_rate_ceiling,
87
+ actual: undecided_rate.round(4), required: "<= #{rule.undecided_rate_ceiling}",
88
+ note: "undecided rate #{undecided_rate.round(3)} > #{rule.undecided_rate_ceiling} " \
89
+ "(#{counts[:split]} split, #{counts[:unknown]} unknown of #{judged} judged)")
90
+ checks << undecided
91
+
92
+ non_open = windowed.length - counts[:open]
93
+ incomplete_rate = non_open.positive? ? counts[:incomplete].fdiv(non_open) : 0.0
94
+ incomplete = Check.new(id: :incomplete, met: incomplete_rate <= rule.incomplete_rate_ceiling,
95
+ actual: incomplete_rate.round(4), required: "<= #{rule.incomplete_rate_ceiling}",
96
+ note: "incomplete rate #{incomplete_rate.round(3)} > #{rule.incomplete_rate_ceiling} " \
97
+ "(#{counts[:incomplete]} of #{non_open} non-open pairs never got both halves)")
98
+ checks << incomplete
99
+ unless undecided.met && incomplete.met
100
+ notes = [undecided.met ? nil : undecided.note, incomplete.met ? nil : incomplete.note].compact
101
+ return build(verdict: :invalid, criterion: criterion, from: from, to: now,
102
+ rule: rule, counts: counts, daily: daily, per_agent: per_agent,
103
+ rates: { undecided: undecided_rate, incomplete: incomplete_rate },
104
+ checks: checks, reason: notes.join("; "))
105
+ end
106
+
107
+ # 6. Primary: the lower bound of the 95% Wilson interval on win-or-tie.
108
+ # The estimator is NAMED in the criterion, so a point estimate can never be
109
+ # quietly substituted.
110
+ win_or_tie = counts[:decided].positive? ? (counts[:better] + counts[:comparable]).fdiv(counts[:decided]) : 0.0
111
+ lower = wilson_lower(counts[:better] + counts[:comparable], counts[:decided])
112
+ primary = Check.new(id: :primary, met: lower >= rule.win_or_tie_floor,
113
+ actual: { win_or_tie: win_or_tie.round(4), wilson_lower_95: lower.round(4) },
114
+ required: "#{rule.estimator} >= #{rule.win_or_tie_floor}",
115
+ note: "win-or-tie #{win_or_tie.round(3)} (wilson lower #{lower.round(3)}) " \
116
+ "< floor #{rule.win_or_tie_floor}")
117
+ checks << primary
118
+
119
+ # 7. Guards: the worse tail on its own, and any store blocking its own cut.
120
+ worse_rate = counts[:decided].positive? ? counts[:worse].fdiv(counts[:decided]) : 0.0
121
+ worse = Check.new(id: :worse_rate, met: worse_rate <= rule.worse_rate_ceiling,
122
+ actual: worse_rate.round(4), required: "<= #{rule.worse_rate_ceiling}",
123
+ note: "worse rate #{worse_rate.round(3)} > #{rule.worse_rate_ceiling} " \
124
+ "(#{counts[:worse]} worse of #{counts[:decided]} decided)")
125
+ checks << worse
126
+ agent = per_agent_check(per_agent, rule)
127
+ checks << agent if agent
128
+
129
+ failed = [primary, worse, agent].compact.reject(&:met)
130
+ unless failed.empty?
131
+ return build(verdict: :fail, criterion: criterion, from: from, to: now,
132
+ rule: rule, counts: counts, daily: daily, per_agent: per_agent,
133
+ rates: { win_or_tie: win_or_tie, lower: lower, worse: worse_rate,
134
+ undecided: undecided_rate, incomplete: incomplete_rate },
135
+ checks: checks, reason: failed.map(&:note).join("; "))
136
+ end
137
+
138
+ build(verdict: :pass, criterion: criterion, from: from, to: now,
139
+ rule: rule, counts: counts, daily: daily, per_agent: per_agent,
140
+ rates: { win_or_tie: win_or_tie, lower: lower, worse: worse_rate,
141
+ undecided: undecided_rate, incomplete: incomplete_rate },
142
+ checks: checks, reason: "the cut cleared: every check in the window is green")
143
+ end
144
+
145
+ # Wilson score interval, lower bound. Pure arithmetic, unit-tested against
146
+ # published values (n=210, k=183 -> ~0.819).
147
+ def wilson_lower(successes, n, z: 1.96)
148
+ return 0.0 if n.to_i <= 0
149
+
150
+ p_hat = successes.to_f / n
151
+ z2 = z * z
152
+ denominator = 1 + (z2 / n)
153
+ centre = p_hat + (z2 / (2 * n))
154
+ spread = Math.sqrt((p_hat * (1 - p_hat) + (z2 / (4 * n))) / n)
155
+ (centre - (z * spread)) / denominator
156
+ end
157
+
158
+ # -- private helpers ---------------------------------------------------
159
+
160
+ def in_window?(pair, from, to)
161
+ created = pair.respond_to?(:created_at) ? pair.created_at : nil
162
+ time = begin
163
+ Time.iso8601(created.to_s)
164
+ rescue ArgumentError
165
+ nil
166
+ end
167
+ time && time >= from && time < to
168
+ end
169
+ private_class_method :in_window?
170
+
171
+ # Pre-registration has nothing to stand on when NO pair carries the sha:
172
+ # the window predates the freeze, and stamping a verdict on it is the same
173
+ # post-hoc edit the mixed-sha rule exists to refuse.
174
+ def sha_check(pairs, expected)
175
+ shas = pairs.filter_map { |p| p.respond_to?(:criterion_sha) ? p.criterion_sha : nil }.compact.uniq
176
+ if shas.length == 1 && shas.first == expected
177
+ Check.new(id: :criterion_sha, met: true, actual: expected, required: expected, note: nil)
178
+ elsif pairs.empty?
179
+ # nothing to pre-register — the volume check owns the empty window
180
+ Check.new(id: :criterion_sha, met: true, actual: expected, required: expected, note: nil)
181
+ elsif shas.empty?
182
+ Check.new(id: :criterion_sha, met: false, actual: "no pair stamped", required: expected,
183
+ note: "no pair in the window carries a criterion_sha — the window predates the freeze")
184
+ else
185
+ Check.new(id: :criterion_sha, met: false, actual: shas.join(" | "), required: expected,
186
+ note: "window carries #{shas.join(', ')}, expected the frozen #{expected}")
187
+ end
188
+ end
189
+ private_class_method :sha_check
190
+
191
+ def count(windowed)
192
+ buckets = windowed.group_by { |p| bucket_of(p) }
193
+ base = { judged: windowed.count { |p| p.status == :judged },
194
+ open: windowed.count { |p| p.status == :open } }
195
+ { silent: buckets[:silent].to_a.length,
196
+ incomplete: buckets[:incomplete].to_a.length,
197
+ human_assisted: buckets[:human_assisted].to_a.length,
198
+ better: buckets[:better].to_a.length, comparable: buckets[:comparable].to_a.length,
199
+ worse: buckets[:worse].to_a.length, split: buckets[:split].to_a.length,
200
+ unknown: buckets[:unknown].to_a.length }.merge(base).tap do |c|
201
+ c[:decided] = c[:better] + c[:comparable] + c[:worse]
202
+ end
203
+ end
204
+ private_class_method :count
205
+
206
+ # A malformed verdict blob counts as :unknown, never as a preference — the
207
+ # same rule Pairwise applies to a broken judge.
208
+ def bucket_of(pair)
209
+ return pair.status.to_sym unless pair.status == :judged
210
+
211
+ verdict = pair.respond_to?(:verdict) && pair.verdict.is_a?(Hash) ? pair.verdict : nil
212
+ return :unknown unless verdict
213
+
214
+ return :human_assisted if verdict["vs"] == "human-assisted"
215
+
216
+ outcome = verdict["outcome"].to_s
217
+ DECIDED.include?(outcome) ? outcome.to_sym : outcome == "split" ? :split : :unknown
218
+ end
219
+ private_class_method :bucket_of
220
+
221
+ # One entry per CALENDAR date the window touches, bucketed by each pair's
222
+ # own date — the newest pairs must land on the grid, never outside the list.
223
+ # `floor` is the daily requirement: pairs_per_day for a day whose full 24h
224
+ # lie inside the window, 0 for the two boundary days — they are partial by
225
+ # construction (`from` carries now's time of day), and holding a partial day
226
+ # to a full floor would make the gate unreachable under constant traffic at
227
+ # exactly the floor. Partial days are reported, never required.
228
+ def daily_of(windowed, from, to, rule)
229
+ by_day = windowed.group_by { |p| Time.iso8601(p.created_at.to_s).utc.strftime("%Y-%m-%d") }
230
+ calendar_dates(from, to).map do |date|
231
+ pairs = by_day[date].to_a
232
+ decided = pairs.count { |p| DECIDED.include?(bucket_of(p).to_s) }
233
+ { date: date, pairs: pairs.length, decided: decided,
234
+ floor: full_day?(date, from, to) ? rule.pairs_per_day : 0 }
235
+ end
236
+ end
237
+ private_class_method :daily_of
238
+
239
+ def calendar_dates(from, to)
240
+ dates = []
241
+ day = Time.utc(from.utc.year, from.utc.month, from.utc.day)
242
+ last = Time.utc(to.utc.year, to.utc.month, to.utc.day)
243
+ while day <= last
244
+ dates << day.strftime("%Y-%m-%d")
245
+ day += 86_400
246
+ end
247
+ dates
248
+ end
249
+ private_class_method :calendar_dates
250
+
251
+ def full_day?(date, from, to)
252
+ y, m, d = date.split("-").map(&:to_i)
253
+ day_start = Time.utc(y, m, d)
254
+ day_end = day_start + 86_400
255
+ [[to, day_end].min - [from, day_start].max, 0].max >= 86_400
256
+ end
257
+ private_class_method :full_day?
258
+
259
+ def volume_check(daily, rule)
260
+ short = daily.find { |d| d[:floor].positive? && d[:pairs] < d[:floor] }
261
+ full_days = daily.count { |d| d[:floor].positive? }
262
+ return Check.new(id: :volume, met: true,
263
+ actual: "#{full_days} full day(s) at >= #{rule.pairs_per_day}",
264
+ required: "#{rule.pairs_per_day} pairs on every full day",
265
+ note: nil) unless short
266
+
267
+ Check.new(id: :volume, met: false,
268
+ actual: "#{short[:date]}: #{short[:pairs]} pairs",
269
+ required: "#{short[:floor]} pairs that day",
270
+ note: "day #{short[:date]} has #{short[:pairs]} pairs (floor #{short[:floor]})")
271
+ end
272
+ private_class_method :volume_check
273
+
274
+ def per_agent_of(windowed)
275
+ windowed.select { |p| DECIDED.include?(bucket_of(p).to_s) }
276
+ .group_by { |p| p.agent.to_s }
277
+ .transform_values do |pairs|
278
+ decided = pairs.length
279
+ win_or_tie = decided.positive? ? pairs.count { |p| %w[better comparable].include?(bucket_of(p).to_s) }.fdiv(decided) : 0.0
280
+ { decided: decided, win_or_tie: win_or_tie.round(4),
281
+ worse: pairs.count { |p| bucket_of(p) == :worse }, meets: nil }
282
+ end
283
+ end
284
+ private_class_method :per_agent_of
285
+
286
+ # A store with enough decided pairs blocks its own cut when its win-or-tie
287
+ # sits under its own floor — even if the aggregate passes.
288
+ def per_agent_check(per_agent, rule)
289
+ blockers = per_agent.each_with_object({}) do |(agent, stats), acc|
290
+ next if stats[:decided] < rule.per_agent_min_decided
291
+
292
+ stats[:meets] = stats[:win_or_tie] >= rule.per_agent_win_or_tie_floor
293
+ acc[agent] = stats[:win_or_tie] unless stats[:meets]
294
+ end
295
+ return nil if blockers.empty?
296
+
297
+ Check.new(id: :per_agent, met: false, actual: blockers.map { |a, r| "#{a}: #{r}" }.join(", "),
298
+ required: ">= #{rule.per_agent_win_or_tie_floor} for stores with >= #{rule.per_agent_min_decided} decided",
299
+ note: blockers.map { |a, r| "#{a} sits at #{r.round(3)} (< #{rule.per_agent_win_or_tie_floor})" }.join("; "))
300
+ end
301
+ private_class_method :per_agent_check
302
+
303
+ def build(verdict:, criterion:, from:, to:, rule:, counts:, daily:, per_agent:, checks:, reason:, rates: nil)
304
+ r = rates || {}
305
+ Report.new(
306
+ verdict: verdict,
307
+ window: { from: from.utc.iso8601, to: to.utc.iso8601, days: rule.window_days },
308
+ counts: counts, daily: daily, per_agent: per_agent,
309
+ win_or_tie: r[:win_or_tie]&.round(4), win_or_tie_lower: r[:lower]&.round(4),
310
+ worse_rate: r[:worse]&.round(4),
311
+ undecided_rate: r[:undecided]&.round(4), incomplete_rate: r[:incomplete]&.round(4),
312
+ checks: checks, criterion_sha: criterion.sha, reason: reason
313
+ )
314
+ end
315
+ private_class_method :build
316
+ end
317
+ end
318
+ end
@@ -64,7 +64,7 @@ module Insika
64
64
  end
65
65
 
66
66
  # -> [PendingAction] every :pending across all tasks — the approvals inbox
67
- # (§12 G5, Studio). Single O(n) scan (vs. open_for per task = O(n·m)); the
67
+ # (Studio). Single O(n) scan (vs. open_for per task = O(n·m)); the
68
68
  # UI resolves task context afterwards via TaskStore#find.
69
69
  def all_open
70
70
  @store.list(SCOPE, KEY_PREFIX).filter_map do |key|
@@ -209,7 +209,7 @@ module Insika
209
209
  @registries[:workflows].register(name, callable, plugin: @plugin_id, &block)
210
210
  end
211
211
 
212
- # RFC-0011 §4.2 — a channel is an INSTANCE (it holds its credentials and its
212
+ # a channel is an INSTANCE (it holds its credentials and its
213
213
  # HTTP client), so unlike a tool there is no factory to defer. Declared-or-
214
214
  # ignored like the others: the id is a URL segment, and a plugin quietly
215
215
  # mounting a route nobody declared is exactly what the contract list prevents.
@@ -254,7 +254,7 @@ module Insika
254
254
 
255
255
  if kind == :workflow
256
256
  warn "[plugin #{@plugin_id}] capability '#{name}' (kind: workflow) registered without a consumer " \
257
- "in this slice — agent exposure is follow-up (P2B-01 L5)"
257
+ "in this slice — agent exposure is follow-up"
258
258
  end
259
259
 
260
260
  @staged_capabilities << { capability: name, impl_name: impl_name, kind: kind,
@@ -47,7 +47,7 @@ module Insika
47
47
  # Absorbs ToolRegistry#resolve as a policy: optional without
48
48
  # opt-in -> deny; tools_deny -> deny ("deny always wins"); tools_allow
49
49
  # with the semantics (nil = all; [] = ∅; [names] = final set).
50
- # Phase 7/D4/F5 (Step C): `tools_allow_groups` UNIONS in the groups' tools
50
+ # `tools_allow_groups` UNIONS in the groups' tools
51
51
  # (the group is GIVEN in the Entry metadata). Both allowlists nil = all.
52
52
  class ToolAllowlist < Base
53
53
  def decide(request)
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha2"
4
+
5
+ module Insika
6
+ # — the cache-prefix hash chain. Inputs are the SYSTEM-placement
7
+ # fragments in canonical render order (the Builder's sort, C3) and the tool
8
+ # schema serialization — the same yardstick the token estimate uses
9
+ # (name + description + parameters.inspect, executor.rb:957). Outputs are
10
+ # PII-free digests: it never sees message text, only hashes leave this class.
11
+ # Pure stdlib (digest/sha2), no gem, no IO.
12
+ class PrefixFingerprint
13
+ # -> { "prompt" => "sha256…", …, "tool_schemas" => "sha256…",
14
+ # "prefix" => "sha256…" }
15
+ # Keys are the demodulized, downcased provider ids, in render order.
16
+ # A category with no fragments is absent (not an empty hash).
17
+ # The category digest = SHA256 of the category's fragments joined "\n\n"
18
+ # (the Builder's separator — the digest matches rendered bytes).
19
+ # "prefix" = SHA256 of the category digests concatenated IN CHAIN ORDER
20
+ # (identity categories, then volatile, then tool_schemas) — any divergence
21
+ # anywhere above the boundary changes it.
22
+ def self.compute(system_fragments, tool_serial:)
23
+ digests = {} # category name -> digest, insertion = render order
24
+ grouped = system_fragments.group_by { |f| category(f.source) }
25
+ grouped.each do |name, frags|
26
+ digests[name] = digest(frags.map(&:content).join("\n\n"))
27
+ end
28
+ digests["tool_schemas"] = digest(tool_serial.to_s) unless tool_serial.nil?
29
+ digests["prefix"] = digest(digests.values.join) unless digests.empty?
30
+ digests
31
+ end
32
+
33
+ # -> String | nil. The first category (in CURRENT chain order) whose digest
34
+ # differs or is absent from `previous`; else the first PREVIOUS key now
35
+ # absent from the current chain (a vanished block is a divergence too);
36
+ # else nil. nil `previous` (first turn) -> nil. The returned name is a
37
+ # category id — PII-free by construction.
38
+ #
39
+ # The cumulative "prefix" key is deliberately SKIPPED in the scan: its
40
+ # digest changes whenever ANY category moves, so scanning it would shadow a
41
+ # vanished block (every surviving category matches, "prefix" differs, and
42
+ # the vanished-fallback below becomes unreachable — reporting `broke:
43
+ # prefix` instead of the category that actually left).
44
+ def self.invalidation_reason(current, previous)
45
+ return nil unless previous.is_a?(Hash)
46
+
47
+ current.each_key do |name|
48
+ next if name == "prefix"
49
+
50
+ return name unless previous[name] == current[name]
51
+ end
52
+ (previous.keys - current.keys).first
53
+ end
54
+
55
+ def self.category(source) = source.to_s.split("::").last.to_s.downcase
56
+ def self.digest(bytes) = Digest::SHA256.hexdigest(bytes)
57
+ end
58
+ end
@@ -90,6 +90,7 @@ module Insika
90
90
  tools_allow: h[:tools_allow], tools_deny: h[:tools_deny] || [],
91
91
  tools_allow_groups: h[:tools_allow_groups],
92
92
  skills: h[:skills],
93
+ skills_eager: h[:skills_eager],
93
94
  context_providers: h[:context_providers],
94
95
  workflows_allow: h[:workflows_allow],
95
96
  policies: Array(h[:policies]).map(&:to_sym),
@@ -97,31 +98,57 @@ module Insika
97
98
  limits: symbolize_limits(h[:limits]),
98
99
  approvals_required: h[:approvals_required],
99
100
  capabilities: h[:capabilities],
100
- # subagents (RFC-0010): allowlist of child ids; build re-normalizes to
101
+ # subagents: allowlist of child ids; build re-normalizes to
101
102
  # [String]. nil round-trips as nil (opt-in: NONE).
102
103
  subagents: h[:subagents],
103
104
  tools_deferred: h[:tools_deferred],
104
105
  memory: h[:memory],
105
106
  prompt_caching: h[:prompt_caching],
106
- # params/model_policy (v2, §10): the resolver tolerates string keys from
107
+ # tool_persistence: the one opt-out flag — nil round-trips as nil (= ON);
108
+ # only a stored explicit false turns the discipline block off.
109
+ tool_persistence: h[:tool_persistence],
110
+ tool_output_compression: h[:tool_output_compression],
111
+ # params/model_policy: the resolver tolerates string keys from
107
112
  # the JSON round-trip (ModelResolver#normalize_params / ModelPolicy), so no
108
113
  # re-symbolization needed here.
109
114
  params: h[:params] || {},
110
115
  model_policy: h[:model_policy],
111
- # guardrails (RFC-0009): a plain Hash; Safety::Config tolerates the JSON
116
+ budget: h[:budget],
117
+ reliability: h[:reliability],
118
+ alerts: h[:alerts],
119
+ routes: h[:routes],
120
+ stuck_signal: h[:stuck_signal],
121
+ outputs: h[:outputs],
122
+ briefing_fields: h[:briefing_fields],
123
+ # grounding profile data — a plain Hash read with string keys
124
+ # by Insika::Grounding.parse per turn; nil round-trips as nil (= off).
125
+ grounding: h[:grounding],
126
+ # guardrails: a plain Hash; Safety::Config tolerates the JSON
112
127
  # round-trip (string keys/values), so no re-symbolization here.
113
128
  guardrails: h[:guardrails],
114
- # sandbox (item 35): a plain config Hash; Sandbox.build tolerates the JSON
129
+ # sandbox: a plain config Hash; Sandbox.build tolerates the JSON
115
130
  # round-trip (string keys), so no re-symbolization here. nil = absent.
116
131
  sandbox: h[:sandbox],
117
- # refinement (RFC-0013): a plain config Hash read with string keys by the
132
+ # refinement: a plain config Hash read with string keys by the
118
133
  # RunRefinement handler; nil round-trips as nil (= report-only).
119
134
  refinement: h[:refinement],
120
- # capabilities_declared (RFC-0014 §3.5): flat [String]; build re-normalizes.
135
+ # capabilities_declared: flat [String]; build re-normalizes.
121
136
  capabilities_declared: h[:capabilities_declared],
122
137
  # edge_stream: which internal channels may cross to the customer. {} = neither.
123
138
  edge_stream: h[:edge_stream],
124
- metadata: h[:metadata] || {}
139
+ metadata: h[:metadata] || {},
140
+ # funnel declaration — a plain Hash read with string keys by
141
+ # FunnelDeclaration.parse; nil round-trips as nil (= no funnel).
142
+ funnel: h[:funnel],
143
+ # followup declaration — a plain Hash read with string keys by
144
+ # FollowupPolicy.parse; nil round-trips as nil (= feature off).
145
+ followup: h[:followup],
146
+ # distill declaration — a plain Hash read with string keys by
147
+ # the distiller/engine/doctor; nil round-trips as nil (= feature off).
148
+ distill: h[:distill],
149
+ # harvest declaration — a plain Hash read with string keys
150
+ # by the miner/engine/doctor; nil round-trips as nil (= loop off).
151
+ harvest: h[:harvest]
125
152
  )
126
153
  end
127
154