insika 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -17
  3. data/README.md +7 -1
  4. data/bin/insika +211 -8
  5. data/docs/AGENTS.md +377 -2
  6. data/docs/API.md +56 -0
  7. data/docs/CHANNELS.md +99 -9
  8. data/docs/CONTEXT.md +127 -16
  9. data/docs/DEPLOY.md +5 -2
  10. data/docs/EVALS.md +19 -0
  11. data/docs/FACTS.md +135 -0
  12. data/docs/HARVEST.md +117 -0
  13. data/docs/LOADTEST.md +14 -8
  14. data/docs/OBSERVABILITY.md +54 -0
  15. data/docs/REFINEMENT.md +3 -3
  16. data/docs/RELEASING.md +27 -0
  17. data/docs/RUNNING-LOCAL.md +3 -3
  18. data/docs/SECURITY.md +61 -0
  19. data/docs/SKILLS.md +2 -2
  20. data/docs/SOAK.md +127 -0
  21. data/docs/TOOLS.md +70 -2
  22. data/docs/domain.md +115 -0
  23. data/docs/index.md +1 -1
  24. data/lib/insika/agent_profile.rb +145 -10
  25. data/lib/insika/balloon_splitter.rb +102 -0
  26. data/lib/insika/budget_ledger.rb +34 -3
  27. data/lib/insika/cache_series_store.rb +49 -0
  28. data/lib/insika/channel_delivery.rb +118 -13
  29. data/lib/insika/channels/relay.rb +77 -3
  30. data/lib/insika/channels/web.rb +2 -2
  31. data/lib/insika/chat_builder.rb +84 -1
  32. data/lib/insika/checkpoint_store.rb +16 -0
  33. data/lib/insika/commands/agent_payload.rb +3 -3
  34. data/lib/insika/commands/cancel_followup.rb +49 -0
  35. data/lib/insika/commands/delete_tenant_data.rb +95 -0
  36. data/lib/insika/commands/export_customer_memory.rb +48 -0
  37. data/lib/insika/commands/forget_customer.rb +117 -0
  38. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  39. data/lib/insika/commands/gate_harvest.rb +138 -0
  40. data/lib/insika/commands/gate_refinement.rb +1 -1
  41. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  42. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  43. data/lib/insika/commands/memory_put_fact.rb +23 -4
  44. data/lib/insika/commands/promote_harvest.rb +130 -0
  45. data/lib/insika/commands/record_outcome.rb +46 -0
  46. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  47. data/lib/insika/commands/reject_harvest.rb +38 -0
  48. data/lib/insika/commands/resolve_proposal.rb +108 -0
  49. data/lib/insika/commands/revoke_contact.rb +49 -0
  50. data/lib/insika/commands/rollback_harvest.rb +86 -0
  51. data/lib/insika/commands/run_distillation.rb +186 -0
  52. data/lib/insika/commands/run_harvest.rb +393 -0
  53. data/lib/insika/commands/send_message.rb +103 -6
  54. data/lib/insika/commands/session_purge.rb +67 -0
  55. data/lib/insika/contact_store.rb +183 -0
  56. data/lib/insika/context/builder.rb +21 -3
  57. data/lib/insika/context/fragment.rb +7 -3
  58. data/lib/insika/context/priority.rb +3 -0
  59. data/lib/insika/context/provider.rb +17 -3
  60. data/lib/insika/context/providers/briefing.rb +96 -0
  61. data/lib/insika/context/providers/memory.rb +16 -7
  62. data/lib/insika/context/providers/prompt.rb +30 -2
  63. data/lib/insika/context/providers/skill.rb +2 -0
  64. data/lib/insika/context/providers/tool_search.rb +2 -0
  65. data/lib/insika/context_trace_store.rb +38 -2
  66. data/lib/insika/distill.rb +224 -0
  67. data/lib/insika/distill_engine.rb +169 -0
  68. data/lib/insika/doctor.rb +716 -6
  69. data/lib/insika/dsl/runtime.rb +9 -3
  70. data/lib/insika/dsl/server_boot.rb +70 -1
  71. data/lib/insika/dsl.rb +83 -3
  72. data/lib/insika/edge_limiter.rb +28 -7
  73. data/lib/insika/env_schema.rb +9 -2
  74. data/lib/insika/errors.rb +11 -0
  75. data/lib/insika/evals/assertions.rb +3 -2
  76. data/lib/insika/evals/pairwise.rb +18 -6
  77. data/lib/insika/evidence.rb +183 -0
  78. data/lib/insika/executor.rb +687 -70
  79. data/lib/insika/followup_engine.rb +207 -0
  80. data/lib/insika/followup_policy.rb +221 -0
  81. data/lib/insika/followup_store.rb +306 -0
  82. data/lib/insika/funnel_declaration.rb +106 -0
  83. data/lib/insika/funnel_fold.rb +179 -0
  84. data/lib/insika/funnel_store.rb +163 -0
  85. data/lib/insika/golden_store.rb +1 -1
  86. data/lib/insika/grounding/matcher.rb +69 -0
  87. data/lib/insika/grounding.rb +44 -0
  88. data/lib/insika/harvest/conversion_gate.rb +159 -0
  89. data/lib/insika/harvest/criterion.rb +98 -0
  90. data/lib/insika/harvest/gate.rb +194 -0
  91. data/lib/insika/harvest/negative_list.rb +199 -0
  92. data/lib/insika/harvest.rb +241 -0
  93. data/lib/insika/harvest_engine.rb +193 -0
  94. data/lib/insika/harvest_store.rb +548 -0
  95. data/lib/insika/media.rb +298 -0
  96. data/lib/insika/memory_audit_store.rb +85 -0
  97. data/lib/insika/memory_store.rb +264 -23
  98. data/lib/insika/message_origin.rb +6 -1
  99. data/lib/insika/model_visible.rb +87 -0
  100. data/lib/insika/model_visible_trace_store.rb +66 -0
  101. data/lib/insika/onboarding.rb +6 -1
  102. data/lib/insika/outbox_store.rb +42 -4
  103. data/lib/insika/outcome_store.rb +147 -0
  104. data/lib/insika/packaging.rb +163 -0
  105. data/lib/insika/parity/criterion.rb +79 -0
  106. data/lib/insika/parity/verdict.rb +318 -0
  107. data/lib/insika/prefix_fingerprint.rb +58 -0
  108. data/lib/insika/profile_source.rb +22 -1
  109. data/lib/insika/proposal_store.rb +271 -0
  110. data/lib/insika/queue_policy.rb +4 -1
  111. data/lib/insika/refinement/proposer.rb +1 -1
  112. data/lib/insika/reliability.rb +32 -6
  113. data/lib/insika/retention.rb +281 -0
  114. data/lib/insika/routing.rb +101 -0
  115. data/lib/insika/safety/config.rb +44 -4
  116. data/lib/insika/safety/corpus.rb +255 -0
  117. data/lib/insika/safety/detectors.rb +32 -113
  118. data/lib/insika/safety/factory.rb +15 -2
  119. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  120. data/lib/insika/safety/grounding_validator.rb +49 -0
  121. data/lib/insika/safety/input_guardrail.rb +1 -1
  122. data/lib/insika/safety/moderator.rb +1 -1
  123. data/lib/insika/safety/output_filter.rb +9 -5
  124. data/lib/insika/safety/output_validator.rb +11 -5
  125. data/lib/insika/schema_guard.rb +35 -0
  126. data/lib/insika/server/app.rb +144 -4
  127. data/lib/insika/server/rack_app.rb +16 -0
  128. data/lib/insika/server/responses.rb +40 -1
  129. data/lib/insika/session_actor.rb +8 -4
  130. data/lib/insika/session_store.rb +65 -2
  131. data/lib/insika/settings_store.rb +10 -0
  132. data/lib/insika/shadow_pair_store.rb +258 -0
  133. data/lib/insika/skill_catalog.rb +4 -0
  134. data/lib/insika/soak/envelope.rb +140 -0
  135. data/lib/insika/soak/report.rb +392 -0
  136. data/lib/insika/soak/runner.rb +554 -0
  137. data/lib/insika/store.rb +10 -1
  138. data/lib/insika/stores/memory.rb +6 -0
  139. data/lib/insika/stores/sqlite.rb +8 -0
  140. data/lib/insika/studio/app.rb +875 -14
  141. data/lib/insika/studio/assets/dist/application.css +1 -1
  142. data/lib/insika/studio/assets/dist/application.js +22 -22
  143. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  144. data/lib/insika/studio/forms.rb +262 -5
  145. data/lib/insika/studio/nav_icons.rb +6 -1
  146. data/lib/insika/studio/views/agent_detail.erb +627 -84
  147. data/lib/insika/studio/views/agents.erb +10 -6
  148. data/lib/insika/studio/views/approvals.erb +4 -1
  149. data/lib/insika/studio/views/chats.erb +4 -1
  150. data/lib/insika/studio/views/customer.erb +94 -0
  151. data/lib/insika/studio/views/customers.erb +32 -0
  152. data/lib/insika/studio/views/evals.erb +4 -1
  153. data/lib/insika/studio/views/facts.erb +133 -0
  154. data/lib/insika/studio/views/followups.erb +125 -0
  155. data/lib/insika/studio/views/funnel.erb +106 -0
  156. data/lib/insika/studio/views/harvest.erb +234 -0
  157. data/lib/insika/studio/views/home.erb +2 -1
  158. data/lib/insika/studio/views/layout.erb +1 -0
  159. data/lib/insika/studio/views/parity.erb +147 -0
  160. data/lib/insika/studio/views/playground.erb +7 -1
  161. data/lib/insika/studio/views/session.erb +56 -1
  162. data/lib/insika/studio/views/settings.erb +2 -0
  163. data/lib/insika/studio/views/skills.erb +0 -1
  164. data/lib/insika/studio/views/system_files.erb +1 -1
  165. data/lib/insika/studio/views/task.erb +13 -0
  166. data/lib/insika/studio/views/tasks.erb +4 -1
  167. data/lib/insika/studio/views/tools.erb +0 -1
  168. data/lib/insika/task_store.rb +21 -1
  169. data/lib/insika/testing/store_contract.rb +27 -6
  170. data/lib/insika/tick.rb +25 -1
  171. data/lib/insika/tool_definition.rb +17 -7
  172. data/lib/insika/tool_envelope.rb +69 -0
  173. data/lib/insika/tool_manifest.rb +5 -1
  174. data/lib/insika/tools/data_defined_tool.rb +10 -0
  175. data/lib/insika/tools/generate_image.rb +44 -0
  176. data/lib/insika/tools/schedule_followup.rb +164 -0
  177. data/lib/insika/tools/tts.rb +47 -0
  178. data/lib/insika/tools/update_briefing.rb +126 -0
  179. data/lib/insika/turn_state.rb +38 -1
  180. data/lib/insika/turn_timing.rb +22 -2
  181. data/lib/insika/version.rb +1 -1
  182. data/lib/insika/vitals.rb +84 -0
  183. data/lib/insika/wiring/graph.rb +300 -13
  184. data/lib/insika.rb +92 -3
  185. metadata +77 -1
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # the durable aggregates of the OUTCOME FUNNEL — per-day stage
7
+ # counts (the fold's cells), the fold cursor and the baseline snapshot.
8
+ # A dumb domain store: it holds no outcome_store and never folds (C4 owns the
9
+ # transformation), and it is recomputable by construction — the OutcomeStore
10
+ # stays the source of truth.
11
+ #
12
+ # Key shapes (string keys, Store-contract JSON):
13
+ # "funnel" "acme:store-support:2026-08-14" -> { "greeted" => 41, "paid" => 3 }
14
+ # "funnel_cursor" "acme:store-support" -> { "at" => ISO8601 | nil, "ids" => [uuid, …] }
15
+ # "funnel_baseline" "acme:store-support" -> { from/to/stages/primary/…/frozen_at }
16
+ #
17
+ # The no-tenant case is the literal "platform" (the outcome KEY's rule), so
18
+ # funnel and outcome keys share the same tenant segment and the purge prefix
19
+ # scan is the same string. A record's `tenant` field is `tenant.to_s` — "" for
20
+ # a single-tenant write — so every key-builder normalizes a blank tenant to
21
+ # "platform" HERE (one place), or a single-tenant fold lands in an ""-prefixed
22
+ # cell that purge("platform") never removes.
23
+ class FunnelStore
24
+ SCOPE = "funnel" # day cells
25
+ CURSOR_SCOPE = "funnel_cursor" # per (tenant, agent)
26
+ BASELINE_SCOPE = "funnel_baseline" # per (tenant, agent)
27
+
28
+ def initialize(store:)
29
+ @store = store
30
+ end
31
+
32
+ # Cumulative increment (D2): `counts` is the fold's { stage => 1 } hash for
33
+ # the reached prefix stages[0..index] — the STORE stays free of the
34
+ # declaration (D1), it only accumulates. Bumped in ONE transaction.
35
+ # -> { stage => count } the NEW day counts (string keys, declared order).
36
+ def add(tenant:, agent:, at:, counts:)
37
+ id = pair_id(tenant, agent)
38
+ day_key = day_segment(at)
39
+ @store.transaction do
40
+ cell = @store.get(SCOPE, "#{id}:#{day_key}") || {}
41
+ counts.each { |stage, n| cell[stage.to_s] = cell[stage.to_s].to_i + n }
42
+ @store.set(SCOPE, "#{id}:#{day_key}", cell)
43
+ cell
44
+ end
45
+ end
46
+
47
+ # -> { stage => count } | {} — one day's cell ("YYYY-MM-DD").
48
+ def day(tenant:, agent:, day:)
49
+ @store.get(SCOPE, "#{pair_id(tenant, agent)}:#{day}") || {}
50
+ end
51
+
52
+ # -> { "YYYY-MM-DD" => { stage => count } } sorted ascending, bounded by
53
+ # ISO-date strings from:/to: (inclusive). Empty hash when none.
54
+ def days(tenant:, agent:, from: nil, to: nil)
55
+ prefix = "#{pair_id(tenant, agent)}:"
56
+ @store.list(SCOPE).select { |k| k.start_with?(prefix) }.each_with_object({}) do |k, acc|
57
+ day = k.delete_prefix(prefix)
58
+ next unless within?(day, from, to)
59
+
60
+ acc[day] = @store.get(SCOPE, k)
61
+ end.sort.to_h
62
+ end
63
+
64
+ # The fold cursor of the pair. -> { "at" => String | nil, "ids" => [String] }
65
+ def cursor(tenant:, agent:)
66
+ record = @store.get(CURSOR_SCOPE, pair_id(tenant, agent))
67
+ { "at" => record && record["at"], "ids" => Array(record && record["ids"]) }
68
+ end
69
+
70
+ def set_cursor(tenant:, agent:, at:, ids:)
71
+ @store.set(CURSOR_SCOPE, pair_id(tenant, agent), { "at" => at, "ids" => Array(ids) })
72
+ end
73
+
74
+ # -> Hash | nil — the current baseline record (D5), read back verbatim.
75
+ def baseline(tenant:, agent:)
76
+ @store.get(BASELINE_SCOPE, pair_id(tenant, agent))
77
+ end
78
+
79
+ # Overwrites (D5 — one current snapshot per pair, no history).
80
+ def set_baseline(tenant:, agent:, record:)
81
+ @store.set(BASELINE_SCOPE, pair_id(tenant, agent), record)
82
+ end
83
+
84
+ # Purge one tenant: its day cells, cursors and baselines (DeleteTenantData,
85
+ # WS8 — the tenant is the FIRST key segment, so a prefix scan; the key IS
86
+ # the isolation). -> count removed.
87
+ def purge(tenant:)
88
+ prefix = "#{tenant_id(tenant)}:"
89
+ [SCOPE, CURSOR_SCOPE, BASELINE_SCOPE].sum do |scope|
90
+ keys = @store.list(scope).select { |k| k.start_with?(prefix) }
91
+ keys.each { |k| @store.delete(scope, k) }
92
+ keys.size
93
+ end
94
+ end
95
+
96
+ # Age-based prune of DAY CELLS ONLY (retention, WS8 — outcomes and their
97
+ # fold die together). The day is the key's last segment ("YYYY-MM-DD",
98
+ # lexicographic). Cursors/baselines are tiny and live while their agent
99
+ # does. -> count removed.
100
+ def delete_older_than(time)
101
+ cutoff = time.utc.strftime("%Y-%m-%d")
102
+ removed = 0
103
+ @store.list(SCOPE).each do |k|
104
+ day = k.rpartition(":").last
105
+ next unless day < cutoff
106
+
107
+ @store.delete(SCOPE, k)
108
+ removed += 1
109
+ end
110
+ removed
111
+ end
112
+
113
+ # wipes ONE pair's day cells — the recompute repair path (the
114
+ # fold rebuilds them from the outcome store, so it must start from zero,
115
+ # never sum on top). Same tenant normalization as every other key builder
116
+ # (`""`/nil/"platform" all reach the "platform" segment). -> count removed.
117
+ def delete_days(tenant:, agent:)
118
+ prefix = "#{pair_id(tenant, agent)}:"
119
+ keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
120
+ keys.each { |k| @store.delete(SCOPE, k) }
121
+ keys.size
122
+ end
123
+
124
+ # The pairs that have any day cell — the Studio's derived drill (D7).
125
+ # -> [{ tenant: String | nil, agent: String }]
126
+ def pairs
127
+ @store.list(SCOPE).map { |k| pair_of(k) }.uniq
128
+ end
129
+
130
+ private
131
+
132
+ def pair_id(tenant, agent)
133
+ "#{tenant_id(tenant)}:#{agent}"
134
+ end
135
+
136
+ # The literal "platform" for a blank tenant — the outcome KEY's rule
137
+ # (outcome_store.rb's key), normalized in ONE place.
138
+ def tenant_id(tenant)
139
+ t = tenant.to_s
140
+ t.empty? ? "platform" : t
141
+ end
142
+
143
+ def day_segment(at)
144
+ t = at.is_a?(Time) ? at : Time.parse(at.to_s)
145
+ t.utc.strftime("%Y-%m-%d")
146
+ end
147
+
148
+ # Tolerant like the series reads: a malformed bound (not an ISO date) reads
149
+ # as unbounded — never a crash, never a silent empty window.
150
+ DATE_RE = /\A\d{4}-\d{2}-\d{2}\z/
151
+
152
+ def within?(day, from, to)
153
+ lo = DATE_RE.match?(from.to_s) ? from.to_s : nil
154
+ hi = DATE_RE.match?(to.to_s) ? to.to_s : nil
155
+ (lo.nil? || day >= lo) && (hi.nil? || day <= hi)
156
+ end
157
+
158
+ def pair_of(key)
159
+ tenant, agent, = key.split(":", 3)
160
+ { tenant: tenant == "platform" ? nil : tenant, agent: agent }
161
+ end
162
+ end
163
+ end
@@ -20,7 +20,7 @@ module Insika
20
20
  # validated by `Evals::GoldenLoader.build` — the one validator, so a case authored in
21
21
  # the Studio and a case read from disk cannot diverge. `evals/golden/**` stays the
22
22
  # export format and the seed for a fresh deploy (`insika evals:import`), for the same
23
- # reason the knowledge RFC keeps markdown as both: no converter to keep honest.
23
+ # reason the knowledge base keeps markdown as both: no converter to keep honest.
24
24
  #
25
25
  # No version history here, unlike the prompt/skill stores: the corpus on disk IS the
26
26
  # backup, and re-importing restores it.
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../coercion"
4
+
5
+ module Insika
6
+ # — the pack's deterministic claim extractor + membership check.
7
+ # The pack's DATA (D7): a `sku` regex for the SKU shape. The engine only
8
+ # APPLIES it — it never guesses what a SKU looks like. No NLP, no LLM, no gem.
9
+ #
10
+ # REVIEW-DECISION (rfc-0029 v2): grounding is **SKU-only**. The techspec's
11
+ # name half (name_keys indexing ledger lines to flag names) cannot flag
12
+ # anything: a name found in the text that also came from the ledger is grounded
13
+ # by construction, and detecting a name that matches NO ledger line needs a
14
+ # "this is a product name" signal that does not exist without NLP. It was
15
+ # decorative config that lied — cut, not half-fixed. The ledger keeps ids;
16
+ # `line` stays in the lean envelope for the model's context, it just does not
17
+ # feed the matcher.
18
+ class GroundingMatcher
19
+ # Catastrophic-backtracking cap. A pathological pattern from the pack would
20
+ # otherwise spin the reactor's thread in C — a rescue cannot save a hang, and
21
+ # with_timeout cannot preempt a regex. The final answer is short; 1s is a
22
+ # hard ceiling, not a budget.
23
+ REGEX_TIMEOUT = 1.0
24
+
25
+ def initialize(sku: nil)
26
+ @sku = sku && Regexp.new(sku, timeout: REGEX_TIMEOUT) # validated at build; a re-raise here is a bug
27
+ end
28
+
29
+ # Is a SKU pattern actually configured? (The doctor's warning and the
30
+ # harvest's D3 refusal ask the same question — a matcher without a sku
31
+ # matches nothing and can verify no claim.)
32
+ def sku? = !@sku.nil?
33
+
34
+ # raw (the pack's matcher Hash) -> GroundingMatcher. The `sku` must compile
35
+ # and be length-capped. Raises ValidationError.
36
+ def self.build(raw)
37
+ h = Coercion.deep_stringify(raw || {})
38
+ sku = Coercion.presence(h["sku"])
39
+ if sku
40
+ begin
41
+ Regexp.new(sku)
42
+ rescue RegexpError
43
+ raise Insika::ValidationError, "grounding.matcher.sku does not compile: #{sku.inspect}"
44
+ end
45
+ if sku.bytesize > Grounding::SKU_MAX
46
+ raise Insika::ValidationError, "grounding.matcher.sku exceeds #{Grounding::SKU_MAX} chars"
47
+ end
48
+ end
49
+ new(sku: sku)
50
+ end
51
+
52
+ # -> [String] SKU-pattern matches in the text, deduped. Capture-group safe:
53
+ # scans the FULL match (Regexp.last_match(0)), so a pack pattern with groups
54
+ # or alternation can never inject nils or empty captures into the references
55
+ # (a nil claim would corrupt the flag detail and crash the sentence cut).
56
+ def references(text)
57
+ return [] unless @sku
58
+
59
+ text.to_enum(:scan, @sku).map { Regexp.last_match(0) }.uniq
60
+ end
61
+
62
+ # -> [String] references NOT in the evidence set. The SET is the ledger ids
63
+ # — a claim quoting a ledgered id is grounded; everything else flagged/cut.
64
+ def ungrounded(references, evidence_ids:)
65
+ known = Set.new(evidence_ids.map(&:to_s))
66
+ references.reject { |r| known.include?(r) }
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "coercion"
4
+ require_relative "grounding/matcher"
5
+
6
+ module Insika
7
+ # — the profile-facing half of grounding: parse the pack's
8
+ # `grounding` data into the per-turn Grounding object (mode + matcher).
9
+ #
10
+ # Data (on the AgentProfile):
11
+ #
12
+ # { "mode" => "flag"|"enforce"|"off",
13
+ # "matcher" => { "sku" => "\b[A-Z]{2,4}\d{4,8}\b" } }
14
+ #
15
+ # Absent = the whole feature is OFF (parity). The matcher is built ONCE per
16
+ # turn (profile parse) and cached on the Grounding object. A `sku` that does
17
+ # not compile, or exceeds 256 chars, is a ValidationError at build — the
18
+ # engine refuses only uncompileable data; a matcher with no sku matches
19
+ # nothing (grounding on is harmless but useless; doctor warns).
20
+ class Grounding
21
+ MODES = %w[flag enforce off].freeze
22
+ SKU_MAX = 256
23
+
24
+ attr_reader :mode, :matcher
25
+
26
+ # raw: the profile's `grounding` Hash | nil | false -> Grounding | nil.
27
+ # nil/false = off. A non-Hash (e.g. true) reads as an empty config.
28
+ def self.parse(raw)
29
+ return nil if raw.nil? || raw == false
30
+
31
+ h = Coercion.deep_stringify(raw.is_a?(Hash) ? raw : {})
32
+ mode = MODES.include?(h["mode"].to_s) ? h["mode"].to_s : "flag" # default :flag
33
+ new(mode: mode, matcher: GroundingMatcher.build(h["matcher"]))
34
+ end
35
+
36
+ def initialize(mode:, matcher:)
37
+ @mode = mode
38
+ @matcher = matcher
39
+ end
40
+
41
+ def enforce? = mode == "enforce"
42
+ def off? = mode == "off"
43
+ end
44
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+
6
+ module Insika
7
+ module Harvest
8
+ # C8 — the second gate: the store's target METRIC RATE over
9
+ # the criterion's window, compared to the frozen baseline's rate. Outcome
10
+ # is EVIDENCE — the only verdict this object can produce is "the store is
11
+ # measurably worse than the accepted state" and "there is nothing to
12
+ # compare against".
13
+ #
14
+ # The ruler is a RATE (metric ÷ first stage), never a total: the frozen
15
+ # baseline covers a ≥ 28-day span while the window is 72 h — comparing the
16
+ # raw counts would flag a store that tripled its traffic as "worse" (fewer
17
+ # absolute sales in 3 days than in 28). Both sides fold the same
18
+ # denominator, so the comparison measures the per-unit health, which is
19
+ # what the audit can actually read back.
20
+ #
21
+ # Refuse-with-a-named-reason, never pass, on missing data (the P18 lesson
22
+ # applied to the second ruler): no frozen baseline, no criterion, no
23
+ # funnel store, a fold that has not converged, a frozen span shorter than
24
+ # the criterion's `min_span`, a baseline whose rate cannot be computed.
25
+ class ConversionGate
26
+ Result = Data.define(:passed, :reason, :metric, :window, :current,
27
+ :baseline, :threshold, :snapshot_ref) do
28
+ # The candidate's conversion_gate record — the operator's review card
29
+ # shows the ruler (both rates), never just a verdict.
30
+ def to_h
31
+ { "passed" => passed, "reason" => reason&.to_s, "metric" => metric,
32
+ "window" => window, "current" => current, "baseline" => baseline,
33
+ "threshold" => threshold, "snapshot_ref" => snapshot_ref }
34
+ end
35
+ end
36
+
37
+ # funnel_store: the FunnelStore (nil = the gate REFUSES — a
38
+ # store without a ruler cannot promote on outcome, D6).
39
+ # criterion: the boot-loaded Harvest::Criterion (nil = refuse).
40
+ def initialize(funnel_store:, criterion:)
41
+ @funnel_store = funnel_store
42
+ @criterion = criterion
43
+ end
44
+
45
+ # -> Result. NEVER passes on missing data (D6).
46
+ def call(tenant:, agent:)
47
+ return refuse(:no_criterion) if @criterion.nil?
48
+ return refuse(:no_funnel) if @funnel_store.nil?
49
+
50
+ baseline = @funnel_store.baseline(tenant: tenant, agent: agent)
51
+ return refuse(:no_frozen_baseline) if baseline.nil?
52
+
53
+ # The criterion's metric is a stage NAME from the declaration
54
+ # (D5). The seed token "primary" resolves to the frozen snapshot's
55
+ # primary stage — the declaration's own vocabulary, never a gem
56
+ # constant .
57
+ metric = resolve_metric(@criterion.rule.metric, baseline)
58
+ return refuse(:metric_mismatch) if metric != baseline["primary"].to_s
59
+
60
+ # min_span is LIVE (D5): the frozen baseline must cover the span the
61
+ # criterion pre-registered — a number nobody re-froze to a shorter
62
+ # span is not a number the gate may compare against.
63
+ return refuse(:baseline_span_short) if span_short?(baseline, @criterion.rule.min_span)
64
+
65
+ baseline_rate = rate_of(baseline["stages"] || {}, metric)
66
+ # A baseline whose conversion was nil when frozen (no first-stage
67
+ # events) cannot be the accepted state's ruler.
68
+ return refuse(:no_baseline_rate) if baseline_rate.nil?
69
+
70
+ window_hours = @criterion.rule.window.to_s.delete_suffix("h").to_i
71
+ window_stages = fold_window(tenant: tenant, agent: agent, hours: window_hours)
72
+ return refuse(:no_fold) if window_stages.empty?
73
+
74
+ current_rate = rate_of(window_stages, metric)
75
+ # The window folded no comparable denominator (no first-stage events
76
+ # over the window) — there is nothing to compare.
77
+ return refuse(:no_fold) if current_rate.nil?
78
+
79
+ threshold = @criterion.rule.threshold.to_f
80
+ snapshot_ref = "funnel:#{tenant_id(tenant)}:#{agent}:#{baseline['frozen_at']}"
81
+
82
+ if current_rate >= baseline_rate * (1 - threshold)
83
+ Result.new(passed: true, reason: nil, metric: metric,
84
+ window: @criterion.rule.window.to_s, current: current_rate,
85
+ baseline: baseline_rate, threshold: threshold,
86
+ snapshot_ref: snapshot_ref)
87
+ else
88
+ Result.new(passed: false, reason: :conversion_down,
89
+ metric: metric, window: @criterion.rule.window.to_s,
90
+ current: current_rate, baseline: baseline_rate,
91
+ threshold: threshold, snapshot_ref: snapshot_ref)
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def refuse(reason)
98
+ Result.new(passed: false, reason: reason,
99
+ metric: @criterion&.rule&.metric.to_s,
100
+ window: @criterion&.rule&.window.to_s,
101
+ current: nil, baseline: nil,
102
+ threshold: @criterion&.rule&.threshold,
103
+ snapshot_ref: nil)
104
+ end
105
+
106
+ # "primary" (the seed token) = the frozen snapshot's primary stage;
107
+ # anything else is a literal stage name.
108
+ def resolve_metric(token, baseline)
109
+ token.to_s == "primary" ? baseline["primary"].to_s : token.to_s
110
+ end
111
+
112
+ # metric ÷ FIRST stage — the same denominator both sides fold, so the
113
+ # totals' different spans cancel out. nil when the denominator is zero
114
+ # (the ruler cannot be expressed).
115
+ def rate_of(stages, metric)
116
+ first = stages.keys.first
117
+ denom = stages[first].to_f
118
+ return nil if denom.zero?
119
+
120
+ stages[metric].to_f / denom
121
+ end
122
+
123
+ # "28d" -> 28. The frozen span (from/to) must cover it — FreezeFunnelBaseline
124
+ # enforces the same number at freeze time; this is the gate's own check
125
+ # against a hand-written baseline or a moved criterion.
126
+ def span_short?(baseline, min_span)
127
+ return false if min_span.to_s.empty?
128
+
129
+ from = baseline["from"].to_s
130
+ to = baseline["to"].to_s
131
+ return true if from.empty? || to.empty?
132
+
133
+ days = (Date.iso8601(to) - Date.iso8601(from)).to_i
134
+ days < min_span.to_s.delete_suffix("d").to_i
135
+ rescue Date::Error
136
+ true
137
+ end
138
+
139
+ # The criterion's window folded over the day cells (the
140
+ # read) — the fold's day cells SUMMED by stage, so the gate can build
141
+ # the window's rate the same way the baseline built its own.
142
+ # -> { stage => count }
143
+ def fold_window(tenant:, agent:, hours:)
144
+ now = Time.now.utc
145
+ from = (now - hours * 3600).strftime("%Y-%m-%d")
146
+ to = now.strftime("%Y-%m-%d")
147
+ @funnel_store.days(tenant: tenant, agent: agent, from: from, to: to)
148
+ .each_with_object(Hash.new(0)) do |(_day, counts), acc|
149
+ counts.each { |stage, n| acc[stage] += n }
150
+ end
151
+ end
152
+
153
+ def tenant_id(tenant)
154
+ t = tenant.to_s
155
+ t.empty? ? "platform" : t
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "yaml"
5
+
6
+ module Insika
7
+ module Harvest
8
+ # C4 — the frozen conversion criterion, parsed from the deployment's
9
+ # criterion file. The parity shape (Parity::Criterion) copied: one
10
+ # file, one ```yaml fence, the WHOLE bytes hashed (an edit to the rationale
11
+ # invalidates the frozen rule too), strict keys, no defaults. A skill may
12
+ # land only when the store's ruler is not measurably worse than the
13
+ # accepted state — the FILE is the criterion; a profile carries no
14
+ # thresholds, and `GateHarvest`/`PromoteHarvest` receive the loaded
15
+ # criterion (with its sha) at boot.
16
+ class Criterion
17
+ Rule = Data.define(:version, :metric, :window, :threshold, :min_span)
18
+
19
+ attr_reader :rule, :path, :sha
20
+
21
+ def initialize(rule:, path:, sha:)
22
+ @rule = rule
23
+ @path = path
24
+ @sha = sha
25
+ end
26
+
27
+ # Reads the file, extracts the FIRST ```yaml fence, validates STRICTLY.
28
+ # Raises Insika::ConfigError when the file is absent or has no block;
29
+ # Insika::ValidationError on an unknown or missing key, or a malformed
30
+ # value — every defect named.
31
+ def self.load(path)
32
+ bytes = read_bytes(path)
33
+ block = extract_yaml_block(bytes)
34
+ parsed = YAML.safe_load(block, permitted_classes: [], aliases: false)
35
+ rule = build_rule(parsed)
36
+ new(rule: rule, path: path, sha: "sha256:#{Digest::SHA256.hexdigest(bytes)}")
37
+ end
38
+
39
+ def to_h = @rule.to_h
40
+
41
+ class << self
42
+ private
43
+
44
+ def read_bytes(path)
45
+ File.read(path, encoding: "UTF-8")
46
+ rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => e
47
+ raise Insika::ConfigError, "harvest criterion not readable at #{path}: #{e.class}"
48
+ end
49
+
50
+ def extract_yaml_block(bytes)
51
+ match = bytes.match(/```yaml\n(.*?)\n```/m)
52
+ raise Insika::ConfigError, "harvest criterion has no ```yaml block (nothing to pre-register)" unless match
53
+
54
+ match[1]
55
+ end
56
+
57
+ def build_rule(parsed)
58
+ raise Insika::ValidationError, "criterion yaml block must be a mapping" unless parsed.is_a?(Hash)
59
+
60
+ raw = parsed.transform_keys(&:to_s)
61
+ keys = raw.keys.sort
62
+ missing = Rule.members.map(&:to_s) - keys
63
+ unknown = keys - Rule.members.map(&:to_s)
64
+ raise Insika::ValidationError, "harvest criterion is missing key(s): #{missing.join(', ')}" if missing.any?
65
+ raise Insika::ValidationError, "harvest criterion has unknown key(s): #{unknown.join(', ')}" if unknown.any?
66
+
67
+ validate!(raw)
68
+ Rule.new(**Rule.members.to_h { |m| [m, raw[m.to_s]] })
69
+ end
70
+
71
+ # Each defect named — a criterion nobody can read is a criterion
72
+ # nobody froze (D5).
73
+ def validate!(raw)
74
+ version = raw["version"]
75
+ raise Insika::ValidationError, "harvest criterion version must be an Integer (got #{version.inspect})" unless version.is_a?(Integer)
76
+
77
+ metric = raw["metric"].to_s
78
+ raise Insika::ValidationError, "harvest criterion metric must be a non-blank string" if metric.empty?
79
+
80
+ window = raw["window"].to_s
81
+ unless window.match?(/\A\d+h\z/)
82
+ raise Insika::ValidationError, "harvest criterion window must match /\\A\\d+h\\z/ (got #{raw['window'].inspect})"
83
+ end
84
+
85
+ min_span = raw["min_span"].to_s
86
+ unless min_span.match?(/\A\d+d\z/)
87
+ raise Insika::ValidationError, "harvest criterion min_span must match /\\A\\d+d\\z/ (got #{raw['min_span'].inspect})"
88
+ end
89
+
90
+ threshold = raw["threshold"]
91
+ unless threshold.is_a?(Float) && threshold >= 0 && threshold <= 1
92
+ raise Insika::ValidationError, "harvest criterion threshold must be a Float in 0..1 (got #{threshold.inspect})"
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end