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,184 @@
1
+ # Providers and Local Models
2
+
3
+ `SQA::BI::LlmSupport` decides where a chat request goes. The policy is
4
+ **local first** — and the part that costs people time is that the fallback to
5
+ cloud is silent.
6
+
7
+ ## Resolution order
8
+
9
+ ![Provider resolution — local first](../assets/diagrams/provider-resolution.svg)
10
+
11
+ 1. `SQA_BI_LLM_PROVIDER` if set — `lms`, `apfel`, or `cloud`
12
+ 2. **LM Studio** at `http://localhost:1234/v1` via `ruby_llm-providers-lms`
13
+ 3. **Apfel** (Apple Foundation Models) at `http://127.0.0.1:11434/v1` via
14
+ `ruby_llm-providers-apfel`
15
+ 4. **Cloud** through the plain `ruby_llm` registry
16
+
17
+ Steps 2 and 3 are real HTTP probes against `/v1/models` with a one-second
18
+ timeout. Any failure falls through.
19
+
20
+ ## Environment variables
21
+
22
+ | Variable | Effect |
23
+ |---|---|
24
+ | `SQA_BI_LLM_PROVIDER` | force `lms`, `apfel`, or `cloud` |
25
+ | `SQA_BI_LLM_MODEL` | force a model id |
26
+ | `LMS_API_BASE` | override the LM Studio base URL |
27
+ | `APFEL_API_BASE` | override the Apfel base URL |
28
+
29
+ The unprefixed `BI_LLM_PROVIDER` / `BI_LLM_MODEL` names this library used before
30
+ it moved into the SQA workspace are still honored as a fallback, via
31
+ `LlmSupport.env_value`. The namespaced names win when both are set.
32
+
33
+ ## Setting up LM Studio
34
+
35
+ Installing the provider gem is not enough. Two further things must be true, and
36
+ neither happens just because the desktop app is open.
37
+
38
+ ### 1. The server must be running
39
+
40
+ ```bash
41
+ lms status
42
+ # Server: OFF
43
+ #
44
+ # (i) To start the server, run the following command:
45
+ # lms server start
46
+
47
+ lms server start
48
+ # Success! Server is now running on port 1234
49
+ ```
50
+
51
+ ### 2. A chat model must be loaded
52
+
53
+ ```bash
54
+ lms ps
55
+ # No models are currently loaded.
56
+
57
+ lms ls # what you have downloaded
58
+ lms load qwen/qwen3.8-27b
59
+ # Model loaded successfully in 29.67s. (16.52 GiB)
60
+ ```
61
+
62
+ !!! danger "Embedding models don't count"
63
+ `choose_local_model` rejects any id matching `/embed|ocr/i`. A server
64
+ offering only `text-embedding-nomic-embed-text-v1.5` resolves to **no chat
65
+ model at all** — `local_chat` then raises:
66
+
67
+ ```
68
+ SQA::BI::Error: No chat model available from lms server at http://localhost:1234/v1
69
+ ```
70
+
71
+ ### Model preference
72
+
73
+ ```ruby
74
+ def choose_local_model(ids)
75
+ chat_ids = ids.reject { |id| id.match?(/embed|ocr/i) }
76
+ chat_ids.find { |id| id.match?(/qwen/i) } ||
77
+ chat_ids.find { |id| id.match?(/gpt-oss/i) } ||
78
+ chat_ids.first
79
+ end
80
+ ```
81
+
82
+ Qwen first — it honors JSON and structured prompts most reliably in LM Studio,
83
+ which matters when every response must parse as a JSON object. Then gpt-oss,
84
+ then whatever is left.
85
+
86
+ ## Making resolution visible
87
+
88
+ This is the important part.
89
+
90
+ ```ruby
91
+ SQA::BI::LlmSupport.current_resolution
92
+ # => "lms — qwen/qwen3.8-27b at http://localhost:1234/v1"
93
+ # => "cloud — claude-haiku-4-5"
94
+ # => "lms — (no chat model available) at http://localhost:1234/v1"
95
+ ```
96
+
97
+ Examples 04 and 05 print this before their first call:
98
+
99
+ ```
100
+ LLM: lms — qwen/qwen3.8-27b at http://localhost:1234/v1
101
+ ```
102
+
103
+ !!! warning "Why this exists"
104
+ With the server off, resolution drops to cloud and hits
105
+ `CLOUD_DEFAULT_MODEL` (`claude-haiku-4-5`). If that key is missing or stale,
106
+ the error you get is an Anthropic `401 — API key is invalid`, with a stack
107
+ trace through `RubyLLM::Protocols::Anthropic` and **no mention of LM Studio
108
+ anywhere**. Nothing in that error suggests the actual cause is a local
109
+ server that never started.
110
+
111
+ The silence is deliberate for a library — it should not print warnings — but
112
+ it means any application using local-first resolution should surface the
113
+ resolved provider itself.
114
+
115
+ ### Probing directly
116
+
117
+ ```ruby
118
+ S = SQA::BI::LlmSupport
119
+
120
+ S.resolve_provider # => :lms
121
+ S.server_alive?(S.lms_api_base) # => true
122
+ S.list_local_models(S.lms_api_base) # => ["qwen/qwen3.8-27b", ...]
123
+ S.choose_local_model(S.list_local_models(S.lms_api_base))
124
+ # => "qwen/qwen3.8-27b"
125
+ S.local_api_base(:cloud) # => nil
126
+ ```
127
+
128
+ ## Cloud providers
129
+
130
+ `configure_ruby_llm` wires up whichever keys are present:
131
+
132
+ ```ruby
133
+ config.anthropic_api_key = ENV['ANTHROPIC_API_KEY'] if ENV['ANTHROPIC_API_KEY']
134
+ config.openai_api_key = ENV['OPENAI_API_KEY'] if ENV['OPENAI_API_KEY']
135
+ config.gemini_api_key = ENV['GEMINI_API_KEY'] if ENV['GEMINI_API_KEY']
136
+ ```
137
+
138
+ It runs once and is safe to call repeatedly. `CLOUD_DEFAULT_MODEL` is
139
+ `claude-haiku-4-5`; override per-run:
140
+
141
+ ```bash
142
+ SQA_BI_LLM_PROVIDER=cloud SQA_BI_LLM_MODEL=gemini-2.5-flash ruby examples/04_llm_elicited_prior.rb
143
+ ```
144
+
145
+ !!! note "Free tiers will stop you mid-run"
146
+ Example 05 makes one call per piece of evidence. On the Gemini free tier
147
+ (20 requests/model) a few runs is enough to exhaust the quota and get
148
+ `RubyLLM::RateLimitError` partway through. Another argument for local.
149
+
150
+ ## Optional dependencies
151
+
152
+ ```ruby
153
+ def require_local_providers
154
+ %w[ruby_llm/providers/lms ruby_llm/providers/apfel].each do |path|
155
+ require path
156
+ rescue LoadError
157
+ nil
158
+ end
159
+ end
160
+ ```
161
+
162
+ A missing provider gem disables only that provider. It never breaks the cloud
163
+ path, and it certainly never breaks the KDE path — which needs no LLM at all.
164
+
165
+ Similarly, `ruby_llm` itself is required lazily inside `build_chat`. If it
166
+ cannot be loaded, `require_ruby_llm` raises a `SQA::BI::Error` explaining both
167
+ likely causes — the gem is genuinely absent, or the process is running outside
168
+ the bundle and RubyGems activated a conflicting dependency:
169
+
170
+ ```
171
+ Could not load ruby_llm (Gem::ConflictError: Unable to activate ruby_llm-2.0.0,
172
+ because json-3.0.2 conflicts with json (< 3)).
173
+
174
+ ruby_llm is an optional dependency of sqa-bi — only the LLM-backed
175
+ prior and likelihood paths need it; the KDE path does not.
176
+
177
+ If it is installed, this usually means the process is running
178
+ outside the bundle. Run it with `bundle exec`, or let direnv set
179
+ BUNDLE_GEMFILE (`asgard dev` / `asgard prod` at the workspace root).
180
+ ```
181
+
182
+ ## API
183
+
184
+ See [`SQA::BI::LlmSupport`](../api/llm-support.md) for the full method list.
@@ -0,0 +1,8 @@
1
+ # Python dependencies for building this documentation site.
2
+ #
3
+ # pip install -r docs/requirements.txt
4
+ # mkdocs serve
5
+ #
6
+ # MkDocs is Python-based and managed separately from the Ruby bundle.
7
+ mkdocs>=1.6
8
+ mkdocs-material>=9.5
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQA
4
+ module BI
5
+ # Computes likelihood P(data | outcome) for Bayesian inference
6
+ #
7
+ # The Likelihood class estimates how probable our observed data is
8
+ # given each possible outcome. For time series data with features (x, y, z),
9
+ # it uses kernel density estimation with historical observations.
10
+ class Likelihood
11
+ attr_reader :observations, :bandwidth
12
+
13
+ # Initialize likelihood estimator
14
+ #
15
+ # @param observations [Array<Hash>] Historical observations
16
+ # Each observation: {features: [x, y, z], outcome: result}
17
+ # @param bandwidth [Float] Kernel bandwidth for density estimation
18
+ # Smaller = more sensitive to local patterns, Larger = smoother estimates
19
+ #
20
+ # @example
21
+ # observations = [
22
+ # {features: [1.0, 2.0, 3.0], outcome: 1},
23
+ # {features: [1.1, 2.1, 2.9], outcome: 1},
24
+ # {features: [-1.0, -2.0, 0.5], outcome: -2}
25
+ # ]
26
+ # likelihood = Likelihood.new(observations, bandwidth: 1.0)
27
+ def initialize(observations = [], bandwidth: 1.0)
28
+ @observations = observations
29
+ @bandwidth = bandwidth
30
+ @outcome_groups = group_by_outcome
31
+ end
32
+
33
+ # Add new observation to the likelihood model
34
+ #
35
+ # @param features [Array<Numeric>] Feature vector (e.g., [x, y, z])
36
+ # @param outcome [Numeric] Observed outcome
37
+ # @return [void]
38
+ def add_observation(features, outcome)
39
+ @observations << { features: features, outcome: outcome }
40
+ @outcome_groups = group_by_outcome
41
+ end
42
+
43
+ # Compute likelihood P(features | outcome) for each outcome
44
+ #
45
+ # Uses Gaussian kernel density estimation:
46
+ # For each outcome, compute average similarity to historical observations
47
+ # with that outcome using Gaussian kernel.
48
+ #
49
+ # @param features [Array<Numeric>] Query feature vector
50
+ # @param outcomes [Array] All possible outcomes
51
+ # @return [Hash] {outcome => likelihood_value}
52
+ #
53
+ # @example
54
+ # likelihoods = likelihood.compute([1.0, 2.0, 3.0], [-2, -1, 0, 1, 2])
55
+ # # => {-2 => 0.05, -1 => 0.1, 0 => 0.15, 1 => 0.6, 2 => 0.1}
56
+ def compute(features, outcomes)
57
+ if @observations.empty?
58
+ # No data yet, return uniform likelihoods
59
+ uniform = 1.0 / outcomes.size
60
+ return outcomes.to_h { |outcome| [outcome, uniform] }
61
+ end
62
+
63
+ likelihoods = outcomes.to_h do |outcome|
64
+ [outcome, estimate_density(features, outcome)]
65
+ end
66
+
67
+ # Normalize likelihoods to sum to 1
68
+ normalize_hash(likelihoods)
69
+ end
70
+
71
+ # Estimate kernel density for given features and outcome
72
+ #
73
+ # @param features [Array<Numeric>] Query features
74
+ # @param outcome [Numeric] Target outcome
75
+ # @return [Float] Density estimate
76
+ def estimate_density(features, outcome)
77
+ outcome_obs = @outcome_groups[outcome] || []
78
+
79
+ if outcome_obs.empty?
80
+ # No observations for this outcome, return small epsilon
81
+ return 1e-10
82
+ end
83
+
84
+ # Gaussian kernel density estimation
85
+ # K(u) = (1 / sqrt(2π)) * exp(-u² / 2)
86
+ # KDE(x) = (1 / n*h) * Σ K((x - xᵢ) / h)
87
+
88
+ total_density = outcome_obs.reduce(0.0) do |sum, obs|
89
+ distance = euclidean_distance(features, obs[:features])
90
+ kernel_value = gaussian_kernel(distance / @bandwidth)
91
+ sum + kernel_value
92
+ end
93
+
94
+ # Average density
95
+ density = total_density / outcome_obs.size
96
+
97
+ # Ensure minimum density to avoid zero probabilities
98
+ [density, 1e-10].max
99
+ end
100
+
101
+ # Get number of observations
102
+ #
103
+ # @return [Integer]
104
+ def size
105
+ @observations.size
106
+ end
107
+
108
+ # Check if likelihood model is empty
109
+ #
110
+ # @return [Boolean]
111
+ def empty?
112
+ @observations.empty?
113
+ end
114
+
115
+ # Get count of observations for each outcome
116
+ #
117
+ # @return [Hash] {outcome => count}
118
+ def outcome_counts
119
+ @outcome_groups.transform_values(&:size)
120
+ end
121
+
122
+ private
123
+
124
+ # Group observations by outcome
125
+ #
126
+ # @return [Hash] {outcome => [observations]}
127
+ def group_by_outcome
128
+ @observations.group_by { |obs| obs[:outcome] }
129
+ end
130
+
131
+ # Compute Euclidean distance between two feature vectors
132
+ #
133
+ # @param query [Array<Numeric>] First vector
134
+ # @param observed [Array<Numeric>] Second vector
135
+ # @return [Float] Euclidean distance
136
+ def euclidean_distance(query, observed)
137
+ raise ArgumentError, 'Feature vectors must have same dimension' unless query.size == observed.size
138
+
139
+ Math.sqrt(query.zip(observed).reduce(0.0) { |sum, (a, b)| sum + ((a - b)**2) })
140
+ end
141
+
142
+ # Gaussian kernel function
143
+ #
144
+ # @param normalized_distance [Float] distance divided by the bandwidth
145
+ # @return [Float] Kernel value
146
+ def gaussian_kernel(normalized_distance)
147
+ (1.0 / Math.sqrt(2 * Math::PI)) * Math.exp(-0.5 * (normalized_distance**2))
148
+ end
149
+
150
+ # Normalize hash values to sum to 1
151
+ #
152
+ # @param hash [Hash] Hash with numeric values
153
+ # @return [Hash] Normalized hash
154
+ def normalize_hash(hash)
155
+ total = hash.values.sum
156
+
157
+ if total.positive?
158
+ hash.transform_values { |v| v / total }
159
+ else
160
+ # All zeros, return uniform
161
+ uniform = 1.0 / hash.size
162
+ hash.transform_values { uniform }
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'llm_support'
4
+
5
+ module SQA
6
+ module BI
7
+ # Uses an LLM as a likelihood function over soft, semantic evidence.
8
+ #
9
+ # Kernel density estimation (the Likelihood class) needs numeric
10
+ # feature vectors and historical data. But much real-world evidence is
11
+ # text: a log line, a witness statement, a news headline. An LLM can
12
+ # judge P(evidence | hypothesis) for that kind of evidence — and then
13
+ # this library does what LLMs are demonstrably bad at: combining those
14
+ # judgments coherently via Bayes' theorem, one update per piece of
15
+ # evidence, with no double-counting and no anchoring drift.
16
+ #
17
+ # @example Root-cause diagnosis from log evidence
18
+ # estimator = LlmLikelihoodEstimator.new(
19
+ # hypotheses: {
20
+ # bad_deploy: 'The 14:02 deploy introduced a bug',
21
+ # database: 'The primary database is degraded',
22
+ # network: 'There is a network partition between AZs'
23
+ # }
24
+ # )
25
+ # lh = estimator.likelihoods('Error rate spiked 2 minutes after deploy')
26
+ # # => { bad_deploy: 0.9, database: 0.2, network: 0.15 }
27
+ class LlmLikelihoodEstimator
28
+ include LlmSupport
29
+
30
+ attr_reader :hypotheses, :model, :provider
31
+
32
+ # @param hypotheses [Hash] {id => description}; ids become the
33
+ # outcome keys used with Prior/Posterior
34
+ # @param chat [#ask, nil] injectable chat object (tests pass a fake)
35
+ # @param model [String, nil] model id; nil resolves per provider
36
+ # @param provider [Symbol, nil] :lms, :apfel, :cloud; nil auto-detects
37
+ def initialize(hypotheses:, chat: nil, model: nil, provider: nil)
38
+ @hypotheses = hypotheses
39
+ @model = model
40
+ @provider = provider
41
+ @chat = chat
42
+ end
43
+
44
+ # Estimate P(evidence | hypothesis) for every hypothesis.
45
+ #
46
+ # Likelihoods are NOT probabilities of the hypotheses and need not
47
+ # sum to 1 — each answers "if this hypothesis were true, how
48
+ # surprising would this evidence be?" independently.
49
+ #
50
+ # @param evidence [String] one piece of evidence in natural language
51
+ # @return [Hash] {hypothesis_id => Float in (0, 1)}
52
+ def likelihoods(evidence)
53
+ response = chat.ask(build_prompt(evidence))
54
+ likelihoods_from_response(response.content)
55
+ end
56
+
57
+ # Convert raw LLM response content into a clamped likelihood hash.
58
+ # Public and pure so it can be tested without any network call.
59
+ #
60
+ # @param content [Hash, String] LLM response content
61
+ # @return [Hash] {hypothesis_id => Float}
62
+ def likelihoods_from_response(content)
63
+ raw = extract_json(content)
64
+ rekey_to_outcomes(raw, @hypotheses.keys)
65
+ .transform_values { |v| clamp_likelihood(v) }
66
+ end
67
+
68
+ # Build the likelihood-estimation prompt.
69
+ # Public and pure so prompt wording can be tested and iterated on.
70
+ #
71
+ # @param evidence [String]
72
+ # @return [String]
73
+ def build_prompt(evidence)
74
+ <<~PROMPT
75
+ You are a careful probabilistic reasoner. For EACH hypothesis below,
76
+ estimate the conditional probability of observing the given evidence
77
+ ASSUMING THAT HYPOTHESIS IS TRUE: P(evidence | hypothesis).
78
+
79
+ These are independent judgments — they do NOT need to sum to 1.
80
+ Use values between 0.01 and 0.99. Avoid 0 and 1; evidence is rarely
81
+ impossible or certain.
82
+
83
+ Evidence:
84
+ #{evidence}
85
+
86
+ Hypotheses:
87
+ #{hypothesis_lines}
88
+
89
+ Respond with ONLY a JSON object mapping each hypothesis id to its
90
+ conditional probability, for example: {#{example_json_keys}}.
91
+ No commentary.
92
+ PROMPT
93
+ end
94
+
95
+ private
96
+
97
+ def chat
98
+ @chat ||= LlmSupport.build_chat(@model, provider: @provider)
99
+ end
100
+
101
+ def hypothesis_lines
102
+ @hypotheses.map { |id, desc| "- #{id}: #{desc}" }.join("\n")
103
+ end
104
+
105
+ def example_json_keys
106
+ @hypotheses.keys.map { |id| "\"#{id}\": 0.5" }.join(', ')
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'llm_support'
4
+
5
+ module SQA
6
+ module BI
7
+ # Elicits a Prior distribution from an LLM given a natural-language
8
+ # description of the current situation.
9
+ #
10
+ # Classical Bayesian analysis stalls on the question "where does the
11
+ # prior come from?" — an LLM is a compressed archive of domain
12
+ # knowledge that can be queried for exactly that. The LLM never does
13
+ # probability arithmetic; it only supplies relative weights, which are
14
+ # floored, normalized, and validated by the existing Prior class.
15
+ #
16
+ # @example Elicit a market-trend prior from a news summary
17
+ # elicitor = LlmPriorElicitor.new(
18
+ # outcomes: [-2, -1, 0, 1, 2],
19
+ # outcome_descriptions: {
20
+ # -2 => 'strong downtrend', -1 => 'mild downtrend', 0 => 'sideways',
21
+ # 1 => 'mild uptrend', 2 => 'strong uptrend'
22
+ # }
23
+ # )
24
+ # prior = elicitor.elicit('The Fed unexpectedly cut rates by 50bp...')
25
+ # predictor = TimeSeriesPredictor.new(
26
+ # outcomes: [-2, -1, 0, 1, 2],
27
+ # prior_probabilities: prior.probabilities,
28
+ # update_prior: false
29
+ # )
30
+ class LlmPriorElicitor
31
+ include LlmSupport
32
+
33
+ attr_reader :outcomes, :outcome_descriptions, :model, :provider
34
+
35
+ # @param outcomes [Array] discrete outcomes the prior ranges over
36
+ # @param outcome_descriptions [Hash] optional {outcome => human label}
37
+ # used in the prompt so the LLM knows what each outcome means
38
+ # @param chat [#ask, nil] injectable chat object (tests pass a fake);
39
+ # defaults to a RubyLLM chat built on first use
40
+ # @param model [String, nil] model id; nil resolves per provider
41
+ # @param provider [Symbol, nil] :lms, :apfel, :cloud; nil auto-detects
42
+ def initialize(outcomes:, outcome_descriptions: {}, chat: nil, model: nil, provider: nil)
43
+ @outcomes = outcomes.sort
44
+ @outcome_descriptions = outcome_descriptions
45
+ @model = model
46
+ @provider = provider
47
+ @chat = chat
48
+ end
49
+
50
+ # Ask the LLM for a prior over the outcomes given the context.
51
+ #
52
+ # @param context [String] natural-language description of the situation
53
+ # @return [Prior] validated prior distribution
54
+ def elicit(context)
55
+ response = chat.ask(build_prompt(context))
56
+ prior_from_response(response.content)
57
+ end
58
+
59
+ # Convert raw LLM response content into a Prior.
60
+ # Public and pure so it can be tested without any network call.
61
+ #
62
+ # @param content [Hash, String] LLM response content
63
+ # @return [Prior]
64
+ def prior_from_response(content)
65
+ raw = extract_json(content)
66
+ weights = rekey_to_outcomes(raw, @outcomes)
67
+ Prior.new(@outcomes, normalize_distribution(weights))
68
+ end
69
+
70
+ # Build the elicitation prompt.
71
+ # Public and pure so prompt wording can be tested and iterated on.
72
+ #
73
+ # @param context [String]
74
+ # @return [String]
75
+ def build_prompt(context)
76
+ <<~PROMPT
77
+ You are a careful probabilistic forecaster. Based on the situation
78
+ described below, assign a relative weight between 0 and 100 to each
79
+ possible outcome, reflecting how probable each outcome is a priori.
80
+
81
+ Situation:
82
+ #{context}
83
+
84
+ Possible outcomes:
85
+ #{outcome_lines}
86
+
87
+ Respond with ONLY a JSON object mapping each outcome to its weight,
88
+ for example: {#{example_json_keys}}. No commentary.
89
+ PROMPT
90
+ end
91
+
92
+ private
93
+
94
+ def chat
95
+ @chat ||= LlmSupport.build_chat(@model, provider: @provider)
96
+ end
97
+
98
+ def outcome_lines
99
+ @outcomes.map do |outcome|
100
+ label = @outcome_descriptions[outcome]
101
+ label ? "- #{outcome}: #{label}" : "- #{outcome}"
102
+ end.join("\n")
103
+ end
104
+
105
+ def example_json_keys
106
+ @outcomes.map { |outcome| "\"#{outcome}\": 20" }.join(', ')
107
+ end
108
+ end
109
+ end
110
+ end