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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +145 -17
- data/README.md +7 -1
- data/bin/insika +211 -8
- data/docs/AGENTS.md +377 -2
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +99 -9
- data/docs/CONTEXT.md +127 -16
- data/docs/DEPLOY.md +5 -2
- data/docs/EVALS.md +19 -0
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +14 -8
- data/docs/OBSERVABILITY.md +54 -0
- data/docs/REFINEMENT.md +3 -3
- data/docs/RELEASING.md +27 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SECURITY.md +61 -0
- data/docs/SKILLS.md +2 -2
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +1 -1
- data/lib/insika/agent_profile.rb +145 -10
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +118 -13
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +84 -1
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +3 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +716 -6
- data/lib/insika/dsl/runtime.rb +9 -3
- data/lib/insika/dsl/server_boot.rb +70 -1
- data/lib/insika/dsl.rb +83 -3
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +9 -2
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/pairwise.rb +18 -6
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +687 -70
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +1 -1
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +6 -1
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +6 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +22 -1
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +144 -4
- data/lib/insika/server/rack_app.rb +16 -0
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +4 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +875 -14
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +22 -22
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +262 -5
- data/lib/insika/studio/nav_icons.rb +6 -1
- data/lib/insika/studio/views/agent_detail.erb +627 -84
- data/lib/insika/studio/views/agents.erb +10 -6
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +56 -1
- data/lib/insika/studio/views/settings.erb +2 -0
- data/lib/insika/studio/views/skills.erb +0 -1
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +25 -1
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_state.rb +38 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +300 -13
- data/lib/insika.rb +92 -3
- 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
|