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,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # WS7: business outcomes over real traffic, recorded per conversation by the
8
+ # OPERATOR or the integration (`POST /v1/outcomes`) — `conversion`,
9
+ # `escalation`, `deflected`, … optionally with a monetary `value`. The engine
10
+ # TRANSPORTS the outcome and never interprets it (the same rule as alerts):
11
+ # what "conversion" means is the operator's. Durable in the KV backend,
12
+ # tenant-scoped (WS1) — a tenant can only ever read/write its own cells (the
13
+ # key carries the tenant, and the read methods narrow on it).
14
+ #
15
+ # The Studio's scorecard is the consumer: the LAST outcome per agent as a
16
+ # pill on the grid, and the per-day series on the agent detail — both fed
17
+ # from the same store, no scheduler.
18
+ class OutcomeStore
19
+ SCOPE = "outcomes"
20
+
21
+ Record = Data.define(:id, :tenant, :agent, :session_id, :outcome, :value, :at)
22
+
23
+ def initialize(store:)
24
+ @store = store
25
+ end
26
+
27
+ # -> Record. `at` defaults to now (UTC); `value` defaults to 0.0 (an
28
+ # outcome without a value is a unit outcome).
29
+ def create(tenant:, agent:, session_id: nil, outcome:, value: nil, id: SecureRandom.uuid, at: Time.now.utc)
30
+ time = at.utc
31
+ record = { "id" => id, "tenant" => tenant.to_s, "agent" => agent.to_s,
32
+ "session_id" => session_id.to_s, "outcome" => outcome.to_s,
33
+ "value" => value.to_f, "at" => time.iso8601 }
34
+ @store.set(SCOPE, key(tenant, agent, time, id), record)
35
+ to_record(record)
36
+ end
37
+
38
+ # All records, NEWEST first. `tenant:` narrows to one tenant's (WS1 — the
39
+ # read path a tenant query uses); `agent:` narrows further. An outcome is a
40
+ # fact, never a secret: the fields are ids, an outcome name and a number.
41
+ # NOTE: `tenant: nil` means NO filter — the record's tenant field is
42
+ # `tenant.to_s` ("" for a single-tenant write), so a caller that wants only
43
+ # the no-tenant records must pass `tenant: ""` explicitly (the FOLD uses
44
+ # `for_pair`, never this nil-means-everything trap).
45
+ def all(tenant: nil, agent: nil)
46
+ records = @store.list(SCOPE).filter_map { |k| to_record(@store.get(SCOPE, k)) }
47
+ records.select! { |r| r.tenant == tenant } if tenant
48
+ records.select! { |r| r.agent == agent } if agent
49
+ records.sort_by(&:at).reverse
50
+ end
51
+
52
+ # the fold's per-pair read — one KEY-prefix scan (the key IS
53
+ # tenant:agent:YYYY-MM-DD:id, WS1), optionally skipping the keys older than
54
+ # an ISO date WITHOUT reading them (the fold's cursor day — everything
55
+ # before it is already folded). `tenant:` takes either spelling (nil/""
56
+ # and "platform" all mean the no-tenant key segment). -> [Record].
57
+ def for_pair(tenant:, agent:, since_date: nil)
58
+ prefix = "#{key_tenant(tenant)}:#{agent}:"
59
+ keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
60
+ keys.select! { |k| date_segment(k) >= since_date } if since_date
61
+ keys.filter_map { |k| to_record(@store.get(SCOPE, k)) }
62
+ end
63
+
64
+ # the distinct (tenant, agent) pairs present in the store —
65
+ # one key scan, no record reads. `tenant` is nil for the no-tenant segment
66
+ # (the FunnelStore#pairs spelling; every key-based API normalizes it back).
67
+ # -> [{ tenant: String | nil, agent: String }]
68
+ def pairs
69
+ @store.list(SCOPE).map { |k| pair_of(k) }.uniq
70
+ end
71
+
72
+ # -> { agent => { outcome:, value:, at:, session_id: } }: the LAST outcome
73
+ # per agent — the Studio's scorecard state card. A map so the agent grid
74
+ # looks up in O(1) per card.
75
+ def latest_per_agent(tenant: nil)
76
+ all(tenant: tenant).each_with_object({}) do |r, acc|
77
+ acc[r.agent] ||= { outcome: r.outcome, value: r.value, at: r.at,
78
+ session_id: r.session_id }
79
+ end
80
+ end
81
+
82
+ # -> { "YYYY-MM-DD" => { outcome => { count:, value: } } } — per-period
83
+ # series for the Studio. `period: :month` buckets by "YYYY-MM".
84
+ def series(tenant: nil, agent: nil, period: :day)
85
+ all(tenant: tenant, agent: agent).each_with_object({}) do |r, acc|
86
+ bucket = period == :month ? r.at[0, 7] : r.at[0, 10]
87
+ cell = ((acc[bucket] ||= {})[r.outcome] ||= { count: 0, value: 0.0 })
88
+ cell[:count] += 1
89
+ cell[:value] += r.value
90
+ end
91
+ end
92
+
93
+ # Purges a tenant's records (WS8 phase 2 — delete_tenant_data). The tenant
94
+ # is the FIRST key segment, so the purge is a prefix scan — the key IS the
95
+ # isolation (WS1). -> count removed.
96
+ def purge(tenant:)
97
+ prefix = "#{tenant}:"
98
+ keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
99
+ keys.each { |k| @store.delete(SCOPE, k) }
100
+ keys.size
101
+ end
102
+
103
+ # Purges records older than the cutoff (WS8 retention — the tick's sweep).
104
+ # `at` is ISO8601 UTC, so the comparison is lexicographic. -> count removed.
105
+ def delete_older_than(time)
106
+ cutoff = time.utc.iso8601
107
+ removed = 0
108
+ @store.list(SCOPE).each do |k|
109
+ rec = @store.get(SCOPE, k)
110
+ next unless rec && rec["at"].to_s < cutoff
111
+
112
+ @store.delete(SCOPE, k)
113
+ removed += 1
114
+ end
115
+ removed
116
+ end
117
+
118
+ private
119
+
120
+ def key_tenant(tenant) = tenant.to_s.empty? ? "platform" : tenant.to_s
121
+
122
+ # The date segment of the key (the 3rd segment, 0-indexed: 2).
123
+ def date_segment(k) = k.split(":").fetch(2, "")
124
+
125
+ # Parses the key's tenant segment back: "platform" -> nil (the
126
+ # FunnelStore#pairs spelling); anything else stays.
127
+ def pair_of(k)
128
+ tenant, agent, = k.split(":")
129
+ { tenant: tenant == "platform" ? nil : tenant, agent: agent }
130
+ end
131
+
132
+ def key(tenant, agent, time, id)
133
+ # tenant + agent + UTC date prefix: per-period / per-agent listing is a
134
+ # prefix scan, and the tenant IS the first segment — WS1 isolation is the
135
+ # key itself, like the session namespace.
136
+ "#{(tenant || 'platform')}:#{agent}:#{time.strftime('%Y-%m-%d')}:#{id}"
137
+ end
138
+
139
+ def to_record(rec)
140
+ return nil if rec.nil?
141
+
142
+ Record.new(id: rec["id"], tenant: rec["tenant"], agent: rec["agent"],
143
+ session_id: rec["session_id"], outcome: rec["outcome"],
144
+ value: rec["value"].to_f, at: rec["at"])
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # The single source of the gem payload selection. The
5
+ # gemspec and the domain-boundary audit spec call THE SAME function, so
6
+ # "what ships in the gem" is a fact the suite asserts on, never a prose
7
+ # promise: `git ls-files` (fallback: a glob where there is no .git) filtered
8
+ # to lib/ + docs/ + the four root files, minus the never-ship set.
9
+ #
10
+ # Pure and dependency-free (no git gem, no FileUtils beyond reads) so both
11
+ # the gemspec (built outside the app) and the spec can load it. It does NOT
12
+ # ship anything itself — it answers a list.
13
+ #
14
+ # `domain_content?` is the audit's yardstick: best-effort detection of pt-BR
15
+ # domain content in a payload file. Ruby files are scanned for their string
16
+ # literals, regex literals and heredoc bodies (comments stripped);
17
+ # everything else is scanned as raw text. The token set is the pt-BR
18
+ # vocabulary + the demo persona name (PT_BR_TOKENS). The corpus data files
19
+ # and the audit's own token table live in the spec's named allowlist.
20
+ module Packaging
21
+ # The pt-BR domain vocabulary the audit scans for. `loja`/`shop`/`store`
22
+ # are generic-retail stopwords — the ONE justification the inventory
23
+ # allows (D3); `bia` is the demo persona name (D8).
24
+ #
25
+ # The scan is FOLD-aware: both the scanned text and these words go through
26
+ # `fold` (accents stripped, regex bracket classes like `voc[êe]` collapsed
27
+ # to their canonical chars) before matching. That is what makes the
28
+ # shipped corpus data — whose patterns encode pt-BR as `voc[êe]`,
29
+ # `instru[çc][õo]es` — detectable by the same token table (D1: the
30
+ # boundary holds for the content the inventory names).
31
+ ACCENT_MAP = {
32
+ "á" => "a", "à" => "a", "â" => "a", "ã" => "a", "ä" => "a",
33
+ "é" => "e", "è" => "e", "ê" => "e", "ë" => "e",
34
+ "í" => "i", "ì" => "i", "î" => "i", "ï" => "i",
35
+ "ó" => "o", "ò" => "o", "ô" => "o", "õ" => "o", "ö" => "o",
36
+ "ú" => "u", "ù" => "u", "û" => "u", "ü" => "u",
37
+ "ç" => "c"
38
+ }.freeze
39
+ BRACKET_CLASS = /\[([^\]]*)\]/
40
+
41
+ # -> String: the canonical form the token match runs on. A bracket class
42
+ # folds to the deduped canonical chars of its members — `voc[êe]` folds
43
+ # to "voce", matching the folded token "voce" (você).
44
+ def self.fold(text)
45
+ text.to_s.gsub(BRACKET_CLASS) do
46
+ Regexp.last_match(1).chars.map { |c| ACCENT_MAP.fetch(c, c) }.uniq.join
47
+ end.gsub(/[#{ACCENT_MAP.keys.join}]/, ACCENT_MAP)
48
+ end
49
+
50
+ TOKEN_WORDS = ["você", "não", "loja", "pedido", "atendente", "obrigad",
51
+ "conversa", "cliente"].freeze
52
+ PT_BR_VOCABULARY = /\b(?:#{TOKEN_WORDS.map { |w| fold(w) }.join("|")})\b/i
53
+ PERSONA_NAME = /\bbia\b/i
54
+ PT_BR_TOKENS = /(?:#{PT_BR_VOCABULARY.source}|#{PERSONA_NAME.source})/i
55
+
56
+ # The never-ship set: the studio JS toolchain (only assets/dist ships) and
57
+ # the docs' own Jekyll Gemfile/config.
58
+ NEVER_SHIP_PREFIXES = ["node_modules", "lib/insika/studio/test/", "lib/insika/studio/assets/src/"].freeze
59
+ NEVER_SHIP_PATHS = %w[
60
+ docs/Gemfile docs/Gemfile.lock docs/_config.yml
61
+ lib/insika/studio/README.md lib/insika/studio/package.json
62
+ lib/insika/studio/package-lock.json lib/insika/studio/tailwind.config.js
63
+ ].freeze
64
+
65
+ module_function
66
+
67
+ # -> [String] repo-relative file paths the gem ships, in the gemspec's
68
+ # order: `git ls-files` (fallback: glob where there is no .git), filtered
69
+ # to lib/ + docs/ + the four root files, minus the never-ship set.
70
+ def payload_files(root = Dir.pwd)
71
+ Dir.chdir(root) do
72
+ tracked = `git ls-files -z 2>/dev/null`.split("\x0")
73
+ if tracked.empty?
74
+ tracked = Dir.glob("{lib,docs}/**/*", File::FNM_DOTMATCH).reject { |f| File.directory?(f) } +
75
+ %w[README.md LICENSE CHANGELOG.md bin/insika]
76
+ end
77
+ tracked.select { |file| payload_path?(file) }.reject { |file| excluded?(file) }
78
+ end
79
+ end
80
+
81
+ # -> bool: is this repo-relative path part of the payload selection?
82
+ def payload_path?(file)
83
+ file.start_with?("lib/", "docs/") || %w[README.md LICENSE CHANGELOG.md bin/insika].include?(file)
84
+ end
85
+
86
+ # -> bool: is this file in the never-ship set?
87
+ def excluded?(file)
88
+ NEVER_SHIP_PREFIXES.any? { |p| file.include?(p) } || NEVER_SHIP_PATHS.include?(file)
89
+ end
90
+
91
+ # -> bool: does a payload file hold pt-BR domain content? Best-effort:
92
+ # - Ruby files: comments stripped, string literals / regex literals /
93
+ # heredoc bodies scanned;
94
+ # - everything else: scanned as-is.
95
+ # The token set is the pt-BR vocabulary + the demo persona name; both the
96
+ # text and the vocabulary are FOLDED before matching (accents stripped,
97
+ # bracket classes collapsed), so the corpus data's `voc[êe]`-style
98
+ # patterns are caught by the same table.
99
+ def domain_content?(path)
100
+ text = path.to_s.end_with?(".rb") ? ruby_text(path) : File.read(path)
101
+ fold(text).match?(PT_BR_TOKENS)
102
+ end
103
+
104
+ # -> bool: does a payload file mention the demo persona name (`bia`)?
105
+ def persona_content?(path)
106
+ text = path.to_s.end_with?(".rb") ? ruby_text(path) : File.read(path)
107
+ text.match?(PERSONA_NAME)
108
+ end
109
+
110
+ # The Ruby source as the audit reads it: heredoc bodies verbatim, string +
111
+ # regex literals, comments stripped. Best-effort by design (D1).
112
+ def ruby_text(path)
113
+ lines = File.readlines(path)
114
+ out = +""
115
+ heredoc = nil
116
+ lines.each do |line|
117
+ if heredoc
118
+ out << line
119
+ heredoc = nil if line.strip == heredoc
120
+ next
121
+ end
122
+ if (m = line.match(HEREDOC_OPEN))
123
+ heredoc = m[1] || m[2] || m[3]
124
+ next
125
+ end
126
+ code = strip_comments(line)
127
+ out << code.scan(STRING_LITERAL).join("\n")
128
+ out << code.scan(REGEX_LITERAL).join("\n")
129
+ end
130
+ out
131
+ end
132
+
133
+ # Cuts a line at its first `#` that starts a COMMENT — i.e. a `#` NOT
134
+ # inside a string/regex literal (an interpolation `#{…}` or a literal `#`
135
+ # must survive; a naive strip would corrupt the literal and hide content).
136
+ def strip_comments(line)
137
+ spans = literal_spans(line)
138
+ cut = nil
139
+ line.chars.each_index do |i|
140
+ next unless line[i] == "#"
141
+ next if spans.any? { |b, e| i >= b && i < e }
142
+
143
+ cut = i
144
+ break
145
+ end
146
+ cut ? line[0...cut] : line
147
+ end
148
+
149
+ # -> [[begin, end), …] spans of the complete string/regex literals in a line.
150
+ def literal_spans(line)
151
+ spans = []
152
+ line.scan(STRING_LITERAL) { spans << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
153
+ line.scan(REGEX_LITERAL) { spans << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
154
+ spans
155
+ end
156
+
157
+ HEREDOC_OPEN = /<<[~-]\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z_]\w*))(?:\s*\.\w+)*\s*\z/
158
+ STRING_LITERAL = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/
159
+ REGEX_LITERAL = %r{\/(?:[^\/\\\n]|\\.)+\/[a-z]*}
160
+
161
+ private_class_method :payload_path?, :excluded?, :ruby_text, :strip_comments, :literal_spans
162
+ end
163
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "yaml"
5
+
6
+ module Insika
7
+ module Parity
8
+ # C5 — the frozen criterion, parsed from the deployment's criterion file.
9
+ # The prose a human reads and the yaml block the machine applies are the SAME
10
+ # file, so there is exactly one place to edit — and the file's WHOLE bytes are
11
+ # hashed, so an edit to the rationale invalidates the frozen rule too, which is
12
+ # correct: the rationale is what makes the numbers reviewable.
13
+ #
14
+ # Strict by construction: a missing key or an unknown key is refused at load
15
+ # (`ValidationError`), and no key has a default — a number the machine filled in
16
+ # is a number nobody pre-registered.
17
+ class Criterion
18
+ Rule = Data.define(
19
+ :version, :unit, :window_days, :pairs_per_day, :min_decided,
20
+ :min_judge_models, :both_orders,
21
+ :win_or_tie_floor, :estimator,
22
+ :worse_rate_ceiling, :undecided_rate_ceiling, :incomplete_rate_ceiling,
23
+ :per_agent_min_decided, :per_agent_win_or_tie_floor,
24
+ :human_assisted, :silent
25
+ )
26
+
27
+ attr_reader :rule, :path, :sha
28
+
29
+ def initialize(rule:, path:, sha:)
30
+ @rule = rule
31
+ @path = path
32
+ @sha = sha
33
+ end
34
+
35
+ # Reads the file, extracts the FIRST ```yaml fence, validates STRICTLY.
36
+ # Raises Insika::ConfigError when the file is absent or has no block;
37
+ # Insika::ValidationError on an unknown or missing key.
38
+ def self.load(path)
39
+ bytes = read_bytes(path)
40
+ block = extract_yaml_block(bytes)
41
+ parsed = YAML.safe_load(block, permitted_classes: [], aliases: false)
42
+ rule = build_rule(parsed)
43
+ new(rule: rule, path: path, sha: "sha256:#{Digest::SHA256.hexdigest(bytes)}")
44
+ end
45
+
46
+ def to_h = @rule.to_h
47
+
48
+ class << self
49
+ private
50
+
51
+ def read_bytes(path)
52
+ File.read(path, encoding: "UTF-8")
53
+ rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => e
54
+ raise Insika::ConfigError, "parity criterion not readable at #{path}: #{e.class}"
55
+ end
56
+
57
+ def extract_yaml_block(bytes)
58
+ match = bytes.match(/```yaml\n(.*?)\n```/m)
59
+ raise Insika::ConfigError, "parity criterion has no ```yaml block (nothing to pre-register)" unless match
60
+
61
+ match[1]
62
+ end
63
+
64
+ def build_rule(parsed)
65
+ raise Insika::ValidationError, "criterion yaml block must be a mapping" unless parsed.is_a?(Hash)
66
+
67
+ raw = parsed.transform_keys(&:to_s)
68
+ keys = raw.keys.sort
69
+ missing = Rule.members.map(&:to_s) - keys
70
+ unknown = keys - Rule.members.map(&:to_s)
71
+ raise Insika::ValidationError, "criterion is missing key(s): #{missing.join(', ')}" if missing.any?
72
+ raise Insika::ValidationError, "criterion has unknown key(s): #{unknown.join(', ')}" if unknown.any?
73
+
74
+ Rule.new(**Rule.members.to_h { |m| [m, raw[m.to_s]] })
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end