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,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Insika
7
+ module Soak
8
+ # The mechanical fold: a results file plus its
9
+ # frozen envelope becomes ONE verdict and the numbers behind it. Pure — no
10
+ # clock, no network, no store, no randomness. This is where "pass/fail is
11
+ # mechanical" lives: two people folding the same bytes get the same verdict.
12
+ module Report
13
+ VERDICTS = %i[pass fail insufficient invalid].freeze
14
+
15
+ # The verdict plus its evidence. `reasons` name every breach; for a
16
+ # `fail` the report-only metrics point the leak hunt. `:invalid` carries
17
+ # NO metrics at all — a tampered file must not produce rates.
18
+ Result = Data.define(:verdict, :reasons, :metrics, :envelope_sha, :run_id) do
19
+ def pass? = verdict == :pass
20
+
21
+ def to_h
22
+ {
23
+ "verdict" => verdict.to_s, "reasons" => reasons,
24
+ "metrics" => metrics.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v },
25
+ "envelope_sha" => envelope_sha, "run_id" => run_id
26
+ }
27
+ end
28
+
29
+ # Human report (Doctor::Report idiom).
30
+ def to_s(color: false)
31
+ head = "SOAK #{run_id} — #{verdict.to_s.upcase}"
32
+ head += " [#{envelope_sha}]" unless envelope_sha.nil?
33
+ lines = [head]
34
+ unless reasons.empty?
35
+ lines << " breaches:"
36
+ reasons.each { |r| lines << " - #{r}" }
37
+ end
38
+ lines.concat(metric_lines) if verdict != :invalid
39
+ lines << leak_hunt if verdict == :fail && leak_hunt
40
+ lines.join("\n")
41
+ end
42
+
43
+ private
44
+
45
+ def f(key, default = "-") = metrics.key?(key) ? metrics[key] : default
46
+
47
+ def metric_lines
48
+ [
49
+ " turns=#{f(:turns)} snapshots=#{f(:snapshots)} restarts=#{f(:restarts)} " \
50
+ "error_rate=#{f(:error_rate)} no_usage_rate=#{f(:no_usage_rate)}",
51
+ " rss: slope=#{f(:rss_slope_mb_per_day)} MB/day growth_ratio=#{f(:rss_growth_ratio)} " \
52
+ "peak=#{f(:rss_peak_mb)} MB",
53
+ " prep p95: #{f(:prep_p95_ms)} ms (first #{f(:prep_p95_first_ms)} / last #{f(:prep_p95_last_ms)}, " \
54
+ "drift #{f(:prep_p95_drift_ratio)})",
55
+ " total p95: #{f(:total_p95_ms)} ms ttft p95: #{f(:ttft_p95_ms)} ms",
56
+ " coverage=#{f(:coverage_ratio)} max_gap=#{f(:max_gap_s)}s thin_hours=#{f(:thin_hours)}"
57
+ ]
58
+ end
59
+
60
+ def leak_hunt
61
+ return nil unless metrics[:rss_growth_ratio].to_f > 1.0
62
+
63
+ heap_grew = (metrics[:heap_growth_ratio].to_f > 1.0)
64
+ if heap_grew
65
+ "leak hunt: the Ruby heap grew with RSS (heap_growth_ratio=#{metrics[:heap_growth_ratio]}) — " \
66
+ "Ruby-side retention; start with retained objects and per-turn allocations."
67
+ else
68
+ "leak hunt: RSS climbed while the Ruby heap stayed flat — allocator/fragmentation " \
69
+ "or a native buffer; start outside the Ruby heap."
70
+ end
71
+ end
72
+ end
73
+
74
+ module_function
75
+
76
+ # `records` is any Enumerable of lines — parsed Hashes AND raw strings
77
+ # (lines that failed to parse travel through unparsed so the fold can
78
+ # count them). The file reader stays separate so the fold is testable on
79
+ # literals.
80
+ def fold(records, envelope:)
81
+ raise ArgumentError, "records must be an Enumerable" unless records.respond_to?(:each)
82
+
83
+ items = records.to_a
84
+ return insufficient(envelope, nil, "no records", {}) if items.empty?
85
+
86
+ parsed = items.select { |l| l.is_a?(Hash) }
87
+ malformed = items.length - parsed.length
88
+ header = parsed.find { |l| l["t"] == "header" }
89
+ return invalid(envelope, nil, "missing header record", {}) if header.nil?
90
+
91
+ run_id = header["run_id"]
92
+ header_sha = header["envelope_sha"]
93
+ return invalid(envelope, run_id, "header envelope_sha #{header_sha.inspect} does not match #{envelope.sha.inspect}", {}) if header_sha != envelope.sha
94
+
95
+ started = parse_time(header["started_at"])
96
+ duration = envelope[:duration_hours]
97
+
98
+ if !envelope.dry_run? && !envelope.calibrated?
99
+ return invalid(envelope, run_id, "the run is not calibrated (write the E1 values into the envelope first)", {})
100
+ end
101
+
102
+ if malformed.positive? && malformed.to_f / items.length > 0.005
103
+ return invalid(envelope, run_id, "malformed lines: #{malformed} of #{items.length}", {})
104
+ end
105
+
106
+ snapshots = parsed.select { |l| l["t"] == "snapshot" }
107
+ offender = snapshots.find { |s| s["envelope_sha"] != header_sha }
108
+ unless offender.nil?
109
+ return invalid(envelope, run_id,
110
+ "snapshot at hour #{offender['hour']} was stamped with a different envelope hash", {})
111
+ end
112
+
113
+ concurrent = concurrent_pids(snapshots)
114
+ if !concurrent.nil? && envelope[:web_concurrency].to_i == 1
115
+ return invalid(envelope, run_id,
116
+ "more than one pid inside boot_id #{concurrent.inspect} while web_concurrency == 1 " \
117
+ "(the RSS series is then not one process)", {})
118
+ end
119
+
120
+ turns = parsed.select { |l| l["t"] == "turn" }
121
+ gaps = parsed.select { |l| l["t"] == "gap" }
122
+ end_rec = parsed.find { |l| l["t"] == "end" }
123
+ end_ok = end_rec && end_rec["reason"] == "complete"
124
+ unless end_ok
125
+ reason = end_rec ? end_rec["reason"] : "the runner never wrote an end record"
126
+ return insufficient(envelope, run_id, "the run did not complete (end reason: #{reason})", {})
127
+ end
128
+
129
+ metrics = compute_metrics(turns, snapshots, gaps, header, started, duration, envelope)
130
+ missing = missing_coverage(envelope, metrics)
131
+ return insufficient(envelope, run_id, missing, metrics) if missing
132
+
133
+ breaches = breaches(envelope, metrics)
134
+ if breaches.empty?
135
+ Result.new(verdict: :pass, reasons: [], metrics: metrics,
136
+ envelope_sha: envelope.sha, run_id: run_id)
137
+ else
138
+ Result.new(verdict: :fail, reasons: breaches, metrics: metrics,
139
+ envelope_sha: envelope.sha, run_id: run_id)
140
+ end
141
+ end
142
+
143
+ # Ordinary least squares on [[hour, value]] -> { slope:, intercept:, se:,
144
+ # upper_95: } where upper_95 = slope + 1.645 * se (one-sided). nil below
145
+ # two points.
146
+ def trend(points)
147
+ return nil if points.nil? || points.length < 2
148
+
149
+ n = points.length
150
+ xs = points.map { |x, _| x.to_f }
151
+ ys = points.map { |_, y| y.to_f }
152
+ xbar = xs.sum / n
153
+ ybar = ys.sum / n
154
+ sxx = xs.sum { |x| (x - xbar)**2 }
155
+ sxy = xs.zip(ys).sum { |x, y| (x - xbar) * (y - ybar) }
156
+ slope = sxx.zero? ? 0.0 : sxy / sxx
157
+ intercept = ybar - slope * xbar
158
+ sse = xs.zip(ys).sum { |x, y| (y - (intercept + slope * x))**2 }
159
+ se = sxx.zero? || n <= 2 ? 0.0 : Math.sqrt(sse / (n - 2) / sxx)
160
+ { slope: slope, intercept: intercept, se: se, upper_95: slope + 1.645 * se }
161
+ end
162
+
163
+ # p-th percentile of a sorted array, linear interpolation — the SAME
164
+ # formula as loadtest.rb#pct (including its 1-decimal rounding), so soak
165
+ # and load-test numbers are comparable.
166
+ def pct(sorted, p)
167
+ return 0.0 if sorted.nil? || sorted.empty?
168
+
169
+ r = (p / 100.0) * (sorted.length - 1)
170
+ lo = sorted[r.floor]
171
+ hi = sorted[r.ceil]
172
+ (lo + (hi - lo) * (r - r.floor)).round(1)
173
+ end
174
+
175
+ # -- internals -----------------------------------------------------
176
+
177
+ MB = 1_048_576.0
178
+ private_constant :MB
179
+
180
+ def parse_time(iso)
181
+ Time.parse(iso.to_s)
182
+ rescue ArgumentError
183
+ nil
184
+ end
185
+
186
+ def invalid(envelope, run_id, reason, metrics)
187
+ Result.new(verdict: :invalid, reasons: [reason], metrics: metrics,
188
+ envelope_sha: envelope&.sha, run_id: run_id)
189
+ end
190
+
191
+ def insufficient(envelope, run_id, reason, metrics)
192
+ Result.new(verdict: :insufficient, reasons: [reason], metrics: metrics,
193
+ envelope_sha: envelope&.sha, run_id: run_id)
194
+ end
195
+
196
+ # A boot_id whose snapshots show two DIFFERENT pids at the SAME hour:
197
+ # concurrent workers answering the sampler. A pid CHANGE across hours is a
198
+ # respawn — evidence for the restarts gate, not for this one.
199
+ def concurrent_pids(snapshots)
200
+ by_hour = snapshots.each_with_object({}) do |s, acc|
201
+ next unless s["vitals"].is_a?(Hash)
202
+
203
+ key = [s["vitals"]["boot_id"], s["hour"]]
204
+ (acc[key] ||= []) << s["vitals"]["pid"]
205
+ end
206
+ by_hour.each { |key, pids| return key[0] if pids.uniq.length > 1 }
207
+ nil
208
+ end
209
+
210
+ def hour_of(turn, started)
211
+ at = parse_time(turn["at"])
212
+ return nil if at.nil? || started.nil?
213
+
214
+ [((at - started) / 3600).floor, 0].max
215
+ end
216
+
217
+ def compute_metrics(turns, snapshots, gaps, header, started, duration, envelope)
218
+ warmup = envelope[:warmup_hours]
219
+ by_hour = Hash.new { |h, k| h[k] = [] }
220
+ turns.each do |t|
221
+ h = hour_of(t, started)
222
+ by_hour[h] << t if h
223
+ end
224
+
225
+ usable = snapshots.select do |s|
226
+ s["vitals"].is_a?(Hash) && !s.dig("vitals", "rss_bytes").nil? && s["error"].nil?
227
+ end
228
+
229
+ restarts, restart_events = count_restarts(snapshots)
230
+
231
+ rss_fit = trend(usable.select { |s| s["hour"] > warmup }.map { |s| [s["hour"].to_f, s["vitals"]["rss_bytes"].to_f] })
232
+ rss_ratio = rss_fit && rss_fit[:intercept].positive? ? growth_ratio(rss_fit, warmup, usable.map { |s| s["hour"] }.max) : nil
233
+
234
+ heap_fit = trend(usable.filter_map { |s|
235
+ live = s.dig("vitals", "gc", "heap_live_slots")
236
+ live && [s["hour"].to_f, live.to_f]
237
+ })
238
+ heap_ratio = heap_fit && heap_fit[:intercept].positive? ? growth_ratio(heap_fit, warmup, usable.map { |s| s["hour"] }.max) : nil
239
+
240
+ db_fit = trend(usable.filter_map { |s|
241
+ bytes = s.dig("vitals", "db_bytes", "db")
242
+ bytes && [s["hour"].to_f, bytes.to_f]
243
+ })
244
+
245
+ prep = turns.filter_map { |t| t.dig("timing", "prep_ms") }
246
+ totals = turns.filter_map { |t| t["total_ms"] }
247
+ ttfts = turns.filter_map { |t| t.dig("timing", "ttft_ms") }
248
+
249
+ # Drift windows: (warmup+1..warmup+6) vs (duration-6..duration-1). A run
250
+ # shorter than ~12h cannot hold two non-overlapping 6h windows: the last
251
+ # window is then empty (drift is reported as "-", never gated) and the
252
+ # first window runs to the end — it never reaches back into the warmup.
253
+ first_lo, first_hi = warmup + 1, warmup + 6
254
+ last_lo, last_hi = duration - 6, duration - 1
255
+ if last_lo <= first_hi
256
+ first_hi = last_hi
257
+ last_lo = last_hi + 1
258
+ end
259
+ first_window = (first_lo..first_hi).flat_map { |h| by_hour[h] }.filter_map { |t| t.dig("timing", "prep_ms") }
260
+ last_window = (last_lo..last_hi).flat_map { |h| by_hour[h] }.filter_map { |t| t.dig("timing", "prep_ms") }
261
+ first_p95 = pct(first_window.sort, 95)
262
+ last_p95 = pct(last_window.sort, 95)
263
+
264
+ ok_turns = turns.count { |t| t["ok"] == true }
265
+ no_usage = turns.count { |t| t["ok"] == true && !t["usage"].is_a?(Hash) }
266
+
267
+ gap_s = [gaps.map { |g| g["seconds"].to_f }, inter_turn_gaps(turns)].flatten.max
268
+
269
+ covered = usable.map { |s| s["hour"] }.uniq.length
270
+ thin = (0...duration).count { |h| by_hour[h].length < envelope[:hourly_turn_floor] }
271
+
272
+ {
273
+ turns: turns.length,
274
+ snapshots: snapshots.length,
275
+ restarts: restarts,
276
+ restart_events: restart_events,
277
+ error_rate: turns.empty? ? 0.0 : (turns.length - ok_turns).to_f / turns.length,
278
+ no_usage_rate: ok_turns.zero? ? 0.0 : no_usage.to_f / ok_turns,
279
+ rss_slope_mb_per_day: rss_fit && (rss_fit[:upper_95] * 24 / MB).round(3),
280
+ rss_growth_ratio: rss_ratio,
281
+ rss_peak_mb: usable.empty? ? nil : (usable.map { |s| s["vitals"]["rss_bytes"] }.max / MB).round(1),
282
+ heap_growth_ratio: heap_ratio,
283
+ db_growth_mb_per_day: db_fit && (db_fit[:slope] * 24 / MB).round(3),
284
+ tokens_per_turn: tokens_per_turn(turns),
285
+ prep_p95_first_ms: first_window.empty? ? nil : first_p95,
286
+ prep_p95_last_ms: last_window.empty? ? nil : last_p95,
287
+ prep_p95_drift_ratio: last_window.empty? ? nil : drift_ratio(first_p95, last_p95),
288
+ prep_p95_ms: prep.empty? ? nil : pct(prep.sort, 95),
289
+ total_p95_ms: totals.empty? ? nil : pct(totals.sort, 95).round(1),
290
+ ttft_p95_ms: ttfts.empty? ? nil : pct(ttfts.sort, 95).round(1),
291
+ coverage_ratio: duration.zero? ? 0.0 : covered.to_f / duration,
292
+ max_gap_s: gap_s.nil? ? nil : gap_s.round,
293
+ thin_hours: thin
294
+ }
295
+ end
296
+
297
+ def drift_ratio(first, last)
298
+ return nil if first.nil? || last.nil?
299
+ return 1.0 if first.zero? && last.zero?
300
+ return Float::INFINITY if first.zero?
301
+
302
+ (last / first).round(3)
303
+ end
304
+
305
+ def growth_ratio(fit, warmup, last_hour)
306
+ return nil if last_hour.nil? || last_hour <= warmup
307
+
308
+ (fit[:intercept] + fit[:upper_95] * last_hour) / (fit[:intercept] + fit[:upper_95] * warmup)
309
+ end
310
+
311
+ # restarts = distinct boot_id changes + worker respawns (pid changes
312
+ # inside one boot_id), each minus the first. -> [count, [event strings]].
313
+ def count_restarts(snapshots)
314
+ boots = snapshots.each_with_object({}) do |s, acc|
315
+ next unless s["vitals"].is_a?(Hash)
316
+
317
+ (acc[s["vitals"]["boot_id"]] ||= []) << [s["hour"], s["vitals"]["pid"]]
318
+ end
319
+ events = []
320
+ boots.each_value do |samples|
321
+ prev = nil
322
+ samples.sort_by(&:first).each do |hour, pid|
323
+ events << "worker respawned at hour #{hour} (pid #{prev[1]} -> #{pid})" if prev && pid != prev[1]
324
+ prev = [hour, pid]
325
+ end
326
+ end
327
+ first_boot = snapshots.select { |s| s["vitals"].is_a?(Hash) }
328
+ .min_by { |s| s["hour"] }&.dig("vitals", "boot_id")
329
+ boots.each_key do |boot|
330
+ next if boot == first_boot
331
+
332
+ events << "boot_id changed at hour #{boots[boot].map(&:first).min}"
333
+ end
334
+ [events.length, events]
335
+ end
336
+
337
+ def inter_turn_gaps(turns)
338
+ sorted = turns.filter_map { |t| parse_time(t["at"]) }.sort
339
+ sorted.each_cons(2).map { |a, b| b - a }
340
+ end
341
+
342
+ def tokens_per_turn(turns)
343
+ totals = turns.filter_map { |t| t.dig("usage", "total_tokens") }
344
+ return nil if totals.empty?
345
+
346
+ (totals.sum.to_f / totals.length).round(1)
347
+ end
348
+
349
+ def missing_coverage(envelope, m)
350
+ return "snapshot coverage: #{m[:coverage_ratio]} below #{envelope[:coverage_min_ratio]}" if m[:coverage_ratio] < envelope[:coverage_min_ratio]
351
+ return "thin hours: #{m[:thin_hours]} hour(s) under #{envelope[:hourly_turn_floor]} turns" if m[:thin_hours].positive?
352
+ if m[:max_gap_s] && m[:max_gap_s] > envelope[:gap_seconds_max]
353
+ return "gap: #{m[:max_gap_s]}s exceeds #{envelope[:gap_seconds_max]}s"
354
+ end
355
+ return "no timing data (INSIKA_TURN_TIMING off on the target)" if m[:prep_p95_ms].nil?
356
+
357
+ nil
358
+ end
359
+
360
+ def breaches(envelope, m)
361
+ out = []
362
+ m[:restart_events].each { |e| out << "restarts: #{e} (max #{envelope[:restarts_max]})" } if m[:restarts] > envelope[:restarts_max]
363
+ out << "error_rate: #{m[:error_rate].round(4)} exceeds #{envelope[:error_rate_ceiling]}" if m[:error_rate] > envelope[:error_rate_ceiling]
364
+ out << "no_usage_rate: #{m[:no_usage_rate].round(4)} exceeds #{envelope[:no_usage_rate_ceiling]} (a turn with no usage called no model)" if m[:no_usage_rate] > envelope[:no_usage_rate_ceiling]
365
+
366
+ if m[:rss_growth_ratio] && m[:rss_growth_ratio] > envelope[:rss_growth_ratio]
367
+ slope = m[:rss_slope_mb_per_day] || 0.0
368
+ out << "rss_growth_ratio: #{m[:rss_growth_ratio].round(3)} exceeds #{envelope[:rss_growth_ratio]} " \
369
+ "(fitted slope #{slope} MB/day)"
370
+ end
371
+
372
+ if envelope[:rss_ceiling_mb] && m[:rss_peak_mb] && m[:rss_peak_mb] > envelope[:rss_ceiling_mb]
373
+ out << "rss_peak: #{m[:rss_peak_mb]} MB exceeds ceiling #{envelope[:rss_ceiling_mb]} MB"
374
+ end
375
+
376
+ if m[:prep_p95_drift_ratio] && m[:prep_p95_drift_ratio] > envelope[:prep_p95_drift_ratio]
377
+ out << "prep_p95_drift_ratio: #{m[:prep_p95_drift_ratio]} exceeds #{envelope[:prep_p95_drift_ratio]} " \
378
+ "(first #{m[:prep_p95_first_ms]} ms -> last #{m[:prep_p95_last_ms]} ms)"
379
+ end
380
+
381
+ if envelope[:prep_p95_ceiling_ms] && m[:prep_p95_ms] && m[:prep_p95_ms] > envelope[:prep_p95_ceiling_ms]
382
+ out << "prep_p95: #{m[:prep_p95_ms]} ms exceeds ceiling #{envelope[:prep_p95_ceiling_ms]} ms"
383
+ end
384
+
385
+ if envelope[:total_p95_ceiling_ms] && m[:total_p95_ms] && m[:total_p95_ms] > envelope[:total_p95_ceiling_ms]
386
+ out << "total_p95: #{m[:total_p95_ms]} ms exceeds ceiling #{envelope[:total_p95_ceiling_ms]} ms"
387
+ end
388
+ out
389
+ end
390
+ end
391
+ end
392
+ end