insika 0.2.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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
@@ -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
@@ -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
@@ -104,6 +104,9 @@ module Insika
104
104
  tools_deferred: h[:tools_deferred],
105
105
  memory: h[:memory],
106
106
  prompt_caching: h[:prompt_caching],
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],
107
110
  tool_output_compression: h[:tool_output_compression],
108
111
  # params/model_policy: the resolver tolerates string keys from
109
112
  # the JSON round-trip (ModelResolver#normalize_params / ModelPolicy), so no
@@ -113,7 +116,13 @@ module Insika
113
116
  budget: h[:budget],
114
117
  reliability: h[:reliability],
115
118
  alerts: h[:alerts],
119
+ routes: h[:routes],
116
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],
117
126
  # guardrails: a plain Hash; Safety::Config tolerates the JSON
118
127
  # round-trip (string keys/values), so no re-symbolization here.
119
128
  guardrails: h[:guardrails],
@@ -127,7 +136,19 @@ module Insika
127
136
  capabilities_declared: h[:capabilities_declared],
128
137
  # edge_stream: which internal channels may cross to the customer. {} = neither.
129
138
  edge_stream: h[:edge_stream],
130
- 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]
131
152
  )
132
153
  end
133
154