sqa-bi 0.1.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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/docs.yml +55 -0
  3. data/.quality/reek_baseline.txt +5 -0
  4. data/.rubocop.yml +224 -0
  5. data/CHANGELOG.md +33 -0
  6. data/CLAUDE.md +128 -0
  7. data/COMMITS.md +196 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +229 -0
  10. data/Rakefile +170 -0
  11. data/decision_support_techniques.md +391 -0
  12. data/docs/EXPLORATION.md +128 -0
  13. data/docs/api/index.md +66 -0
  14. data/docs/api/likelihood.md +78 -0
  15. data/docs/api/llm-elicitors.md +119 -0
  16. data/docs/api/llm-support.md +136 -0
  17. data/docs/api/posterior.md +106 -0
  18. data/docs/api/prior.md +81 -0
  19. data/docs/api/time-series-predictor.md +96 -0
  20. data/docs/assets/css/custom.css +25 -0
  21. data/docs/assets/diagrams/architecture.svg +75 -0
  22. data/docs/assets/diagrams/bayes-pipeline.svg +48 -0
  23. data/docs/assets/diagrams/kde.svg +52 -0
  24. data/docs/assets/diagrams/llm-bayes-loop.svg +58 -0
  25. data/docs/assets/diagrams/provider-resolution.svg +77 -0
  26. data/docs/assets/js/mathjax.js +18 -0
  27. data/docs/development.md +164 -0
  28. data/docs/examples/index.md +198 -0
  29. data/docs/getting-started/core-concepts.md +119 -0
  30. data/docs/getting-started/installation.md +112 -0
  31. data/docs/getting-started/quick-start.md +143 -0
  32. data/docs/guide/likelihood.md +132 -0
  33. data/docs/guide/posterior.md +135 -0
  34. data/docs/guide/predictor.md +191 -0
  35. data/docs/guide/prior.md +141 -0
  36. data/docs/guide/tuning.md +156 -0
  37. data/docs/guide/uncertainty.md +148 -0
  38. data/docs/index.md +110 -0
  39. data/docs/llm/index.md +124 -0
  40. data/docs/llm/likelihood-estimation.md +161 -0
  41. data/docs/llm/prior-elicitation.md +172 -0
  42. data/docs/llm/providers.md +184 -0
  43. data/docs/requirements.txt +8 -0
  44. data/lib/sqa/bi/likelihood.rb +167 -0
  45. data/lib/sqa/bi/llm_likelihood_estimator.rb +110 -0
  46. data/lib/sqa/bi/llm_prior_elicitor.rb +110 -0
  47. data/lib/sqa/bi/llm_support.rb +299 -0
  48. data/lib/sqa/bi/posterior.rb +189 -0
  49. data/lib/sqa/bi/prior.rb +135 -0
  50. data/lib/sqa/bi/time_series_predictor.rb +219 -0
  51. data/lib/sqa/bi/version.rb +7 -0
  52. data/lib/sqa/bi.rb +57 -0
  53. data/mkdocs.yml +174 -0
  54. metadata +272 -0
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module SQA
8
+ module BI
9
+ # Shared plumbing for classes that get numbers out of an LLM.
10
+ #
11
+ # LLMs return prose; Bayes' theorem needs clean numeric hashes. Every
12
+ # method here is a module_function so it can be tested in isolation
13
+ # without instantiating anything.
14
+ #
15
+ # Provider strategy — local first:
16
+ # 1. SQA_BI_LLM_PROVIDER env var (lms | apfel | cloud) if set
17
+ # 2. LM Studio via ruby_llm-providers-lms (http://localhost:1234/v1)
18
+ # 3. Apfel (Apple Foundation Models) via ruby_llm-providers-apfel
19
+ # (http://127.0.0.1:11434/v1)
20
+ # 4. cloud fallback through the plain ruby_llm registry
21
+ # SQA_BI_LLM_MODEL overrides the model in every case.
22
+ #
23
+ # The unprefixed BI_LLM_PROVIDER / BI_LLM_MODEL names this library
24
+ # used before it moved into the SQA workspace are still honored as a
25
+ # fallback, so existing shell setups keep working.
26
+ module LlmSupport
27
+ module_function
28
+
29
+ LMS_DEFAULT_BASE = 'http://localhost:1234/v1'
30
+ APFEL_DEFAULT_BASE = 'http://127.0.0.1:11434/v1'
31
+ CLOUD_DEFAULT_MODEL = 'claude-haiku-4-5'
32
+
33
+ def lms_api_base
34
+ ENV.fetch('LMS_API_BASE', LMS_DEFAULT_BASE)
35
+ end
36
+
37
+ def apfel_api_base
38
+ ENV.fetch('APFEL_API_BASE', APFEL_DEFAULT_BASE)
39
+ end
40
+
41
+ # Read the first non-blank value among a list of env var names.
42
+ #
43
+ # @param names [Array<String>]
44
+ # @return [String, nil]
45
+ def env_value(*names)
46
+ names.each do |name|
47
+ value = ENV[name]&.strip
48
+ return value unless value.nil? || value.empty?
49
+ end
50
+ nil
51
+ end
52
+
53
+ # Model override from the environment, nil when unset/blank.
54
+ def env_model
55
+ env_value('SQA_BI_LLM_MODEL', 'BI_LLM_MODEL')
56
+ end
57
+
58
+ # Which provider to talk to: explicit env choice, else the first
59
+ # local server that answers, else the cloud.
60
+ #
61
+ # @return [Symbol] :lms, :apfel, or :cloud
62
+ def resolve_provider
63
+ env = env_value('SQA_BI_LLM_PROVIDER', 'BI_LLM_PROVIDER')
64
+ return env.to_sym if env
65
+ return :lms if server_alive?(lms_api_base)
66
+ return :apfel if server_alive?(apfel_api_base)
67
+
68
+ :cloud
69
+ end
70
+
71
+ # Quick liveness probe against an OpenAI-compatible /models endpoint.
72
+ #
73
+ # @param base [String] API base URL (".../v1")
74
+ # @return [Boolean]
75
+ def server_alive?(base, timeout: 1)
76
+ uri = URI("#{base}/models")
77
+ Net::HTTP.start(uri.host, uri.port, open_timeout: timeout, read_timeout: timeout) do |http|
78
+ http.get(uri.path).code.start_with?('2')
79
+ end
80
+ rescue StandardError
81
+ false
82
+ end
83
+
84
+ # Model ids served right now by a local OpenAI-compatible server.
85
+ #
86
+ # @param base [String] API base URL (".../v1")
87
+ # @return [Array<String>]
88
+ def list_local_models(base)
89
+ uri = URI("#{base}/models")
90
+ body = Net::HTTP.get(uri)
91
+ JSON.parse(body).fetch('data', []).map { |m| m['id'] }
92
+ rescue StandardError
93
+ []
94
+ end
95
+
96
+ # Pick the best judgment model from a local server's offerings.
97
+ # Qwen models honor JSON/structured prompts most reliably in LM
98
+ # Studio (see ruby_llm-providers-lms README), then gpt-oss; embedding
99
+ # and OCR models are never eligible.
100
+ #
101
+ # @param ids [Array<String>] model ids as listed by the server
102
+ # @return [String, nil]
103
+ def choose_local_model(ids)
104
+ chat_ids = ids.grep_v(/embed|ocr/i)
105
+ chat_ids.find { |id| id.match?(/qwen/i) } ||
106
+ chat_ids.find { |id| id.match?(/gpt-oss/i) } ||
107
+ chat_ids.first
108
+ end
109
+
110
+ # Build a lazily-required RubyLLM chat so the core math library
111
+ # never depends on ruby_llm being installed.
112
+ #
113
+ # @param model [String, nil] model id; nil resolves per provider
114
+ # @param provider [Symbol, String, nil] :lms, :apfel, :cloud;
115
+ # nil auto-detects via resolve_provider
116
+ # @return [RubyLLM::Chat]
117
+ def build_chat(model = nil, provider: nil)
118
+ require_ruby_llm
119
+ configure_ruby_llm
120
+ provider = (provider || resolve_provider).to_sym
121
+ model ||= env_model
122
+ base = local_api_base(provider)
123
+
124
+ if base
125
+ local_chat(provider, model, base)
126
+ else
127
+ RubyLLM.chat(model: model || CLOUD_DEFAULT_MODEL)
128
+ end
129
+ end
130
+
131
+ # API base for a local provider, or nil when the provider is a
132
+ # cloud one.
133
+ #
134
+ # @param provider [Symbol] :lms, :apfel, or :cloud
135
+ # @return [String, nil]
136
+ def local_api_base(provider)
137
+ case provider
138
+ when :lms then lms_api_base
139
+ when :apfel then apfel_api_base
140
+ end
141
+ end
142
+
143
+ # One-line description of where a chat would be sent.
144
+ #
145
+ # Local-first resolution is silent by design, which means a dead or
146
+ # stopped LM Studio server looks identical to a deliberate cloud
147
+ # run until a provider error surfaces. Callers that care — the demo
148
+ # apps do — can show this first.
149
+ #
150
+ # @param provider [Symbol] :lms, :apfel, or :cloud
151
+ # @param model [String, nil] resolved model id
152
+ # @param base [String, nil] local server base; nil for cloud
153
+ # @return [String]
154
+ def resolution_label(provider, model, base)
155
+ target = model || '(no chat model available)'
156
+ base ? "#{provider} — #{target} at #{base}" : "#{provider} — #{target}"
157
+ end
158
+
159
+ # Where the next chat built with these arguments will actually go.
160
+ # Probes the local servers, so it costs one HTTP round trip.
161
+ #
162
+ # @param model [String, nil] explicit model id, if any
163
+ # @param provider [Symbol, nil] explicit provider, if any
164
+ # @return [String] as formatted by resolution_label
165
+ def current_resolution(model = nil, provider: nil)
166
+ provider = (provider || resolve_provider).to_sym
167
+ base = local_api_base(provider)
168
+ model ||= env_model
169
+ model ||= base ? choose_local_model(list_local_models(base)) : CLOUD_DEFAULT_MODEL
170
+
171
+ resolution_label(provider, model, base)
172
+ end
173
+
174
+ # Require ruby_llm, turning both failure modes into one actionable
175
+ # message. It is an optional dependency, so it may simply be absent;
176
+ # and when the process is running outside the bundle, RubyGems can
177
+ # activate a gem version ruby_llm rejects (json >= 3) long before
178
+ # this require runs. The latter surfaces as Gem::ConflictError, which
179
+ # is itself a LoadError, so one rescue covers both.
180
+ #
181
+ # @raise [SQA::BI::Error] when ruby_llm cannot be loaded
182
+ def require_ruby_llm
183
+ require 'ruby_llm'
184
+ rescue LoadError => e
185
+ raise Error, <<~MESSAGE
186
+ Could not load ruby_llm (#{e.class}: #{e.message}).
187
+
188
+ ruby_llm is an optional dependency of sqa-bi — only the LLM-backed
189
+ prior and likelihood paths need it; the KDE path does not.
190
+
191
+ If it is installed, this usually means the process is running
192
+ outside the bundle. Run it with `bundle exec`, or let direnv set
193
+ BUNDLE_GEMFILE (`asgard dev` / `asgard prod` at the workspace root).
194
+ MESSAGE
195
+ end
196
+
197
+ # @param provider [Symbol] :lms or :apfel
198
+ # @param model [String, nil]
199
+ # @param base [String] server API base, used to pick a model when none given
200
+ # @return [RubyLLM::Chat]
201
+ def local_chat(provider, model, base)
202
+ model ||= choose_local_model(list_local_models(base))
203
+ raise Error, "No chat model available from #{provider} server at #{base}" unless model
204
+
205
+ RubyLLM.chat(model:, provider:, assume_model_exists: true)
206
+ end
207
+
208
+ # Register the local provider gems and point RubyLLM at whichever
209
+ # cloud keys exist in the environment. Runs once; safe to call
210
+ # repeatedly.
211
+ def configure_ruby_llm
212
+ return if @ruby_llm_configured
213
+
214
+ require_local_providers
215
+
216
+ RubyLLM.configure do |config|
217
+ config.anthropic_api_key = ENV['ANTHROPIC_API_KEY'] if ENV['ANTHROPIC_API_KEY']
218
+ config.openai_api_key = ENV['OPENAI_API_KEY'] if ENV['OPENAI_API_KEY']
219
+ config.gemini_api_key = ENV['GEMINI_API_KEY'] if ENV['GEMINI_API_KEY']
220
+ config.lms_api_base = lms_api_base if config.respond_to?(:lms_api_base=)
221
+ config.apfel_api_base = apfel_api_base if config.respond_to?(:apfel_api_base=)
222
+ end
223
+ @ruby_llm_configured = true
224
+ end
225
+
226
+ # The provider gems are optional: their absence only disables the
227
+ # corresponding provider, it never breaks the cloud path.
228
+ def require_local_providers
229
+ %w[ruby_llm/providers/lms ruby_llm/providers/apfel].each do |path|
230
+ require path
231
+ rescue LoadError
232
+ nil
233
+ end
234
+ end
235
+
236
+ # Pull a Hash out of whatever the LLM returned.
237
+ #
238
+ # Accepts an already-parsed Hash (e.g., from structured output or a
239
+ # test double), a raw JSON string, or prose containing a JSON object
240
+ # (possibly inside a ```json fence).
241
+ #
242
+ # @param content [Hash, String, #to_s] LLM response content
243
+ # @return [Hash] parsed JSON object with string keys
244
+ # @raise [SQA::BI::Error] when no JSON object can be found
245
+ def extract_json(content)
246
+ return content if content.is_a?(Hash)
247
+
248
+ text = content.to_s
249
+ json = text[/\{.*\}/m]
250
+ raise Error, "No JSON object found in LLM response: #{text.inspect}" unless json
251
+
252
+ JSON.parse(json)
253
+ rescue JSON::ParserError => e
254
+ raise Error, "Malformed JSON from LLM: #{e.message}"
255
+ end
256
+
257
+ # Re-key an LLM-produced hash onto the caller's outcome objects.
258
+ #
259
+ # JSON keys are always strings; outcomes may be integers, symbols,
260
+ # or strings. Matches on +to_s+ equality. Missing outcomes get 0.0.
261
+ #
262
+ # @param raw [Hash] {string_key => Numeric}
263
+ # @param outcomes [Array] the caller's outcome objects
264
+ # @return [Hash] {outcome => Float}
265
+ def rekey_to_outcomes(raw, outcomes)
266
+ by_string = raw.transform_keys(&:to_s)
267
+ outcomes.to_h do |outcome|
268
+ [outcome, by_string.fetch(outcome.to_s, 0.0).to_f]
269
+ end
270
+ end
271
+
272
+ # Normalize weights into a proper probability distribution.
273
+ #
274
+ # Adds a small epsilon before normalizing so no outcome is ever
275
+ # assigned exactly zero probability — a zero prior can never recover
276
+ # no matter how much evidence arrives (Cromwell's rule).
277
+ #
278
+ # @param weights [Hash] {outcome => non-negative Numeric}
279
+ # @param epsilon [Float] floor added to every weight
280
+ # @return [Hash] {outcome => Float} summing to 1.0
281
+ def normalize_distribution(weights, epsilon: 1e-6)
282
+ floored = weights.transform_values { |weight| [weight.to_f, 0.0].max + epsilon }
283
+ total = floored.values.sum
284
+ floored.transform_values { |weight| weight / total }
285
+ end
286
+
287
+ # Clamp a likelihood into an open unit interval.
288
+ #
289
+ # Keeps LLM overconfidence (0.0 / 1.0 answers) from zeroing out or
290
+ # saturating the posterior in a single update.
291
+ #
292
+ # @param value [Numeric]
293
+ # @return [Float] value clamped to [floor, ceiling]
294
+ def clamp_likelihood(value, floor: 0.001, ceiling: 0.999)
295
+ value.to_f.clamp(floor, ceiling)
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQA
4
+ module BI
5
+ # Computes posterior probability distribution using Bayes' theorem
6
+ #
7
+ # Bayes' Theorem: P(outcome | data) ∝ P(data | outcome) × P(outcome)
8
+ #
9
+ # The Posterior class combines prior beliefs and observed data likelihoods
10
+ # to produce an updated probability distribution over outcomes.
11
+ class Posterior
12
+ attr_reader :prior, :likelihoods, :probabilities
13
+
14
+ # Initialize posterior distribution
15
+ #
16
+ # @param prior [Prior] Prior probability distribution
17
+ # @param likelihoods [Hash] Likelihood values {outcome => P(data|outcome)}
18
+ #
19
+ # @example
20
+ # prior = Prior.new([-2, -1, 0, 1, 2])
21
+ # likelihoods = {-2 => 0.05, -1 => 0.1, 0 => 0.15, 1 => 0.6, 2 => 0.1}
22
+ # posterior = Posterior.new(prior, likelihoods)
23
+ def initialize(prior, likelihoods)
24
+ @prior = prior
25
+ @likelihoods = likelihoods
26
+ @probabilities = compute_posterior
27
+ end
28
+
29
+ # Get posterior probability for specific outcome
30
+ #
31
+ # @param outcome [Numeric] The outcome to query
32
+ # @return [Float] Posterior probability
33
+ def probability(outcome)
34
+ @probabilities[outcome] || 0.0
35
+ end
36
+ alias [] probability
37
+
38
+ # Get the most likely outcome (MAP estimate)
39
+ #
40
+ # @return [Numeric] Outcome with highest posterior probability
41
+ def max_outcome
42
+ @probabilities.max_by { |_outcome, prob| prob }&.first
43
+ end
44
+
45
+ # Get top N most likely outcomes
46
+ #
47
+ # @param n [Integer] Number of outcomes to return
48
+ # @return [Array<Array>] Array of [outcome, probability] sorted by probability
49
+ #
50
+ # @example
51
+ # posterior.top_outcomes(3)
52
+ # # => [[1, 0.6], [0, 0.15], [-1, 0.1]]
53
+ def top_outcomes(n = 3)
54
+ @probabilities.sort_by { |_outcome, prob| -prob }.take(n)
55
+ end
56
+
57
+ # Get probability distribution as sorted array
58
+ #
59
+ # @return [Array<Array>] Array of [outcome, probability] sorted by outcome
60
+ def to_a
61
+ @probabilities.sort_by { |outcome, _prob| outcome }
62
+ end
63
+
64
+ # Get probability distribution as hash
65
+ #
66
+ # @return [Hash] {outcome => probability}
67
+ def to_h
68
+ @probabilities.dup
69
+ end
70
+
71
+ # Compute entropy of posterior distribution
72
+ # Higher entropy = more uncertain, Lower entropy = more confident
73
+ #
74
+ # @return [Float] Shannon entropy in bits
75
+ def entropy
76
+ -@probabilities.values.reduce(0.0) do |sum, prob|
77
+ sum + (prob.positive? ? prob * Math.log2(prob) : 0)
78
+ end
79
+ end
80
+
81
+ # Compute Kullback-Leibler divergence from prior to posterior
82
+ # Measures information gain from observing data
83
+ #
84
+ # @return [Float] KL divergence in bits (always non-negative)
85
+ def kl_divergence_from_prior
86
+ @probabilities.reduce(0.0) do |sum, (outcome, posterior_prob)|
87
+ prior_prob = @prior.probability(outcome)
88
+ if posterior_prob.positive? && prior_prob.positive?
89
+ sum + (posterior_prob * Math.log2(posterior_prob / prior_prob))
90
+ else
91
+ sum
92
+ end
93
+ end
94
+ end
95
+
96
+ # Get confidence level (1 - entropy / max_entropy)
97
+ # Returns value between 0 (uniform, no confidence) and 1 (certain)
98
+ #
99
+ # @return [Float] Confidence level
100
+ def confidence
101
+ max_entropy = Math.log2(@probabilities.size)
102
+ 1.0 - (entropy / max_entropy)
103
+ end
104
+
105
+ # Sample an outcome from the posterior distribution
106
+ #
107
+ # @param rng [Random] Random number generator
108
+ # @return [Numeric] Sampled outcome
109
+ def sample(rng: Random.new)
110
+ cumulative = 0.0
111
+ threshold = rng.rand
112
+
113
+ @probabilities.each do |outcome, prob|
114
+ cumulative += prob
115
+ return outcome if cumulative >= threshold
116
+ end
117
+
118
+ # Fallback (shouldn't reach here if probabilities sum to 1)
119
+ @probabilities.keys.last
120
+ end
121
+
122
+ # Generate N samples from the posterior distribution
123
+ #
124
+ # @param n [Integer] Number of samples
125
+ # @param rng [Random] Random number generator
126
+ # @return [Array] Array of sampled outcomes
127
+ def samples(n, rng: Random.new)
128
+ Array.new(n) { sample(rng: rng) }
129
+ end
130
+
131
+ # Return formatted string representation
132
+ #
133
+ # @return [String] Formatted probability distribution
134
+ def to_s
135
+ parts = @probabilities.sort.map do |outcome, prob|
136
+ "#{outcome}: #{format('%.3f', prob)}"
137
+ end
138
+ "Posterior(#{parts.join(', ')})"
139
+ end
140
+
141
+ # Return detailed summary with statistics
142
+ #
143
+ # @return [String] Multi-line summary
144
+ def summary
145
+ max_out = max_outcome
146
+ max_prob = probability(max_out)
147
+
148
+ <<~SUMMARY
149
+ Posterior Distribution Summary:
150
+ ================================
151
+ Most Likely: #{max_out} (#{format('%.1f%%', max_prob * 100)})
152
+ Confidence: #{format('%.1f%%', confidence * 100)}
153
+ Entropy: #{format('%.3f', entropy)} bits
154
+ KL Divergence from Prior: #{format('%.3f', kl_divergence_from_prior)} bits
155
+
156
+ Probabilities:
157
+ #{to_a.map { |outcome, prob| " #{outcome}: #{format('%.3f', prob)} (#{format('%.1f%%', prob * 100)})" }.join("\n")}
158
+ SUMMARY
159
+ end
160
+
161
+ private
162
+
163
+ # Compute posterior using Bayes' theorem
164
+ # P(outcome | data) = P(data | outcome) × P(outcome) / P(data)
165
+ #
166
+ # @return [Hash] Normalized posterior probabilities
167
+ def compute_posterior
168
+ # Compute unnormalized posterior: likelihood × prior
169
+ unnormalized = @prior.outcomes.each_with_object({}) do |outcome, hash|
170
+ likelihood = @likelihoods[outcome] || 0.0
171
+ prior_prob = @prior.probability(outcome)
172
+ hash[outcome] = likelihood * prior_prob
173
+ end
174
+
175
+ # Normalize to sum to 1
176
+ total = unnormalized.values.sum
177
+
178
+ if total.positive?
179
+ unnormalized.transform_values { |v| v / total }
180
+ else
181
+ # All zeros, fall back to prior
182
+ @prior.outcomes.to_h do |outcome|
183
+ [outcome, @prior.probability(outcome)]
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQA
4
+ module BI
5
+ # Manages prior probability distributions over discrete outcomes
6
+ #
7
+ # The Prior class represents our initial beliefs about outcome probabilities
8
+ # before observing any data. It supports:
9
+ # - Uniform priors (equal probability for all outcomes)
10
+ # - Custom priors (specified probabilities)
11
+ # - Dynamic updating based on historical observations
12
+ class Prior
13
+ attr_reader :outcomes, :probabilities
14
+
15
+ # Initialize a prior distribution
16
+ #
17
+ # @param outcomes [Array] Discrete outcomes (e.g., [-2, -1, 0, 1, 2])
18
+ # @param probabilities [Hash, nil] Optional custom probabilities {outcome => probability}
19
+ # If nil, uses uniform distribution
20
+ #
21
+ # @example Uniform prior
22
+ # prior = Prior.new([-2, -1, 0, 1, 2])
23
+ #
24
+ # @example Custom prior favoring neutral outcome
25
+ # prior = Prior.new([-2, -1, 0, 1, 2], {-2 => 0.1, -1 => 0.15, 0 => 0.5, 1 => 0.15, 2 => 0.1})
26
+ def initialize(outcomes, probabilities = nil)
27
+ @outcomes = outcomes.sort
28
+
29
+ if probabilities
30
+ validate_probabilities!(probabilities)
31
+ @probabilities = probabilities
32
+ else
33
+ # Uniform prior: equal probability for all outcomes
34
+ uniform_prob = 1.0 / outcomes.size
35
+ @probabilities = outcomes.to_h do |outcome|
36
+ [outcome, uniform_prob]
37
+ end
38
+ end
39
+ end
40
+
41
+ # Get probability for a specific outcome
42
+ #
43
+ # @param outcome [Numeric] The outcome to query
44
+ # @return [Float] Probability of the outcome
45
+ def probability(outcome)
46
+ @probabilities[outcome] || 0.0
47
+ end
48
+ alias [] probability
49
+
50
+ # Update prior based on observed outcome frequencies
51
+ #
52
+ # Uses Laplace smoothing (add-1 smoothing) to avoid zero probabilities
53
+ #
54
+ # @param observations [Hash] Observed outcome frequencies {outcome => count}
55
+ # @param smoothing [Float] Laplace smoothing parameter (default: 1.0)
56
+ # @return [Prior] New Prior instance with updated probabilities
57
+ #
58
+ # @example
59
+ # observations = {-2 => 5, -1 => 10, 0 => 20, 1 => 12, 2 => 3}
60
+ # updated_prior = prior.update_from_observations(observations)
61
+ def update_from_observations(observations, smoothing: 1.0)
62
+ total_count = observations.values.sum
63
+
64
+ new_probabilities = @outcomes.each_with_object({}) do |outcome, hash|
65
+ # Laplace smoothing: (count + smoothing) / (total + smoothing * num_outcomes)
66
+ count = observations[outcome] || 0
67
+ hash[outcome] = (count + smoothing) / (total_count + (smoothing * @outcomes.size))
68
+ end
69
+
70
+ self.class.new(@outcomes, new_probabilities)
71
+ end
72
+
73
+ # Combine this prior with another using weighted average
74
+ #
75
+ # @param other_prior [Prior] Another prior distribution
76
+ # @param weight [Float] Weight for this prior (0.0 to 1.0)
77
+ # @return [Prior] New Prior with combined probabilities
78
+ def combine(other_prior, weight: 0.5)
79
+ unless outcomes_compatible?(other_prior)
80
+ raise ArgumentError, "Cannot combine priors with different outcomes"
81
+ end
82
+
83
+ new_probabilities = @outcomes.to_h do |outcome|
84
+ [outcome, (weight * probability(outcome)) +
85
+ ((1 - weight) * other_prior.probability(outcome))]
86
+ end
87
+
88
+ self.class.new(@outcomes, new_probabilities)
89
+ end
90
+
91
+ # Get entropy of the prior distribution
92
+ # Higher entropy = more uncertain/uniform, Lower entropy = more peaked/certain
93
+ #
94
+ # @return [Float] Shannon entropy in bits
95
+ def entropy
96
+ -@probabilities.values.reduce(0.0) do |sum, prob|
97
+ sum + (prob.positive? ? prob * Math.log2(prob) : 0)
98
+ end
99
+ end
100
+
101
+ # Return a human-readable representation
102
+ #
103
+ # @return [String] Formatted probability distribution
104
+ def to_s
105
+ parts = @outcomes.map { |outcome| "#{outcome}: #{format('%.3f', probability(outcome))}" }
106
+ "Prior(#{parts.join(', ')})"
107
+ end
108
+
109
+ private
110
+
111
+ def validate_probabilities!(probabilities)
112
+ # Check all outcomes are present
113
+ missing = @outcomes - probabilities.keys
114
+ unless missing.empty?
115
+ raise ArgumentError, "Missing probabilities for outcomes: #{missing}"
116
+ end
117
+
118
+ # Check probabilities sum to 1.0 (with small tolerance)
119
+ total = probabilities.values.sum
120
+ unless (total - 1.0).abs < 1e-6
121
+ raise ArgumentError, "Probabilities must sum to 1.0, got #{total}"
122
+ end
123
+
124
+ # Check all probabilities are non-negative
125
+ negative = probabilities.select { |_k, v| v.negative? }
126
+ return if negative.empty?
127
+ raise ArgumentError, "Probabilities must be non-negative: #{negative}"
128
+ end
129
+
130
+ def outcomes_compatible?(other_prior)
131
+ @outcomes == other_prior.outcomes
132
+ end
133
+ end
134
+ end
135
+ end