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,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "yaml"
5
+
6
+ module Insika
7
+ module Soak
8
+ # The frozen pre-declared envelope for a soak run. Parses the
9
+ # fenced `yaml` block inside a markdown file, validates it completely or not
10
+ # at all, and exposes the SHA-256 of the WHOLE file — every hourly snapshot
11
+ # is stamped with it, so editing the envelope mid-run turns the run
12
+ # `invalid` instead of producing a verdict nobody can defend.
13
+ #
14
+ # No defaults, no tolerance: an envelope with a hole is not a
15
+ # pre-declaration. Contrast with EnvSchema (tolerant, warn-and-boot) — that
16
+ # resilience protects a live service; this strictness protects a claim.
17
+ class Envelope
18
+ # Required keys. A missing one is a ConfigError, not a default.
19
+ REQUIRED = %i[
20
+ version target duration_hours warmup_hours
21
+ arrival turns_per_hour session_turns concurrency_cap web_concurrency
22
+ rss_growth_ratio prep_p95_drift_ratio
23
+ restarts_max error_rate_ceiling no_usage_rate_ceiling
24
+ coverage_min_ratio gap_seconds_max hourly_turn_floor
25
+ ].freeze
26
+
27
+ # Written after E1, before E2 (techspec D3). Absent -> `calibrated?` is
28
+ # false and the 72h run refuses to start; the 4h dry run does not require them.
29
+ CALIBRATED = %i[rss_ceiling_mb prep_p95_ceiling_ms total_p95_ceiling_ms].freeze
30
+
31
+ # Keys validated as ratios: must be strictly greater than 1 (the gate is
32
+ # "no more than 1.15x", so a ratio of 1 or below is a nonsense gate).
33
+ RATIO_KEYS = %i[rss_growth_ratio prep_p95_drift_ratio].freeze
34
+
35
+ # Keys validated as positive integers.
36
+ POSITIVE_COUNT_KEYS = %i[
37
+ duration_hours warmup_hours turns_per_hour session_turns
38
+ concurrency_cap web_concurrency gap_seconds_max hourly_turn_floor
39
+ ].freeze
40
+
41
+ # Keys validated as rates in [0, 1].
42
+ RATE_KEYS = %i[error_rate_ceiling no_usage_rate_ceiling coverage_min_ratio].freeze
43
+
44
+ # Raises Insika::ConfigError: file missing, no fenced yaml block,
45
+ # unparseable, a REQUIRED key absent, or a value out of range.
46
+ def self.load(path)
47
+ bytes = File.binread(path)
48
+ parse(bytes, sha: "sha256:#{Digest::SHA256.hexdigest(bytes)}")
49
+ rescue Errno::ENOENT
50
+ raise ConfigError, "soak envelope not found: #{path}"
51
+ end
52
+
53
+ # Pure parse (specs, fixtures). `sha` is stamped verbatim.
54
+ def self.parse(source, sha:)
55
+ new(values: validate(YAML.safe_load(fenced_yaml(source)) || {}), sha: sha)
56
+ rescue Psych::SyntaxError => e
57
+ raise ConfigError, "soak envelope yaml is unparseable: #{e.message.lines.first.to_s.strip}"
58
+ end
59
+
60
+ def self.fenced_yaml(source)
61
+ match = source.to_s.match(/```yaml\s*\n(.*?)\n```/m)
62
+ raise ConfigError, "soak envelope has no fenced yaml block" unless match
63
+
64
+ # Markdown fence content may be indented; YAML cares about the column.
65
+ lines = match[1].lines
66
+ indent = lines.reject { |l| l.strip.empty? }.map { |l| l[/\A */].length }.min
67
+ lines.map { |l| l[indent..] || "\n" }.join
68
+ end
69
+ private_class_method :fenced_yaml
70
+
71
+ def self.validate(raw)
72
+ values = raw.each_with_object({}) do |(k, v), acc|
73
+ acc[k.to_sym] = v if k.to_s =~ /\A[a-z_][a-z0-9_]*\z/
74
+ end
75
+
76
+ REQUIRED.each do |key|
77
+ raise ConfigError, "soak envelope is missing required key: #{key}" if values[key].nil?
78
+ end
79
+
80
+ RATIO_KEYS.each do |key|
81
+ v = values[key]
82
+ unless v.is_a?(Numeric) && v > 1
83
+ raise ConfigError, "soak envelope key #{key} must be > 1 (got #{v.inspect})"
84
+ end
85
+ end
86
+
87
+ POSITIVE_COUNT_KEYS.each do |key|
88
+ v = values[key]
89
+ unless v.is_a?(Integer) && v.positive?
90
+ raise ConfigError, "soak envelope key #{key} must be a positive integer (got #{v.inspect})"
91
+ end
92
+ end
93
+
94
+ unless values[:restarts_max].is_a?(Integer) && values[:restarts_max] >= 0
95
+ raise ConfigError, "soak envelope key restarts_max must be >= 0 (got #{values[:restarts_max].inspect})"
96
+ end
97
+
98
+ RATE_KEYS.each do |key|
99
+ v = values[key]
100
+ unless v.is_a?(Numeric) && v >= 0 && v <= 1
101
+ raise ConfigError, "soak envelope key #{key} must be a rate in 0..1 (got #{v.inspect})"
102
+ end
103
+ end
104
+
105
+ unless values[:warmup_hours] < values[:duration_hours]
106
+ raise ConfigError, "soak envelope key warmup_hours must be < duration_hours " \
107
+ "(got #{values[:warmup_hours]} >= #{values[:duration_hours]})"
108
+ end
109
+
110
+ CALIBRATED.each do |key|
111
+ v = values[key]
112
+ next if v.nil?
113
+
114
+ unless v.is_a?(Numeric) && v.positive?
115
+ raise ConfigError, "soak envelope key #{key} must be a positive number when set (got #{v.inspect})"
116
+ end
117
+ end
118
+
119
+ values.freeze
120
+ end
121
+ private_class_method :validate
122
+
123
+ attr_reader :sha, :values
124
+
125
+ def initialize(values:, sha:)
126
+ @values = values
127
+ @sha = sha
128
+ end
129
+
130
+ def [](key) = values[key]
131
+
132
+ def calibrated? = CALIBRATED.all? { |key| !values[key].nil? }
133
+
134
+ # E1 shape (<= 8h): the dry run skips the calibrated ceilings.
135
+ def dry_run? = values[:duration_hours] <= 8
136
+
137
+ def to_h = values.merge(sha: sha)
138
+ end
139
+ end
140
+ end
@@ -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