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,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # the schedule records of the follow-up feature. The store owns
8
+ # the `pending|fired|cancelled|blocked` states (never written by a consumer —
9
+ # the task_store.rb state-machine idiom) and the (customer, reason) scans.
10
+ # It holds no policy (C2) and no contact cells (C3).
11
+ #
12
+ # States:
13
+ # pending -> fired | cancelled | blocked
14
+ # `fired` is set ONLY inside the same transaction that created the synthetic
15
+ # task (D5 — the record and the turn commit together or together fail); a
16
+ # `blocked` record carries `blocked_reason` — auditable, never silent.
17
+ #
18
+ # Record key: "<tenant>:<agent>:<customer>:<at>:#{uuid}" — per-customer /
19
+ # per-agent scans are prefixes, and the scheduled `at` lives in the key so a
20
+ # fired record keeps its scheduled time for the A/B card. Blank tenant -> the
21
+ # literal "platform" (outcome_store.rb's rule — contact, follow-up and
22
+ # outcome keys share one tenant segment so the purge prefix scans line up).
23
+ class FollowupStore
24
+ SCOPE = "followups"
25
+ STATUSES = %w[pending fired cancelled blocked].freeze
26
+
27
+ Record = Data.define(:id, :tenant, :agent, :customer, :session_id, :at,
28
+ :reason, :arm, :status, :task_id, :blocked_reason,
29
+ :transport, :created_at, :updated_at, :fired_at)
30
+
31
+ def initialize(store:)
32
+ @store = store
33
+ end
34
+
35
+ # -> Record (status :pending). `at` must be a future ISO8601 (a Time or an
36
+ # ISO8601 string — ValidationError otherwise, "the follow-up would already
37
+ # be due"). The caller decided the arm (C1) and the transport (C7).
38
+ def create(tenant:, agent:, customer:, session_id:, at:, reason:, arm:,
39
+ transport: nil, id: SecureRandom.uuid, now: Time.now.utc)
40
+ time = parse_at(at)
41
+ if time <= now
42
+ raise Insika::ValidationError,
43
+ "follow-up #{id.inspect} would already be due (at #{time.iso8601} <= now)"
44
+ end
45
+ if (prior = pending_for(tenant: tenant, agent: agent, customer: customer, reason: reason))
46
+ raise Insika::ValidationError,
47
+ "a follow-up for (customer #{customer.inspect}, reason #{reason.inspect}) is already " \
48
+ "pending: #{prior.id}"
49
+ end
50
+
51
+ time = time.utc.iso8601
52
+ record = { "id" => id.to_s, "tenant" => tenant_id(tenant), "agent" => agent.to_s,
53
+ "customer" => customer.to_s, "session_id" => session_id.to_s,
54
+ "at" => time, "reason" => reason.to_s, "arm" => arm.to_s,
55
+ "status" => "pending", "task_id" => nil, "blocked_reason" => nil,
56
+ "transport" => transport.to_s, "created_at" => now.iso8601,
57
+ "updated_at" => now.iso8601, "fired_at" => nil }
58
+ @store.set(SCOPE, key_for(record), record)
59
+ to_record(record)
60
+ end
61
+
62
+ # -> Record; NotFoundError on a nonexistent id. The only path out of
63
+ # pending besides the engine's fired/blocked. Idempotent: an
64
+ # already-cancelled record returns as-is (a repeat of the same call is not
65
+ # an error).
66
+ def cancel(id:, now: Time.now.utc)
67
+ mutate(id, now) do |record|
68
+ status = record["status"]
69
+ unless %w[pending cancelled].include?(status)
70
+ raise ArgumentError, "follow-up #{id}: cannot cancel a #{status} record"
71
+ end
72
+
73
+ record["status"] = "cancelled"
74
+ end
75
+ end
76
+
77
+ # The engine's atomic claim: pending -> fired, WITH task_id. Read-check-
78
+ # write inside Store#transaction (D5 — the record and the task commit
79
+ # together). A second claim raises. `fired_at` stamps WHEN the fire
80
+ # happened — the frequency ceiling and the A/B card count FIRES, never the
81
+ # scheduled time (a record booked days ago and fired after a tick outage
82
+ # must still count against the cap).
83
+ def transition_fired(id:, task_id:, now: Time.now.utc)
84
+ mutate(id, now) do |record|
85
+ raise ArgumentError, "follow-up #{id}: not pending (#{record['status']}) — it fires once" unless record["status"] == "pending"
86
+
87
+ record["status"] = "fired"
88
+ record["task_id"] = task_id.to_s
89
+ record["fired_at"] = now.utc.iso8601
90
+ end
91
+ end
92
+
93
+ # pending -> blocked, with the failing rule name. -> Record
94
+ def block(id:, reason:, now: Time.now.utc)
95
+ mutate(id, now) do |record|
96
+ raise ArgumentError, "follow-up #{id}: not pending (#{record['status']}) — only a pending record blocks" unless record["status"] == "pending"
97
+
98
+ record["status"] = "blocked"
99
+ record["blocked_reason"] = reason.to_s
100
+ end
101
+ end
102
+
103
+ # -> Record | nil
104
+ def find(id)
105
+ record = @store.get(SCOPE, key_for_id(id))
106
+ record && to_record(record)
107
+ end
108
+
109
+ # pending AND at <= now, oldest first (at, then id — determinism).
110
+ def due(now: Time.now.utc)
111
+ cutoff = now.iso8601
112
+ @store.list(SCOPE).filter_map do |k|
113
+ record = @store.get(SCOPE, k)
114
+ next unless record && record["status"] == "pending" && record["at"].to_s <= cutoff
115
+
116
+ to_record(record)
117
+ end.sort_by { |r| [r.at.to_s, r.id] }
118
+ end
119
+
120
+ # ALL records of one (tenant, agent) — the Follow-ups page's read (C10),
121
+ # lexicographic (the scheduled at first, so a fired record keeps its
122
+ # position for the A/B card).
123
+ def for_agent(tenant:, agent:)
124
+ prefix = "#{tenant_id(tenant)}:#{agent}:"
125
+ @store.list(SCOPE).filter_map do |k|
126
+ next unless k.start_with?(prefix)
127
+
128
+ to_record(@store.get(SCOPE, k))
129
+ end
130
+ end
131
+
132
+ # The dedup scans (D7): the OLDEST pending record of the pair (nil when
133
+ # none) and how many fired records of the customer fall inside the window
134
+ # (the frequency gate). `pending_for` scans the pair's keys in key order —
135
+ # the key embeds the scheduled `at` and the id, so the first pending record
136
+ # found IS the oldest pending.
137
+ def pending_for(tenant:, agent:, customer:, reason:)
138
+ pending_record_for(tenant: tenant, agent: agent, customer: customer, reason: reason)
139
+ end
140
+
141
+ def pending_for?(tenant:, agent:, customer:, reason:)
142
+ !pending_record_for(tenant: tenant, agent: agent, customer: customer, reason: reason).nil?
143
+ end
144
+
145
+ def fired_in_window(tenant:, customer:, since:)
146
+ boundary = since.iso8601
147
+ count = 0
148
+ @store.list(SCOPE).each do |k|
149
+ record = @store.get(SCOPE, k)
150
+ next unless record
151
+ next unless record["status"] == "fired"
152
+ next unless record["tenant"] == tenant_id(tenant)
153
+ next unless record["customer"] == customer.to_s
154
+ # the frequency gate counts FIRES, never the scheduled time — a record
155
+ # booked long ago and fired after a backlog still lands in the window.
156
+ next unless record["fired_at"].to_s >= boundary
157
+
158
+ count += 1
159
+ end
160
+ count
161
+ end
162
+
163
+ # Purge/prune (C11 — the LGPD footprint): one customer's records; a
164
+ # tenant's; records older than the cutoff — TERMINAL (fired/cancelled/
165
+ # blocked) OR pendings past their at (a zombie that will never fire;
166
+ # retention ages it out rather than firing late). All count-returning and
167
+ # nil-safe.
168
+
169
+ # cancels every PENDING record of the customer inside ONE
170
+ # transaction — the opt-out discipline (D2: a half-cancelled opt-out is
171
+ # the spam bug). Blocked/fired records are never touched. -> count
172
+ # cancelled.
173
+ def cancel_pending_for(tenant:, customer:)
174
+ count = 0
175
+ @store.transaction do
176
+ @store.list(SCOPE).each do |k|
177
+ record = @store.get(SCOPE, k)
178
+ next unless record && record["status"] == "pending"
179
+ next unless record["tenant"] == tenant_id(tenant)
180
+ next unless record["customer"] == customer.to_s
181
+
182
+ record["status"] = "cancelled"
183
+ record["updated_at"] = Time.now.utc.iso8601
184
+ @store.set(SCOPE, k, record)
185
+ count += 1
186
+ end
187
+ end
188
+ count
189
+ end
190
+
191
+ def purge_customer(tenant:, customer:)
192
+ removed = 0
193
+ @store.list(SCOPE).each do |k|
194
+ record = @store.get(SCOPE, k)
195
+ next unless record && record["tenant"] == tenant_id(tenant)
196
+ next unless record["customer"] == customer.to_s
197
+
198
+ @store.delete(SCOPE, k)
199
+ removed += 1
200
+ end
201
+ removed
202
+ end
203
+
204
+ def purge(tenant:)
205
+ prefix = "#{tenant_id(tenant)}:"
206
+ keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
207
+ keys.each { |k| @store.delete(SCOPE, k) }
208
+ keys.size
209
+ end
210
+
211
+ # -> count removed.
212
+ def delete_older_than(time)
213
+ cutoff = time.utc.iso8601
214
+ removed = 0
215
+ @store.list(SCOPE).each do |k|
216
+ record = @store.get(SCOPE, k)
217
+ next unless record
218
+
219
+ terminal = record["status"] != "pending"
220
+ # TERMINAL records age by their updated_at; a PENDING record is a
221
+ # zombie when its scheduled `at` has passed (it will never fire —
222
+ # retention ages it out rather than firing late).
223
+ next unless (terminal && record["updated_at"].to_s < cutoff) ||
224
+ (!terminal && record["at"].to_s < cutoff)
225
+
226
+ @store.delete(SCOPE, k)
227
+ removed += 1
228
+ end
229
+ removed
230
+ end
231
+
232
+ private
233
+
234
+ # -> Record | nil — the OLDEST pending record of the (agent, customer,
235
+ # reason) pair, if any (the dedup rule holds at creation AND at fire: the
236
+ # firer only fires the oldest pending per pair).
237
+ def pending_record_for(tenant:, agent:, customer:, reason:)
238
+ prefix = "#{tenant_id(tenant)}:#{agent}:"
239
+ @store.list(SCOPE).each do |k|
240
+ next unless k.start_with?(prefix)
241
+
242
+ record = @store.get(SCOPE, k)
243
+ next unless record && record["status"] == "pending"
244
+ next unless record["customer"] == customer.to_s
245
+ next unless record["reason"] == reason.to_s
246
+
247
+ return to_record(record)
248
+ end
249
+ nil
250
+ end
251
+
252
+ def mutate(id, now)
253
+ @store.transaction do
254
+ record = @store.get(SCOPE, key_for_id(id))
255
+ raise Insika::NotFoundError, "follow-up not found: #{id}" if record.nil?
256
+
257
+ yield record
258
+ record["updated_at"] = now.utc.iso8601
259
+ @store.set(SCOPE, key_for_id(id), record)
260
+ to_record(record)
261
+ end
262
+ end
263
+
264
+ def key_for(record)
265
+ # tenant + agent + customer + the scheduled at + id: per-customer /
266
+ # per-agent scans are prefixes, the at-first ordering keeps a fired
267
+ # record at its scheduled position (the A/B card's lexicographic list).
268
+ "#{record['tenant']}:#{record['agent']}:#{record['customer']}:#{record['at']}:#{record['id']}"
269
+ end
270
+
271
+ def key_for_id(id)
272
+ # the id is the LAST segment, so a find is a suffix match over the
273
+ # scope's keys (there is no stable prefix for an id alone).
274
+ @store.list(SCOPE).find { |k| k.end_with?(":#{id}") }
275
+ end
276
+
277
+ def parse_at(at)
278
+ case at
279
+ when Time then at.utc
280
+ else
281
+ begin
282
+ Time.iso8601(at.to_s).utc
283
+ rescue ArgumentError
284
+ raise Insika::ValidationError, "follow-up `at` must be an ISO8601 time, got: #{at.inspect}"
285
+ end
286
+ end
287
+ end
288
+
289
+ def tenant_id(tenant)
290
+ t = tenant.to_s
291
+ t.empty? ? "platform" : t
292
+ end
293
+
294
+ def to_record(rec)
295
+ return nil if rec.nil?
296
+
297
+ Record.new(id: rec["id"], tenant: rec["tenant"], agent: rec["agent"],
298
+ customer: rec["customer"], session_id: rec["session_id"],
299
+ at: rec["at"], reason: rec["reason"], arm: rec["arm"],
300
+ status: rec["status"], task_id: rec["task_id"],
301
+ blocked_reason: rec["blocked_reason"], transport: rec["transport"],
302
+ created_at: rec["created_at"], updated_at: rec["updated_at"],
303
+ fired_at: rec["fired_at"])
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # the parsed funnel declaration of ONE agent — the ONLY shape
5
+ # the engine accepts, shared by the fold, the doctor, the Studio and the
6
+ # freeze command. Pure value object: it never touches a store, and the engine
7
+ # never hard-codes a stage name (D1) — `stages`/`primary`/`advance_on` are the
8
+ # forge's vocabulary, carried as data.
9
+ #
10
+ # `parse` returns nil on a malformed hash (D8: the fold skips that agent, the
11
+ # doctor explains the exact defect); `parse!` raises Insika::ValidationError
12
+ # naming the field. `attribution_window` is carried data, never computed (D4).
13
+ class FunnelDeclaration
14
+ WINDOW_RE = /\A\d+h\z/
15
+
16
+ attr_reader :stages, :advance_on, :primary, :attribution_window
17
+
18
+ def self.parse(hash)
19
+ new(hash)
20
+ rescue Insika::ValidationError
21
+ nil
22
+ end
23
+
24
+ def self.parse!(hash)
25
+ new(hash)
26
+ end
27
+
28
+ def initialize(hash)
29
+ raise Insika::ValidationError, "funnel: declaration must be a Hash" unless hash.is_a?(Hash)
30
+
31
+ h = hash.transform_keys(&:to_s)
32
+ @stages = stages_of(h)
33
+ @advance_on = advance_on_of(h, @stages)
34
+ @primary = primary_of(h, @stages)
35
+ @attribution_window = window_of(h)
36
+ freeze
37
+ end
38
+
39
+ # position of a stage in the declared order. -> Integer | nil
40
+ def index_of(stage)
41
+ index = @stages.index(stage.to_s)
42
+ index.nil? ? nil : index
43
+ end
44
+
45
+ # the first declared stage — the funnel's denominator.
46
+ def first_stage = @stages.first
47
+
48
+ # "72h" -> 72 (Integer). Always valid once parsed.
49
+ def window_hours = @attribution_window.to_i
50
+
51
+ # Value-object equality: two declarations parsed from equal input (symbol
52
+ # or string keys) are the same declaration.
53
+ def ==(other)
54
+ other.is_a?(FunnelDeclaration) &&
55
+ @stages == other.stages && @advance_on == other.advance_on &&
56
+ @primary == other.primary && @attribution_window == other.attribution_window
57
+ end
58
+ alias eql? ==
59
+
60
+ def hash
61
+ [@stages, @advance_on, @primary, @attribution_window].hash
62
+ end
63
+
64
+ private
65
+
66
+ def stages_of(hash)
67
+ list = hash["stages"]
68
+ raise Insika::ValidationError, "funnel.stages: must be a non-empty Array of non-blank Strings" unless valid_stages?(list)
69
+
70
+ list
71
+ end
72
+
73
+ def valid_stages?(list)
74
+ list.is_a?(Array) && !list.empty? && list.all? { |s| s.is_a?(String) && !s.strip.empty? } &&
75
+ list.uniq.length == list.length
76
+ end
77
+
78
+ def advance_on_of(hash, stages)
79
+ map = hash["advance_on"]
80
+ raise Insika::ValidationError, "funnel.advance_on: must be a non-empty Hash of kind => stage" unless map.is_a?(Hash) && !map.empty?
81
+
82
+ map = map.transform_keys(&:to_s)
83
+ unless map.all? { |kind, stage| !kind.strip.empty? && stage.is_a?(String) && stages.include?(stage) }
84
+ raise Insika::ValidationError, "funnel.advance_on: every value must be one of the declared stages"
85
+ end
86
+
87
+ map
88
+ end
89
+
90
+ def primary_of(hash, stages)
91
+ primary = hash["primary"]
92
+ raise Insika::ValidationError, "funnel.primary: must be one of the declared stages" unless primary.is_a?(String) && stages.include?(primary)
93
+
94
+ primary
95
+ end
96
+
97
+ def window_of(hash)
98
+ window = hash["attribution_window"]
99
+ unless window.is_a?(String) && WINDOW_RE.match?(window)
100
+ raise Insika::ValidationError, "funnel.attribution_window: must match /\\A\\d+h\\z/ (e.g. \"72h\")"
101
+ end
102
+
103
+ window
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "securerandom"
5
+
6
+ module Insika
7
+ # the tick-driven fold of WS7 outcome records into the
8
+ # pack-declared stages (the tick's cadence). One pass per claim
9
+ # window; the fold is idempotent across crashes via the per-pair {at, ids}
10
+ # cursor (D3) and per-pair transactions. Pairs whose declaration is absent or
11
+ # malformed are skipped (D8), as are outcome kinds the declaration does not
12
+ # map — the funnel shows the hole. Nothing else: no attribution
13
+ # (D4), no stage vocabulary of its own (D1).
14
+ class FunnelFold
15
+ SCOPE = "funnel_fold"
16
+ KEY = "claim"
17
+ DEFAULT_WINDOW = 300 # seconds; one folding worker per window
18
+
19
+ def initialize(outcome_store:, funnel_store:, profiles:, store:,
20
+ window: DEFAULT_WINDOW, now: nil)
21
+ @outcome_store = outcome_store
22
+ @funnel_store = funnel_store
23
+ @profiles = profiles
24
+ @store = store
25
+ @window = window
26
+ @now = now
27
+ end
28
+
29
+ # One pass. -> { claimed: true, folded: Integer, skipped: Integer, pairs: Integer }
30
+ # | { claimed: false } (another worker holds the window — the
31
+ # retention.rb:74 shape, so the tick summary reads uniformly)
32
+ def run
33
+ return { claimed: false } unless claim
34
+
35
+ folded = 0
36
+ skipped = 0
37
+ pairs = 0
38
+ # The pairs come from the OUTCOME keys (prefix scan, no record reads);
39
+ # each pair's records are read by `for_pair`, so a pair without a
40
+ # declaration is never even read (a full `all` would read every record
41
+ # of every pair — the O(n) the key shape is there to avoid).
42
+ @outcome_store.pairs.each do |pair|
43
+ begin
44
+ declaration = declaration_for(pair[:agent])
45
+ next unless declaration
46
+
47
+ folded += fold_pair(tenant: pair[:tenant], agent: pair[:agent],
48
+ declaration: declaration, skipped: -> { skipped += 1 })
49
+ pairs += 1
50
+ rescue StandardError
51
+ # a broken pair must not hold every other store's funnel hostage —
52
+ # its own transaction already rolled back; the pass keeps folding.
53
+ next
54
+ end
55
+ end
56
+ { claimed: true, folded: folded, skipped: skipped, pairs: pairs }
57
+ end
58
+
59
+ # From scratch (E1's "recompute matches the incremental fold", and the
60
+ # repair path for backfilled outcomes): wipes the pair's day cells and
61
+ # rebuilds them from every outcome record. `tenant:` takes the store's
62
+ # spellings alike (nil/""/"platform" = the no-tenant pair) — `for_pair` and
63
+ # `delete_days` normalize at their own key boundaries, so no tenant is
64
+ # ever mixed with another (a "platform" recompute never folds "acme"'s
65
+ # records). -> folded count.
66
+ def recompute(tenant:, agent:, declaration:)
67
+ @funnel_store.delete_days(tenant: tenant, agent: agent)
68
+ fold_pair(tenant: tenant, agent: agent, declaration: declaration, full: true)
69
+ end
70
+
71
+ private
72
+
73
+ # -> FunnelDeclaration | nil — absent or malformed declarations read as
74
+ # "nothing to fold" (D8); never raises.
75
+ def declaration_for(agent)
76
+ profile = @profiles.fetch(agent)
77
+ return nil unless profile
78
+
79
+ Insika::FunnelDeclaration.parse(profile.funnel)
80
+ end
81
+
82
+ # The fold for one pair: cursor-filtered, ONE transaction (all adds + the
83
+ # cursor write — D3: a crash leaves either the old or the new state, never
84
+ # a half-fold). `full: true` (recompute) ignores the cursor — every record
85
+ # folds and the cursor rewrites from the newest. Records are read by key
86
+ # prefix, optionally skipping the keys older than the cursor's day without
87
+ # reading them. -> folded count.
88
+ def fold_pair(tenant:, agent:, declaration:, records: nil, skipped: nil, full: false)
89
+ cursor = @funnel_store.cursor(tenant: tenant, agent: agent)
90
+ # The cursor's DAY is the prefix bound: records on that day may still be
91
+ # newer than the cursor (boundary handling below); anything older is
92
+ # already folded. `full` reads the whole pair.
93
+ records ||= @outcome_store.for_pair(tenant: tenant, agent: agent,
94
+ since_date: full ? nil : cursor["at"]&.[](0, 10))
95
+ new_records = records.select { |r| full || new_record?(r, cursor) }
96
+ .sort_by { |r| [r.at.to_s, r.id] }
97
+ return 0 if new_records.empty? && !full
98
+ if new_records.empty?
99
+ # recompute of an emptied pair: nothing to fold, but the stale cursor
100
+ # must not outlive its records (a later backfill would be invisible).
101
+ @funnel_store.set_cursor(tenant: tenant, agent: agent, at: nil, ids: [])
102
+ return 0
103
+ end
104
+
105
+ folded = 0
106
+ # Grouped by DAY (the cell key), never by the full timestamp: N events on
107
+ # one day must be ONE read-modify-write of the day cell, not N.
108
+ counts_by_day = Hash.new { |h, k| h[k] = Hash.new(0) }
109
+ at_by_day = {}
110
+ newest_at = full ? nil : cursor["at"]
111
+ ids_at_newest = []
112
+
113
+ new_records.each do |record|
114
+ # The cursor advances over SKIPPED records too (they are not new any
115
+ # more): a pair whose integration only emits unmapped kinds would
116
+ # otherwise re-read the same records every window forever.
117
+ if newest_at.nil? || record.at.to_s > newest_at.to_s
118
+ newest_at = record.at.to_s
119
+ ids_at_newest = [record.id]
120
+ elsif record.at.to_s == newest_at.to_s
121
+ ids_at_newest << record.id
122
+ end
123
+
124
+ index = declaration.index_of(declaration.advance_on[record.outcome])
125
+ if index.nil?
126
+ skipped&.call
127
+ next
128
+ end
129
+
130
+ day = record.at.to_s[0, 10] # the record's at is always UTC ISO8601
131
+ declaration.stages[0..index].each { |stage| counts_by_day[day][stage] += 1 }
132
+ at_by_day[day] ||= record.at.to_s
133
+ folded += 1
134
+ end
135
+
136
+ @store.transaction do
137
+ counts_by_day.each do |day, counts|
138
+ @funnel_store.add(tenant: tenant, agent: agent, at: at_by_day[day], counts: counts)
139
+ end
140
+ @funnel_store.set_cursor(tenant: tenant, agent: agent,
141
+ at: newest_at, ids: ids_at_newest)
142
+ end
143
+ folded
144
+ end
145
+
146
+ # `at > cursor.at` or boundary-equal and not already folded (D3). The at
147
+ # field is ISO8601 UTC, so the comparison is lexicographic; a record with
148
+ # `at` older than the cursor (a backfill) is missed incrementally —
149
+ # `recompute` is the repair path.
150
+ def new_record?(record, cursor)
151
+ at = cursor["at"]
152
+ return true if at.nil?
153
+ return true if record.at.to_s > at.to_s
154
+
155
+ record.at.to_s == at.to_s && !cursor["ids"].include?(record.id)
156
+ end
157
+
158
+ # The claim window (tick.rb:93's idiom — read-check-write on one key inside
159
+ # a transaction): the scan is O(outcome records), fine at 288 passes/day,
160
+ # but it does not ride every 60 s tick.
161
+ def claim
162
+ now_time = @now || Time.now.utc
163
+ @store.transaction do
164
+ current = @store.get(SCOPE, KEY)
165
+ last = current && begin
166
+ Time.iso8601(current["claimed_at"].to_s)
167
+ rescue ArgumentError
168
+ nil # a corrupted claim is not a claim — take the window
169
+ end
170
+ if last.nil? || (now_time - last) >= @window
171
+ @store.set(SCOPE, KEY, { "claimed_at" => now_time.iso8601 })
172
+ true
173
+ else
174
+ false
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end