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,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Parity
|
|
7
|
+
# Both halves of an exchange formatted IDENTICALLY, so the judge cannot tell
|
|
8
|
+
# them apart by shape (anonymity by construction).
|
|
9
|
+
def self.transcript(inbound, reply)
|
|
10
|
+
"customer: #{inbound.to_s.strip}\nassistant: #{reply.to_s.strip}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# C6 — the mechanical fold. Given the window's pairs and the frozen criterion,
|
|
14
|
+
# produce ONE verdict and show its arithmetic. Pure: no store, no clock beyond
|
|
15
|
+
# the injected `now`, no LLM. Every number the Studio prints comes from here,
|
|
16
|
+
# so two people reading the same pairs get the same answer (E3).
|
|
17
|
+
#
|
|
18
|
+
# The order of the steps IS the rule:
|
|
19
|
+
# window -> pre-registration -> buckets -> volume -> sanity -> primary -> guards
|
|
20
|
+
module Verdict
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
Report = Data.define(
|
|
24
|
+
:verdict, # :pass | :fail | :insufficient | :invalid
|
|
25
|
+
:window, # { from:, to:, days: }
|
|
26
|
+
:counts, # judged/decided/better/comparable/worse/split/unknown/
|
|
27
|
+
# silent/incomplete/human_assisted/open
|
|
28
|
+
:daily, # [{ date:, pairs:, decided: }]
|
|
29
|
+
:per_agent, # { agent => { decided:, win_or_tie:, worse:, meets: } }
|
|
30
|
+
:win_or_tie, :win_or_tie_lower, :worse_rate,
|
|
31
|
+
:undecided_rate, :incomplete_rate,
|
|
32
|
+
:checks, # [{ id:, met:, actual:, required:, note: }]
|
|
33
|
+
:criterion_sha, :reason
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
DECIDED = %w[better comparable worse].freeze
|
|
37
|
+
|
|
38
|
+
Check = Data.define(:id, :met, :actual, :required, :note)
|
|
39
|
+
|
|
40
|
+
def fold(pairs:, criterion:, now: Time.now.utc)
|
|
41
|
+
rule = criterion.rule
|
|
42
|
+
from = now - (rule.window_days * 86_400)
|
|
43
|
+
windowed = Array(pairs).select { |p| in_window?(p, from, now) }
|
|
44
|
+
|
|
45
|
+
checks = []
|
|
46
|
+
|
|
47
|
+
# 2. Pre-registration: a window whose pairs disagree about which file was
|
|
48
|
+
# frozen is :invalid — a post-hoc edit produces NO verdict, not a worse one.
|
|
49
|
+
sha = sha_check(windowed, criterion.sha)
|
|
50
|
+
if sha && !sha.met
|
|
51
|
+
checks << sha
|
|
52
|
+
return build(verdict: :invalid, criterion: criterion, from: from, to: now,
|
|
53
|
+
rule: rule, counts: nil, daily: [], per_agent: {},
|
|
54
|
+
checks: checks, reason: sha.note)
|
|
55
|
+
end
|
|
56
|
+
checks << sha if sha
|
|
57
|
+
|
|
58
|
+
# 3. Buckets: decided is better+comparable+worse against the incumbent's
|
|
59
|
+
# model (`vs: agent`). human-assisted/silent/incomplete/open/split/unknown
|
|
60
|
+
# are counted and reported, never in the denominator.
|
|
61
|
+
counts = count(windowed)
|
|
62
|
+
daily = daily_of(windowed, from, now, rule)
|
|
63
|
+
per_agent = per_agent_of(windowed)
|
|
64
|
+
|
|
65
|
+
# 4. Volume: every day at the floor, and enough decided pairs in total —
|
|
66
|
+
# short volume is :insufficient (an honest "keep running"), never a fail.
|
|
67
|
+
volume = volume_check(daily, rule)
|
|
68
|
+
checks << volume
|
|
69
|
+
decided_check = Check.new(id: :min_decided, met: counts[:decided] >= rule.min_decided,
|
|
70
|
+
actual: counts[:decided], required: rule.min_decided,
|
|
71
|
+
note: "decided #{counts[:decided]} < #{rule.min_decided}")
|
|
72
|
+
checks << decided_check
|
|
73
|
+
unless volume.met && decided_check.met
|
|
74
|
+
notes = [volume.met ? nil : volume.note, decided_check.met ? nil : decided_check.note].compact
|
|
75
|
+
return build(verdict: :insufficient, criterion: criterion, from: from, to: now,
|
|
76
|
+
rule: rule, counts: counts, daily: daily, per_agent: per_agent,
|
|
77
|
+
checks: checks, reason: notes.join("; "))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# 5. Sanity: a panel that cannot decide, or a mirror that cannot pair,
|
|
81
|
+
# is not measuring anything -> :invalid. The denominator is the panel's
|
|
82
|
+
# own work: judged pairs against a model — human-assisted pairs are
|
|
83
|
+
# counted and reported, never in a denominator.
|
|
84
|
+
judged = counts[:judged] - counts[:human_assisted]
|
|
85
|
+
undecided_rate = judged.positive? ? (counts[:split] + counts[:unknown]).fdiv(judged) : 0.0
|
|
86
|
+
undecided = Check.new(id: :undecided, met: undecided_rate <= rule.undecided_rate_ceiling,
|
|
87
|
+
actual: undecided_rate.round(4), required: "<= #{rule.undecided_rate_ceiling}",
|
|
88
|
+
note: "undecided rate #{undecided_rate.round(3)} > #{rule.undecided_rate_ceiling} " \
|
|
89
|
+
"(#{counts[:split]} split, #{counts[:unknown]} unknown of #{judged} judged)")
|
|
90
|
+
checks << undecided
|
|
91
|
+
|
|
92
|
+
non_open = windowed.length - counts[:open]
|
|
93
|
+
incomplete_rate = non_open.positive? ? counts[:incomplete].fdiv(non_open) : 0.0
|
|
94
|
+
incomplete = Check.new(id: :incomplete, met: incomplete_rate <= rule.incomplete_rate_ceiling,
|
|
95
|
+
actual: incomplete_rate.round(4), required: "<= #{rule.incomplete_rate_ceiling}",
|
|
96
|
+
note: "incomplete rate #{incomplete_rate.round(3)} > #{rule.incomplete_rate_ceiling} " \
|
|
97
|
+
"(#{counts[:incomplete]} of #{non_open} non-open pairs never got both halves)")
|
|
98
|
+
checks << incomplete
|
|
99
|
+
unless undecided.met && incomplete.met
|
|
100
|
+
notes = [undecided.met ? nil : undecided.note, incomplete.met ? nil : incomplete.note].compact
|
|
101
|
+
return build(verdict: :invalid, criterion: criterion, from: from, to: now,
|
|
102
|
+
rule: rule, counts: counts, daily: daily, per_agent: per_agent,
|
|
103
|
+
rates: { undecided: undecided_rate, incomplete: incomplete_rate },
|
|
104
|
+
checks: checks, reason: notes.join("; "))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# 6. Primary: the lower bound of the 95% Wilson interval on win-or-tie.
|
|
108
|
+
# The estimator is NAMED in the criterion, so a point estimate can never be
|
|
109
|
+
# quietly substituted.
|
|
110
|
+
win_or_tie = counts[:decided].positive? ? (counts[:better] + counts[:comparable]).fdiv(counts[:decided]) : 0.0
|
|
111
|
+
lower = wilson_lower(counts[:better] + counts[:comparable], counts[:decided])
|
|
112
|
+
primary = Check.new(id: :primary, met: lower >= rule.win_or_tie_floor,
|
|
113
|
+
actual: { win_or_tie: win_or_tie.round(4), wilson_lower_95: lower.round(4) },
|
|
114
|
+
required: "#{rule.estimator} >= #{rule.win_or_tie_floor}",
|
|
115
|
+
note: "win-or-tie #{win_or_tie.round(3)} (wilson lower #{lower.round(3)}) " \
|
|
116
|
+
"< floor #{rule.win_or_tie_floor}")
|
|
117
|
+
checks << primary
|
|
118
|
+
|
|
119
|
+
# 7. Guards: the worse tail on its own, and any store blocking its own cut.
|
|
120
|
+
worse_rate = counts[:decided].positive? ? counts[:worse].fdiv(counts[:decided]) : 0.0
|
|
121
|
+
worse = Check.new(id: :worse_rate, met: worse_rate <= rule.worse_rate_ceiling,
|
|
122
|
+
actual: worse_rate.round(4), required: "<= #{rule.worse_rate_ceiling}",
|
|
123
|
+
note: "worse rate #{worse_rate.round(3)} > #{rule.worse_rate_ceiling} " \
|
|
124
|
+
"(#{counts[:worse]} worse of #{counts[:decided]} decided)")
|
|
125
|
+
checks << worse
|
|
126
|
+
agent = per_agent_check(per_agent, rule)
|
|
127
|
+
checks << agent if agent
|
|
128
|
+
|
|
129
|
+
failed = [primary, worse, agent].compact.reject(&:met)
|
|
130
|
+
unless failed.empty?
|
|
131
|
+
return build(verdict: :fail, criterion: criterion, from: from, to: now,
|
|
132
|
+
rule: rule, counts: counts, daily: daily, per_agent: per_agent,
|
|
133
|
+
rates: { win_or_tie: win_or_tie, lower: lower, worse: worse_rate,
|
|
134
|
+
undecided: undecided_rate, incomplete: incomplete_rate },
|
|
135
|
+
checks: checks, reason: failed.map(&:note).join("; "))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
build(verdict: :pass, criterion: criterion, from: from, to: now,
|
|
139
|
+
rule: rule, counts: counts, daily: daily, per_agent: per_agent,
|
|
140
|
+
rates: { win_or_tie: win_or_tie, lower: lower, worse: worse_rate,
|
|
141
|
+
undecided: undecided_rate, incomplete: incomplete_rate },
|
|
142
|
+
checks: checks, reason: "the cut cleared: every check in the window is green")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Wilson score interval, lower bound. Pure arithmetic, unit-tested against
|
|
146
|
+
# published values (n=210, k=183 -> ~0.819).
|
|
147
|
+
def wilson_lower(successes, n, z: 1.96)
|
|
148
|
+
return 0.0 if n.to_i <= 0
|
|
149
|
+
|
|
150
|
+
p_hat = successes.to_f / n
|
|
151
|
+
z2 = z * z
|
|
152
|
+
denominator = 1 + (z2 / n)
|
|
153
|
+
centre = p_hat + (z2 / (2 * n))
|
|
154
|
+
spread = Math.sqrt((p_hat * (1 - p_hat) + (z2 / (4 * n))) / n)
|
|
155
|
+
(centre - (z * spread)) / denominator
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# -- private helpers ---------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def in_window?(pair, from, to)
|
|
161
|
+
created = pair.respond_to?(:created_at) ? pair.created_at : nil
|
|
162
|
+
time = begin
|
|
163
|
+
Time.iso8601(created.to_s)
|
|
164
|
+
rescue ArgumentError
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
time && time >= from && time < to
|
|
168
|
+
end
|
|
169
|
+
private_class_method :in_window?
|
|
170
|
+
|
|
171
|
+
# Pre-registration has nothing to stand on when NO pair carries the sha:
|
|
172
|
+
# the window predates the freeze, and stamping a verdict on it is the same
|
|
173
|
+
# post-hoc edit the mixed-sha rule exists to refuse.
|
|
174
|
+
def sha_check(pairs, expected)
|
|
175
|
+
shas = pairs.filter_map { |p| p.respond_to?(:criterion_sha) ? p.criterion_sha : nil }.compact.uniq
|
|
176
|
+
if shas.length == 1 && shas.first == expected
|
|
177
|
+
Check.new(id: :criterion_sha, met: true, actual: expected, required: expected, note: nil)
|
|
178
|
+
elsif pairs.empty?
|
|
179
|
+
# nothing to pre-register — the volume check owns the empty window
|
|
180
|
+
Check.new(id: :criterion_sha, met: true, actual: expected, required: expected, note: nil)
|
|
181
|
+
elsif shas.empty?
|
|
182
|
+
Check.new(id: :criterion_sha, met: false, actual: "no pair stamped", required: expected,
|
|
183
|
+
note: "no pair in the window carries a criterion_sha — the window predates the freeze")
|
|
184
|
+
else
|
|
185
|
+
Check.new(id: :criterion_sha, met: false, actual: shas.join(" | "), required: expected,
|
|
186
|
+
note: "window carries #{shas.join(', ')}, expected the frozen #{expected}")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
private_class_method :sha_check
|
|
190
|
+
|
|
191
|
+
def count(windowed)
|
|
192
|
+
buckets = windowed.group_by { |p| bucket_of(p) }
|
|
193
|
+
base = { judged: windowed.count { |p| p.status == :judged },
|
|
194
|
+
open: windowed.count { |p| p.status == :open } }
|
|
195
|
+
{ silent: buckets[:silent].to_a.length,
|
|
196
|
+
incomplete: buckets[:incomplete].to_a.length,
|
|
197
|
+
human_assisted: buckets[:human_assisted].to_a.length,
|
|
198
|
+
better: buckets[:better].to_a.length, comparable: buckets[:comparable].to_a.length,
|
|
199
|
+
worse: buckets[:worse].to_a.length, split: buckets[:split].to_a.length,
|
|
200
|
+
unknown: buckets[:unknown].to_a.length }.merge(base).tap do |c|
|
|
201
|
+
c[:decided] = c[:better] + c[:comparable] + c[:worse]
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
private_class_method :count
|
|
205
|
+
|
|
206
|
+
# A malformed verdict blob counts as :unknown, never as a preference — the
|
|
207
|
+
# same rule Pairwise applies to a broken judge.
|
|
208
|
+
def bucket_of(pair)
|
|
209
|
+
return pair.status.to_sym unless pair.status == :judged
|
|
210
|
+
|
|
211
|
+
verdict = pair.respond_to?(:verdict) && pair.verdict.is_a?(Hash) ? pair.verdict : nil
|
|
212
|
+
return :unknown unless verdict
|
|
213
|
+
|
|
214
|
+
return :human_assisted if verdict["vs"] == "human-assisted"
|
|
215
|
+
|
|
216
|
+
outcome = verdict["outcome"].to_s
|
|
217
|
+
DECIDED.include?(outcome) ? outcome.to_sym : outcome == "split" ? :split : :unknown
|
|
218
|
+
end
|
|
219
|
+
private_class_method :bucket_of
|
|
220
|
+
|
|
221
|
+
# One entry per CALENDAR date the window touches, bucketed by each pair's
|
|
222
|
+
# own date — the newest pairs must land on the grid, never outside the list.
|
|
223
|
+
# `floor` is the daily requirement: pairs_per_day for a day whose full 24h
|
|
224
|
+
# lie inside the window, 0 for the two boundary days — they are partial by
|
|
225
|
+
# construction (`from` carries now's time of day), and holding a partial day
|
|
226
|
+
# to a full floor would make the gate unreachable under constant traffic at
|
|
227
|
+
# exactly the floor. Partial days are reported, never required.
|
|
228
|
+
def daily_of(windowed, from, to, rule)
|
|
229
|
+
by_day = windowed.group_by { |p| Time.iso8601(p.created_at.to_s).utc.strftime("%Y-%m-%d") }
|
|
230
|
+
calendar_dates(from, to).map do |date|
|
|
231
|
+
pairs = by_day[date].to_a
|
|
232
|
+
decided = pairs.count { |p| DECIDED.include?(bucket_of(p).to_s) }
|
|
233
|
+
{ date: date, pairs: pairs.length, decided: decided,
|
|
234
|
+
floor: full_day?(date, from, to) ? rule.pairs_per_day : 0 }
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
private_class_method :daily_of
|
|
238
|
+
|
|
239
|
+
def calendar_dates(from, to)
|
|
240
|
+
dates = []
|
|
241
|
+
day = Time.utc(from.utc.year, from.utc.month, from.utc.day)
|
|
242
|
+
last = Time.utc(to.utc.year, to.utc.month, to.utc.day)
|
|
243
|
+
while day <= last
|
|
244
|
+
dates << day.strftime("%Y-%m-%d")
|
|
245
|
+
day += 86_400
|
|
246
|
+
end
|
|
247
|
+
dates
|
|
248
|
+
end
|
|
249
|
+
private_class_method :calendar_dates
|
|
250
|
+
|
|
251
|
+
def full_day?(date, from, to)
|
|
252
|
+
y, m, d = date.split("-").map(&:to_i)
|
|
253
|
+
day_start = Time.utc(y, m, d)
|
|
254
|
+
day_end = day_start + 86_400
|
|
255
|
+
[[to, day_end].min - [from, day_start].max, 0].max >= 86_400
|
|
256
|
+
end
|
|
257
|
+
private_class_method :full_day?
|
|
258
|
+
|
|
259
|
+
def volume_check(daily, rule)
|
|
260
|
+
short = daily.find { |d| d[:floor].positive? && d[:pairs] < d[:floor] }
|
|
261
|
+
full_days = daily.count { |d| d[:floor].positive? }
|
|
262
|
+
return Check.new(id: :volume, met: true,
|
|
263
|
+
actual: "#{full_days} full day(s) at >= #{rule.pairs_per_day}",
|
|
264
|
+
required: "#{rule.pairs_per_day} pairs on every full day",
|
|
265
|
+
note: nil) unless short
|
|
266
|
+
|
|
267
|
+
Check.new(id: :volume, met: false,
|
|
268
|
+
actual: "#{short[:date]}: #{short[:pairs]} pairs",
|
|
269
|
+
required: "#{short[:floor]} pairs that day",
|
|
270
|
+
note: "day #{short[:date]} has #{short[:pairs]} pairs (floor #{short[:floor]})")
|
|
271
|
+
end
|
|
272
|
+
private_class_method :volume_check
|
|
273
|
+
|
|
274
|
+
def per_agent_of(windowed)
|
|
275
|
+
windowed.select { |p| DECIDED.include?(bucket_of(p).to_s) }
|
|
276
|
+
.group_by { |p| p.agent.to_s }
|
|
277
|
+
.transform_values do |pairs|
|
|
278
|
+
decided = pairs.length
|
|
279
|
+
win_or_tie = decided.positive? ? pairs.count { |p| %w[better comparable].include?(bucket_of(p).to_s) }.fdiv(decided) : 0.0
|
|
280
|
+
{ decided: decided, win_or_tie: win_or_tie.round(4),
|
|
281
|
+
worse: pairs.count { |p| bucket_of(p) == :worse }, meets: nil }
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
private_class_method :per_agent_of
|
|
285
|
+
|
|
286
|
+
# A store with enough decided pairs blocks its own cut when its win-or-tie
|
|
287
|
+
# sits under its own floor — even if the aggregate passes.
|
|
288
|
+
def per_agent_check(per_agent, rule)
|
|
289
|
+
blockers = per_agent.each_with_object({}) do |(agent, stats), acc|
|
|
290
|
+
next if stats[:decided] < rule.per_agent_min_decided
|
|
291
|
+
|
|
292
|
+
stats[:meets] = stats[:win_or_tie] >= rule.per_agent_win_or_tie_floor
|
|
293
|
+
acc[agent] = stats[:win_or_tie] unless stats[:meets]
|
|
294
|
+
end
|
|
295
|
+
return nil if blockers.empty?
|
|
296
|
+
|
|
297
|
+
Check.new(id: :per_agent, met: false, actual: blockers.map { |a, r| "#{a}: #{r}" }.join(", "),
|
|
298
|
+
required: ">= #{rule.per_agent_win_or_tie_floor} for stores with >= #{rule.per_agent_min_decided} decided",
|
|
299
|
+
note: blockers.map { |a, r| "#{a} sits at #{r.round(3)} (< #{rule.per_agent_win_or_tie_floor})" }.join("; "))
|
|
300
|
+
end
|
|
301
|
+
private_class_method :per_agent_check
|
|
302
|
+
|
|
303
|
+
def build(verdict:, criterion:, from:, to:, rule:, counts:, daily:, per_agent:, checks:, reason:, rates: nil)
|
|
304
|
+
r = rates || {}
|
|
305
|
+
Report.new(
|
|
306
|
+
verdict: verdict,
|
|
307
|
+
window: { from: from.utc.iso8601, to: to.utc.iso8601, days: rule.window_days },
|
|
308
|
+
counts: counts, daily: daily, per_agent: per_agent,
|
|
309
|
+
win_or_tie: r[:win_or_tie]&.round(4), win_or_tie_lower: r[:lower]&.round(4),
|
|
310
|
+
worse_rate: r[:worse]&.round(4),
|
|
311
|
+
undecided_rate: r[:undecided]&.round(4), incomplete_rate: r[:incomplete]&.round(4),
|
|
312
|
+
checks: checks, criterion_sha: criterion.sha, reason: reason
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
private_class_method :build
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha2"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# — the cache-prefix hash chain. Inputs are the SYSTEM-placement
|
|
7
|
+
# fragments in canonical render order (the Builder's sort, C3) and the tool
|
|
8
|
+
# schema serialization — the same yardstick the token estimate uses
|
|
9
|
+
# (name + description + parameters.inspect, executor.rb:957). Outputs are
|
|
10
|
+
# PII-free digests: it never sees message text, only hashes leave this class.
|
|
11
|
+
# Pure stdlib (digest/sha2), no gem, no IO.
|
|
12
|
+
class PrefixFingerprint
|
|
13
|
+
# -> { "prompt" => "sha256…", …, "tool_schemas" => "sha256…",
|
|
14
|
+
# "prefix" => "sha256…" }
|
|
15
|
+
# Keys are the demodulized, downcased provider ids, in render order.
|
|
16
|
+
# A category with no fragments is absent (not an empty hash).
|
|
17
|
+
# The category digest = SHA256 of the category's fragments joined "\n\n"
|
|
18
|
+
# (the Builder's separator — the digest matches rendered bytes).
|
|
19
|
+
# "prefix" = SHA256 of the category digests concatenated IN CHAIN ORDER
|
|
20
|
+
# (identity categories, then volatile, then tool_schemas) — any divergence
|
|
21
|
+
# anywhere above the boundary changes it.
|
|
22
|
+
def self.compute(system_fragments, tool_serial:)
|
|
23
|
+
digests = {} # category name -> digest, insertion = render order
|
|
24
|
+
grouped = system_fragments.group_by { |f| category(f.source) }
|
|
25
|
+
grouped.each do |name, frags|
|
|
26
|
+
digests[name] = digest(frags.map(&:content).join("\n\n"))
|
|
27
|
+
end
|
|
28
|
+
digests["tool_schemas"] = digest(tool_serial.to_s) unless tool_serial.nil?
|
|
29
|
+
digests["prefix"] = digest(digests.values.join) unless digests.empty?
|
|
30
|
+
digests
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# -> String | nil. The first category (in CURRENT chain order) whose digest
|
|
34
|
+
# differs or is absent from `previous`; else the first PREVIOUS key now
|
|
35
|
+
# absent from the current chain (a vanished block is a divergence too);
|
|
36
|
+
# else nil. nil `previous` (first turn) -> nil. The returned name is a
|
|
37
|
+
# category id — PII-free by construction.
|
|
38
|
+
#
|
|
39
|
+
# The cumulative "prefix" key is deliberately SKIPPED in the scan: its
|
|
40
|
+
# digest changes whenever ANY category moves, so scanning it would shadow a
|
|
41
|
+
# vanished block (every surviving category matches, "prefix" differs, and
|
|
42
|
+
# the vanished-fallback below becomes unreachable — reporting `broke:
|
|
43
|
+
# prefix` instead of the category that actually left).
|
|
44
|
+
def self.invalidation_reason(current, previous)
|
|
45
|
+
return nil unless previous.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
current.each_key do |name|
|
|
48
|
+
next if name == "prefix"
|
|
49
|
+
|
|
50
|
+
return name unless previous[name] == current[name]
|
|
51
|
+
end
|
|
52
|
+
(previous.keys - current.keys).first
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.category(source) = source.to_s.split("::").last.to_s.downcase
|
|
56
|
+
def self.digest(bytes) = Digest::SHA256.hexdigest(bytes)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -104,6 +104,9 @@ module Insika
|
|
|
104
104
|
tools_deferred: h[:tools_deferred],
|
|
105
105
|
memory: h[:memory],
|
|
106
106
|
prompt_caching: h[:prompt_caching],
|
|
107
|
+
# tool_persistence: the one opt-out flag — nil round-trips as nil (= ON);
|
|
108
|
+
# only a stored explicit false turns the discipline block off.
|
|
109
|
+
tool_persistence: h[:tool_persistence],
|
|
107
110
|
tool_output_compression: h[:tool_output_compression],
|
|
108
111
|
# params/model_policy: the resolver tolerates string keys from
|
|
109
112
|
# the JSON round-trip (ModelResolver#normalize_params / ModelPolicy), so no
|
|
@@ -113,7 +116,13 @@ module Insika
|
|
|
113
116
|
budget: h[:budget],
|
|
114
117
|
reliability: h[:reliability],
|
|
115
118
|
alerts: h[:alerts],
|
|
119
|
+
routes: h[:routes],
|
|
116
120
|
stuck_signal: h[:stuck_signal],
|
|
121
|
+
outputs: h[:outputs],
|
|
122
|
+
briefing_fields: h[:briefing_fields],
|
|
123
|
+
# grounding profile data — a plain Hash read with string keys
|
|
124
|
+
# by Insika::Grounding.parse per turn; nil round-trips as nil (= off).
|
|
125
|
+
grounding: h[:grounding],
|
|
117
126
|
# guardrails: a plain Hash; Safety::Config tolerates the JSON
|
|
118
127
|
# round-trip (string keys/values), so no re-symbolization here.
|
|
119
128
|
guardrails: h[:guardrails],
|
|
@@ -127,7 +136,19 @@ module Insika
|
|
|
127
136
|
capabilities_declared: h[:capabilities_declared],
|
|
128
137
|
# edge_stream: which internal channels may cross to the customer. {} = neither.
|
|
129
138
|
edge_stream: h[:edge_stream],
|
|
130
|
-
metadata: h[:metadata] || {}
|
|
139
|
+
metadata: h[:metadata] || {},
|
|
140
|
+
# funnel declaration — a plain Hash read with string keys by
|
|
141
|
+
# FunnelDeclaration.parse; nil round-trips as nil (= no funnel).
|
|
142
|
+
funnel: h[:funnel],
|
|
143
|
+
# followup declaration — a plain Hash read with string keys by
|
|
144
|
+
# FollowupPolicy.parse; nil round-trips as nil (= feature off).
|
|
145
|
+
followup: h[:followup],
|
|
146
|
+
# distill declaration — a plain Hash read with string keys by
|
|
147
|
+
# the distiller/engine/doctor; nil round-trips as nil (= feature off).
|
|
148
|
+
distill: h[:distill],
|
|
149
|
+
# harvest declaration — a plain Hash read with string keys
|
|
150
|
+
# by the miner/engine/doctor; nil round-trips as nil (= loop off).
|
|
151
|
+
harvest: h[:harvest]
|
|
131
152
|
)
|
|
132
153
|
end
|
|
133
154
|
|