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.
- checksums.yaml +7 -0
- data/.github/workflows/docs.yml +55 -0
- data/.quality/reek_baseline.txt +5 -0
- data/.rubocop.yml +224 -0
- data/CHANGELOG.md +33 -0
- data/CLAUDE.md +128 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +229 -0
- data/Rakefile +170 -0
- data/decision_support_techniques.md +391 -0
- data/docs/EXPLORATION.md +128 -0
- data/docs/api/index.md +66 -0
- data/docs/api/likelihood.md +78 -0
- data/docs/api/llm-elicitors.md +119 -0
- data/docs/api/llm-support.md +136 -0
- data/docs/api/posterior.md +106 -0
- data/docs/api/prior.md +81 -0
- data/docs/api/time-series-predictor.md +96 -0
- data/docs/assets/css/custom.css +25 -0
- data/docs/assets/diagrams/architecture.svg +75 -0
- data/docs/assets/diagrams/bayes-pipeline.svg +48 -0
- data/docs/assets/diagrams/kde.svg +52 -0
- data/docs/assets/diagrams/llm-bayes-loop.svg +58 -0
- data/docs/assets/diagrams/provider-resolution.svg +77 -0
- data/docs/assets/js/mathjax.js +18 -0
- data/docs/development.md +164 -0
- data/docs/examples/index.md +198 -0
- data/docs/getting-started/core-concepts.md +119 -0
- data/docs/getting-started/installation.md +112 -0
- data/docs/getting-started/quick-start.md +143 -0
- data/docs/guide/likelihood.md +132 -0
- data/docs/guide/posterior.md +135 -0
- data/docs/guide/predictor.md +191 -0
- data/docs/guide/prior.md +141 -0
- data/docs/guide/tuning.md +156 -0
- data/docs/guide/uncertainty.md +148 -0
- data/docs/index.md +110 -0
- data/docs/llm/index.md +124 -0
- data/docs/llm/likelihood-estimation.md +161 -0
- data/docs/llm/prior-elicitation.md +172 -0
- data/docs/llm/providers.md +184 -0
- data/docs/requirements.txt +8 -0
- data/lib/sqa/bi/likelihood.rb +167 -0
- data/lib/sqa/bi/llm_likelihood_estimator.rb +110 -0
- data/lib/sqa/bi/llm_prior_elicitor.rb +110 -0
- data/lib/sqa/bi/llm_support.rb +299 -0
- data/lib/sqa/bi/posterior.rb +189 -0
- data/lib/sqa/bi/prior.rb +135 -0
- data/lib/sqa/bi/time_series_predictor.rb +219 -0
- data/lib/sqa/bi/version.rb +7 -0
- data/lib/sqa/bi.rb +57 -0
- data/mkdocs.yml +174 -0
- 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
|
+

|
|
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,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
|