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
data/docs/index.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# SQA::BI
|
|
2
|
+
|
|
3
|
+
**Bayesian inference over discrete outcomes from time series data.**
|
|
4
|
+
|
|
5
|
+
`SQA::BI` answers one question:
|
|
6
|
+
|
|
7
|
+
> Given this feature vector, what is the probability distribution over outcomes?
|
|
8
|
+
|
|
9
|
+
…where *outcomes* is a small, ordered set such as `[-2, -1, 0, 1, 2]` — read as
|
|
10
|
+
<span class="outcome-down">strong downtrend</span> through
|
|
11
|
+
<span class="outcome-flat">sideways</span> through
|
|
12
|
+
<span class="outcome-up">strong uptrend</span>.
|
|
13
|
+
|
|
14
|
+
It returns a **full posterior**, not a point estimate. Entropy, confidence and
|
|
15
|
+
information gain fall out of the math rather than being bolted on afterward.
|
|
16
|
+
|
|
17
|
+
!!! danger "This is a learning tool, not production software"
|
|
18
|
+
DO NOT use this library when real money is at stake. The probability
|
|
19
|
+
distributions it produces should not be taken seriously. If you lose your
|
|
20
|
+
shirt playing in the stock market, don't come crying to me. Playing in the
|
|
21
|
+
market is like playing in the street — you're going to get run over.
|
|
22
|
+
|
|
23
|
+
## The shape of it
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "sqa/bi"
|
|
29
|
+
|
|
30
|
+
predictor = SQA::BI.predictor(outcomes: [-2, -1, 0, 1, 2], bandwidth: 1.0)
|
|
31
|
+
|
|
32
|
+
predictor.train([1.0, 2.0, 3.0], outcome: 1)
|
|
33
|
+
predictor.train([1.1, 2.1, 2.9], outcome: 1)
|
|
34
|
+
predictor.train([-1.0, -2.0, 0.5], outcome: -2)
|
|
35
|
+
|
|
36
|
+
posterior = predictor.predict([1.05, 2.05, 3.0])
|
|
37
|
+
|
|
38
|
+
posterior.max_outcome # => 1 the MAP estimate
|
|
39
|
+
posterior.confidence # => 0.41 1 − entropy / max entropy
|
|
40
|
+
posterior.entropy # => 1.37 bits
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Why a posterior rather than a number
|
|
44
|
+
|
|
45
|
+
A classifier that says "up" tells you nothing about how close the call was. A
|
|
46
|
+
posterior does:
|
|
47
|
+
|
|
48
|
+
| Question | Method |
|
|
49
|
+
|---|---|
|
|
50
|
+
| What is most likely? | `max_outcome` |
|
|
51
|
+
| How likely is each outcome? | `to_h`, `to_a`, `top_outcomes(n)` |
|
|
52
|
+
| How uncertain am I? | `entropy`, `confidence` |
|
|
53
|
+
| How much did this evidence teach me? | `kl_divergence_from_prior` |
|
|
54
|
+
| What does the uncertainty look like downstream? | `sample`, `samples(n)` |
|
|
55
|
+
|
|
56
|
+
That last row matters more than it looks. Because the belief state lives in a
|
|
57
|
+
distribution rather than in prose, you can propagate it — Monte Carlo a
|
|
58
|
+
portfolio, gate an action on `confidence`, or refuse to act when `entropy` is
|
|
59
|
+
near its maximum.
|
|
60
|
+
|
|
61
|
+
## Two ways to get a likelihood
|
|
62
|
+
|
|
63
|
+
**From numbers.** [`Likelihood`](guide/likelihood.md) runs Gaussian kernel
|
|
64
|
+
density estimation over the observations you have trained on. This is the
|
|
65
|
+
default path and needs nothing but Ruby.
|
|
66
|
+
|
|
67
|
+
**From text.** [`LlmLikelihoodEstimator`](llm/likelihood-estimation.md) asks a
|
|
68
|
+
language model one narrow question per hypothesis — *assuming H is true, how
|
|
69
|
+
probable is this evidence?* — and Ruby chains the updates. This is for evidence
|
|
70
|
+
that has no numeric form: a news headline, a log line, a filing.
|
|
71
|
+
|
|
72
|
+
The rule that governs the second path, everywhere:
|
|
73
|
+
|
|
74
|
+
!!! quote "The LLM judges. Ruby computes."
|
|
75
|
+
Ask the LLM only for isolated, independent judgments — a weight, a single
|
|
76
|
+
conditional probability. Never ask it to accumulate, normalize, or update
|
|
77
|
+
beliefs. That is Bayes' job, done in Ruby.
|
|
78
|
+
|
|
79
|
+
See [LLM Integration](llm/index.md) for why those two failure modes are exactly
|
|
80
|
+
complementary, and [Exploration Notes](EXPLORATION.md) for the verified results.
|
|
81
|
+
|
|
82
|
+
## Where it sits
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
sqa-tai ─┐
|
|
86
|
+
sqa-bi ─┴→ sqa → sqa-cli / sqa-advisor / sqa-rails / sqa-sinatra
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`sqa-bi` is a **leaf** gem with no runtime dependencies. The inference math is
|
|
90
|
+
domain-agnostic — it knows nothing about markets — so `sqa` can depend on it the
|
|
91
|
+
same way it depends on `sqa-tai`.
|
|
92
|
+
|
|
93
|
+
The market-facing demo reaches the other direction and needs `sqa`, which is why
|
|
94
|
+
it resolves through `Gemfile.local` in development only. See
|
|
95
|
+
[Installation](getting-started/installation.md).
|
|
96
|
+
|
|
97
|
+
## Start here
|
|
98
|
+
|
|
99
|
+
<div class="grid cards" markdown>
|
|
100
|
+
|
|
101
|
+
- **[Installation](getting-started/installation.md)** — add the gem, pick a bundle mode
|
|
102
|
+
- **[Quick Start](getting-started/quick-start.md)** — a working predictor in twenty lines
|
|
103
|
+
- **[Core Concepts](getting-started/core-concepts.md)** — outcomes, features, and what Bayes is doing
|
|
104
|
+
- **[API Reference](api/index.md)** — every public method, with contracts
|
|
105
|
+
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT. See [LICENSE.txt](https://github.com/MadBomber/sqa-bi/blob/main/LICENSE.txt).
|
data/docs/llm/index.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# LLM Integration
|
|
2
|
+
|
|
3
|
+
## The core insight
|
|
4
|
+
|
|
5
|
+
LLMs and Bayes' theorem have exactly complementary failure modes.
|
|
6
|
+
|
|
7
|
+
| | Semantic judgment | Probability arithmetic |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| **LLM** | excellent — can assess "how surprising is this log line if the DB were down?" | poor — anchors, double-counts evidence, drifts toward its first conclusion |
|
|
10
|
+
| **This library** | none — KDE needs numeric feature vectors | exact — normalization, entropy, KL divergence, sequential updates |
|
|
11
|
+
|
|
12
|
+
Which yields one design rule, applied everywhere:
|
|
13
|
+
|
|
14
|
+
!!! quote "The LLM judges. Ruby computes."
|
|
15
|
+
Ask the LLM only for isolated, independent judgments (a weight, a
|
|
16
|
+
conditional probability). Never ask it to accumulate, normalize, or update
|
|
17
|
+
beliefs — that is Bayes' job, done in Ruby.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
The practical consequence is that belief state lives in a `Posterior` object
|
|
22
|
+
rather than in a conversation. An LLM asked to track five facts in prose
|
|
23
|
+
typically anchors on whichever it saw first. Externalizing the state removes
|
|
24
|
+
that failure mode entirely — not mitigates it, removes it.
|
|
25
|
+
|
|
26
|
+
## Two patterns
|
|
27
|
+
|
|
28
|
+
### Prior elicitation
|
|
29
|
+
|
|
30
|
+
The standing objection to Bayesian methods is "where does the prior come from?"
|
|
31
|
+
An LLM is a queryable compression of domain knowledge — exactly the thing a
|
|
32
|
+
prior is meant to encode.
|
|
33
|
+
|
|
34
|
+
[**→ Prior Elicitation**](prior-elicitation.md)
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
elicitor = SQA::BI::LlmPriorElicitor.new(outcomes: [-2, -1, 0, 1, 2])
|
|
38
|
+
prior = elicitor.elicit("The Fed unexpectedly cut rates by 50bp...")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Likelihood estimation
|
|
42
|
+
|
|
43
|
+
KDE needs numbers and history. Much real evidence is text: a log line, a news
|
|
44
|
+
headline, a witness statement. An LLM can judge `P(evidence | hypothesis)` for
|
|
45
|
+
that kind of evidence; Ruby chains the updates.
|
|
46
|
+
|
|
47
|
+
[**→ Likelihood Estimation**](likelihood-estimation.md)
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
estimator = SQA::BI::LlmLikelihoodEstimator.new(hypotheses: {...})
|
|
51
|
+
estimator.likelihoods("Error rate spiked 2 minutes after deploy")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Engineering guarantees
|
|
55
|
+
|
|
56
|
+
These are structural, not stylistic. Preserve them in any extension.
|
|
57
|
+
|
|
58
|
+
**`ruby_llm` is required lazily**, inside `LlmSupport.build_chat`. The core math
|
|
59
|
+
library loads and the KDE path works without it installed. It is a development
|
|
60
|
+
dependency of this gem, never a runtime one.
|
|
61
|
+
|
|
62
|
+
**Both classes accept an injectable `chat:`.** The test suite passes a
|
|
63
|
+
`FakeChat` and makes zero network calls. No test may reach a real provider.
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
elicitor = SQA::BI::LlmPriorElicitor.new(outcomes: outcomes, chat: FakeChat.new(canned))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Likelihoods are clamped to `[0.001, 0.999]`.** An overconfident model
|
|
70
|
+
answering `0.0` would zero out a hypothesis permanently — no later evidence
|
|
71
|
+
could ever revive it. Cromwell's rule, enforced in code.
|
|
72
|
+
|
|
73
|
+
**Prompt building and response parsing are public and pure.** `build_prompt`,
|
|
74
|
+
`prior_from_response`, and `likelihoods_from_response` take input and return
|
|
75
|
+
output with no I/O, so prompt wording can be iterated on and tested directly.
|
|
76
|
+
|
|
77
|
+
**`LlmSupport` is all `module_function`.** Every helper — JSON extraction,
|
|
78
|
+
re-keying, normalization, clamping — is callable and testable in isolation.
|
|
79
|
+
|
|
80
|
+
## Local first
|
|
81
|
+
|
|
82
|
+
Provider resolution probes LM Studio, then Apfel, then falls back to cloud.
|
|
83
|
+
That fallback is **silent by design**, which means a stopped local server looks
|
|
84
|
+
exactly like a deliberate cloud run until a provider error surfaces.
|
|
85
|
+
|
|
86
|
+
[**→ Providers and Local Models**](providers.md) covers the setup, the two ways
|
|
87
|
+
it silently fails, and `LlmSupport.current_resolution` for making it visible.
|
|
88
|
+
|
|
89
|
+
## Verified results
|
|
90
|
+
|
|
91
|
+
Both patterns were validated before being documented; see
|
|
92
|
+
[Exploration Notes](../EXPLORATION.md) for the full record.
|
|
93
|
+
|
|
94
|
+
**Prior elicitation.** With only 5 training observations the data-only posterior
|
|
95
|
+
was a dead tie between "sideways" and "mild uptrend" (0.358 each). The LLM read
|
|
96
|
+
a bullish market context, produced a prior peaked at +1/+2, and the informed
|
|
97
|
+
posterior broke the tie: mild uptrend at 50.0%, confidence up from 19.9% to
|
|
98
|
+
30.1%.
|
|
99
|
+
|
|
100
|
+
**Likelihood estimation.** A production-outage diagnosis over three hypotheses.
|
|
101
|
+
After evidence 1–2, `bad_deploy` led at 78.8%. Evidence 3 ("rollback didn't
|
|
102
|
+
help") and 4 ("cross-AZ ping 1ms → 900ms") cleanly reversed the belief; the
|
|
103
|
+
final posterior put `network` at 99.96%. Information gain per update ranged
|
|
104
|
+
0.025–0.959 bits.
|
|
105
|
+
|
|
106
|
+
## Patterns not yet built
|
|
107
|
+
|
|
108
|
+
Recorded in the exploration notes as future work:
|
|
109
|
+
|
|
110
|
+
1. **Bayesian calibration of LLM outputs** — treat the LLM as a noisy sensor
|
|
111
|
+
with a measured confusion matrix `P(LLM says j | truth is i)`, estimated from
|
|
112
|
+
a labeled set. An LLM classification then becomes a likelihood column, and
|
|
113
|
+
Bayes yields calibrated posteriors instead of the model's overconfident
|
|
114
|
+
self-reported probabilities.
|
|
115
|
+
2. **Self-consistency as sampling** — query k times at temperature > 0, treat
|
|
116
|
+
the answers as draws, update a Dirichlet posterior over the answer
|
|
117
|
+
distribution. Gives error bars on LLM judgments and a principled stopping
|
|
118
|
+
rule.
|
|
119
|
+
3. **Bandwidth and hyperparameter elicitation** — let the model read a dataset
|
|
120
|
+
description and suggest bandwidth and outcome coding, closing the last manual
|
|
121
|
+
knob.
|
|
122
|
+
4. **Posterior narration** — the inverse direction. Hand the LLM a posterior and
|
|
123
|
+
its KL trail and have it write the report paragraph. Zero math risk, pure
|
|
124
|
+
language work.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Likelihood Estimation
|
|
2
|
+
|
|
3
|
+
`SQA::BI::LlmLikelihoodEstimator` uses a language model as a likelihood function
|
|
4
|
+
over evidence that is text rather than numbers.
|
|
5
|
+
|
|
6
|
+
## The problem it solves
|
|
7
|
+
|
|
8
|
+
[`Likelihood`](../guide/likelihood.md) needs numeric feature vectors and
|
|
9
|
+
history. But much real-world evidence is prose: a log line, an incident report,
|
|
10
|
+
a news headline, a witness statement. There is no sensible way to embed *"the
|
|
11
|
+
rollback didn't help"* as a float.
|
|
12
|
+
|
|
13
|
+
An LLM can judge `P(evidence | hypothesis)` for that kind of evidence. It is
|
|
14
|
+
notably bad at combining several such judgments coherently — so it doesn't.
|
|
15
|
+
Ruby does that part.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
estimator = SQA::BI::LlmLikelihoodEstimator.new(
|
|
21
|
+
hypotheses: {
|
|
22
|
+
bad_deploy: "The 14:02 deploy introduced a bug",
|
|
23
|
+
database: "The primary database is degraded",
|
|
24
|
+
network: "There is a network partition between AZs"
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
estimator.likelihoods("Error rate spiked 2 minutes after deploy")
|
|
29
|
+
# => { bad_deploy: 0.9, database: 0.2, network: 0.15 }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
!!! important "These are not probabilities of the hypotheses"
|
|
33
|
+
They need not sum to 1, and normally won't. Each answers an independent
|
|
34
|
+
question: *if this hypothesis were true, how surprising would this evidence
|
|
35
|
+
be?* Turning them into beliefs about the hypotheses is what
|
|
36
|
+
[`Posterior`](../guide/posterior.md) is for.
|
|
37
|
+
|
|
38
|
+
## Chaining evidence
|
|
39
|
+
|
|
40
|
+
The full pattern — each posterior becomes the next prior:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
HYPOTHESES = {
|
|
44
|
+
bad_deploy: "The 14:02 deploy introduced a bug",
|
|
45
|
+
database: "The primary database is degraded",
|
|
46
|
+
network: "There is a network partition between AZs"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
estimator = SQA::BI::LlmLikelihoodEstimator.new(hypotheses: HYPOTHESES)
|
|
50
|
+
prior = SQA::BI::Prior.new(HYPOTHESES.keys)
|
|
51
|
+
|
|
52
|
+
EVIDENCE.each do |evidence|
|
|
53
|
+
likelihoods = estimator.likelihoods(evidence)
|
|
54
|
+
posterior = SQA::BI::Posterior.new(prior, likelihoods)
|
|
55
|
+
|
|
56
|
+
puts "#{evidence}"
|
|
57
|
+
puts " information gain: #{posterior.kl_divergence_from_prior.round(3)} bits"
|
|
58
|
+
posterior.to_a.each { |id, p| puts " #{id}: #{(p * 100).round(1)}%" }
|
|
59
|
+
|
|
60
|
+
prior = SQA::BI::Prior.new(HYPOTHESES.keys, posterior.to_h)
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`posterior_n ∝ P(evidence_n | H) × posterior_{n-1}`, computed exactly, with no
|
|
65
|
+
double-counting and no anchoring drift.
|
|
66
|
+
|
|
67
|
+
!!! warning "Conditional independence is assumed"
|
|
68
|
+
Chaining treats each piece of evidence as independent given the hypothesis.
|
|
69
|
+
Two log lines restating the same underlying fact get counted twice and will
|
|
70
|
+
overstate confidence. Deduplicate before feeding in — the arithmetic cannot
|
|
71
|
+
detect it.
|
|
72
|
+
|
|
73
|
+
## The clamp
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
def likelihoods_from_response(content)
|
|
77
|
+
raw = extract_json(content)
|
|
78
|
+
rekey_to_outcomes(raw, @hypotheses.keys)
|
|
79
|
+
.transform_values { |v| clamp_likelihood(v) } # [0.001, 0.999]
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This is load-bearing. Without it, a model answering `0.0` for a hypothesis
|
|
84
|
+
eliminates it permanently — the posterior multiplies through, and no subsequent
|
|
85
|
+
evidence can revive a zero.
|
|
86
|
+
|
|
87
|
+
The prompt also asks for values between 0.01 and 0.99 and says explicitly that
|
|
88
|
+
evidence is rarely impossible or certain. The clamp enforces what the prompt
|
|
89
|
+
requests, because prompts are not contracts.
|
|
90
|
+
|
|
91
|
+
## Verified behavior
|
|
92
|
+
|
|
93
|
+
From `examples/05_llm_likelihood_diagnosis.rb` — a production outage, five
|
|
94
|
+
pieces of evidence arriving in order:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
Initial belief: bad_deploy 33.3% database 33.3% network 33.3%
|
|
98
|
+
|
|
99
|
+
Evidence 1: HTTP 500 error rate jumped from 0.1% to 8% at 14:04, two minutes
|
|
100
|
+
after the deploy finished.
|
|
101
|
+
P(e|H): bad_deploy=0.95 database=0.60 network=0.10
|
|
102
|
+
gain: 0.351 bits bad_deploy 57.6% database 36.4% network 6.1%
|
|
103
|
+
...
|
|
104
|
+
Evidence 5: The cloud provider posted a networking incident for our region.
|
|
105
|
+
P(e|H): bad_deploy=0.05 database=0.05 network=0.90
|
|
106
|
+
gain: 0.054 bits bad_deploy 0.1% database 0.1% network 99.7%
|
|
107
|
+
|
|
108
|
+
Final diagnosis: network at 99.7% confidence
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Note the arc. Evidence 1 looks damning for the deploy — a 500 spike two minutes
|
|
112
|
+
after a release is about as incriminating as observational evidence gets. But
|
|
113
|
+
because likelihoods are clamped away from 0 and 1, Bayes never fully commits,
|
|
114
|
+
and evidence 3–5 cleanly reverse the belief.
|
|
115
|
+
|
|
116
|
+
!!! quote "This is the whole argument"
|
|
117
|
+
A single LLM asked to track the same five facts in prose typically anchors
|
|
118
|
+
on the deploy story and stays there. The belief state is not in the
|
|
119
|
+
conversation — it is in a `Posterior` object, recomputed from scratch each
|
|
120
|
+
step. There is nothing to anchor *to*.
|
|
121
|
+
|
|
122
|
+
And the KL divergence tells you **which evidence mattered**: per-update
|
|
123
|
+
information gain ranged 0.025–0.959 bits across the run, and the large values
|
|
124
|
+
land exactly on the two facts that flipped the conclusion.
|
|
125
|
+
|
|
126
|
+
## Testing without a network
|
|
127
|
+
|
|
128
|
+
`build_prompt` and `likelihoods_from_response` are public and pure:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
estimator = SQA::BI::LlmLikelihoodEstimator.new(
|
|
132
|
+
hypotheses: { a: "A happened", b: "B happened" }
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
estimator.likelihoods_from_response('{"a": 0.9, "b": 0.1}')
|
|
136
|
+
# => { a: 0.9, b: 0.1 }
|
|
137
|
+
|
|
138
|
+
# Clamping is observable:
|
|
139
|
+
estimator.likelihoods_from_response('{"a": 1.0, "b": 0.0}')
|
|
140
|
+
# => { a: 0.999, b: 0.001 }
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Beyond diagnosis
|
|
144
|
+
|
|
145
|
+
The shape generalizes past incident triage. Anywhere you have competing
|
|
146
|
+
explanations and a stream of textual evidence:
|
|
147
|
+
|
|
148
|
+
- **Market regime classification** — headlines and filings as evidence for
|
|
149
|
+
"risk-on" / "risk-off" / "rotation"
|
|
150
|
+
- **Sentiment with a memory** — each article updates a belief instead of
|
|
151
|
+
producing an isolated score
|
|
152
|
+
- **Root-cause analysis** outside software entirely
|
|
153
|
+
|
|
154
|
+
The one requirement: hypotheses must be **mutually exclusive and collectively
|
|
155
|
+
exhaustive**. If the real answer is "both the deploy *and* the network", a
|
|
156
|
+
posterior over three single-cause hypotheses cannot represent it, and will
|
|
157
|
+
confidently pick whichever one the evidence favors slightly.
|
|
158
|
+
|
|
159
|
+
## API
|
|
160
|
+
|
|
161
|
+
See [LLM Elicitors](../api/llm-elicitors.md) for the full method list.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Prior Elicitation
|
|
2
|
+
|
|
3
|
+
`SQA::BI::LlmPriorElicitor` turns a natural-language description of a situation
|
|
4
|
+
into a validated [`Prior`](../guide/prior.md).
|
|
5
|
+
|
|
6
|
+
## The problem it solves
|
|
7
|
+
|
|
8
|
+
Every Bayesian method runs into the same objection: *where does the prior come
|
|
9
|
+
from?* The usual answers are unsatisfying — uniform (throws away everything you
|
|
10
|
+
know), historical frequency (wrong exactly when the regime has changed), or
|
|
11
|
+
hand-tuned (arbitrary).
|
|
12
|
+
|
|
13
|
+
An LLM is a compressed, queryable archive of domain knowledge. That is precisely
|
|
14
|
+
what a prior is supposed to encode.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
elicitor = SQA::BI::LlmPriorElicitor.new(
|
|
20
|
+
outcomes: [-2, -1, 0, 1, 2],
|
|
21
|
+
outcome_descriptions: {
|
|
22
|
+
-2 => "strong downtrend",
|
|
23
|
+
-1 => "mild downtrend",
|
|
24
|
+
0 => "sideways",
|
|
25
|
+
1 => "mild uptrend",
|
|
26
|
+
2 => "strong uptrend"
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
prior = elicitor.elicit(<<~CONTEXT)
|
|
31
|
+
The Federal Reserve unexpectedly cut interest rates by 50 basis points
|
|
32
|
+
yesterday. Tech-sector earnings this week broadly beat expectations.
|
|
33
|
+
However, unemployment claims ticked up slightly and consumer sentiment
|
|
34
|
+
is flat. Volatility (VIX) has dropped from 22 to 16 over five sessions.
|
|
35
|
+
CONTEXT
|
|
36
|
+
|
|
37
|
+
prior.probabilities
|
|
38
|
+
# => {-2 => 0.0, -1 => 0.02, 0 => 0.08, 1 => 0.35, 2 => 0.55}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Feed it into a predictor with `update_prior: false`, or training will overwrite
|
|
42
|
+
the knowledge you just injected:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
predictor = SQA::BI::TimeSeriesPredictor.new(
|
|
46
|
+
outcomes: [-2, -1, 0, 1, 2],
|
|
47
|
+
prior_probabilities: prior.probabilities,
|
|
48
|
+
update_prior: false
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
!!! tip "`outcome_descriptions` is optional but not really"
|
|
53
|
+
Without labels the prompt shows bare integers, and the model has to guess
|
|
54
|
+
what `-2` means. The labels are how the semantic knowledge gets connected to
|
|
55
|
+
your outcome scale — supply them.
|
|
56
|
+
|
|
57
|
+
## What actually happens
|
|
58
|
+
|
|
59
|
+
The LLM is asked for **relative weights between 0 and 100**, not probabilities.
|
|
60
|
+
It never normalizes, never does arithmetic. Ruby handles all of that:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
def prior_from_response(content)
|
|
64
|
+
raw = extract_json(content) # find the JSON object
|
|
65
|
+
weights = rekey_to_outcomes(raw, @outcomes) # "1" (string) → 1 (integer)
|
|
66
|
+
Prior.new(@outcomes, normalize_distribution(weights))
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Step | Purpose |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `extract_json` | pull a Hash out of a Hash, a raw JSON string, or prose containing a fenced JSON block |
|
|
73
|
+
| `rekey_to_outcomes` | JSON keys are always strings; outcomes may be integers or symbols. Matches on `to_s`. Missing outcomes get `0.0` |
|
|
74
|
+
| `normalize_distribution` | add an epsilon floor, then normalize to sum to 1 |
|
|
75
|
+
| `Prior.new` | full validation — sums to 1, non-negative, complete |
|
|
76
|
+
|
|
77
|
+
### The epsilon floor
|
|
78
|
+
|
|
79
|
+
`normalize_distribution` adds `1e-6` to every weight before normalizing, so no
|
|
80
|
+
outcome is ever assigned exactly zero.
|
|
81
|
+
|
|
82
|
+
!!! warning "Cromwell's rule"
|
|
83
|
+
A zero prior can never recover, no matter how much evidence arrives — the
|
|
84
|
+
posterior multiplies prior by likelihood, and zero times anything is zero.
|
|
85
|
+
An LLM that confidently writes `"−2": 0` would otherwise permanently
|
|
86
|
+
eliminate an outcome on the strength of one prose paragraph.
|
|
87
|
+
|
|
88
|
+
## Testing without a network
|
|
89
|
+
|
|
90
|
+
Both `build_prompt` and `prior_from_response` are public and pure:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
elicitor = SQA::BI::LlmPriorElicitor.new(outcomes: [-1, 0, 1])
|
|
94
|
+
|
|
95
|
+
prior = elicitor.prior_from_response('{"-1": 10, "0": 20, "1": 70}')
|
|
96
|
+
prior.probability(1) # => 0.7
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Or inject a fake chat to exercise the full path:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
class FakeChat
|
|
103
|
+
Response = Struct.new(:content)
|
|
104
|
+
attr_reader :last_prompt
|
|
105
|
+
|
|
106
|
+
def initialize(content) = @content = content
|
|
107
|
+
def ask(prompt)
|
|
108
|
+
@last_prompt = prompt
|
|
109
|
+
Response.new(@content)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
chat = FakeChat.new('{"-1": 10, "0": 20, "1": 70}')
|
|
114
|
+
elicitor = SQA::BI::LlmPriorElicitor.new(outcomes: [-1, 0, 1], chat: chat)
|
|
115
|
+
|
|
116
|
+
elicitor.elicit("bullish setup")
|
|
117
|
+
chat.last_prompt # assert on the prompt wording
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Verified behavior
|
|
121
|
+
|
|
122
|
+
From the [exploration notes](../EXPLORATION.md), `examples/04_llm_elicited_prior.rb`:
|
|
123
|
+
|
|
124
|
+
With only 5 training observations, the data-only posterior was a dead tie:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
Posterior(-2: 0.000, -1: 0.139, 0: 0.358, 1: 0.358, 2: 0.144)
|
|
128
|
+
MAP outcome: 1 (mild uptrend)
|
|
129
|
+
Confidence: 19.9%
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The LLM read the bullish context and returned a prior peaked at +1/+2. The
|
|
133
|
+
informed posterior broke the tie:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
Posterior(-2: 0.000, -1: 0.040, 0: 0.256, 1: 0.576, 2: 0.129)
|
|
137
|
+
MAP outcome: 1 (mild uptrend)
|
|
138
|
+
Confidence: 34.2%
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Model quality shows up as prior sharpness.** qwen3.8-27b via LM Studio gave a
|
|
142
|
+
bullish prior peaked at +1; Apple's 3B on-device model was more cautious and
|
|
143
|
+
peaked at 0. Neither is wrong — but the bigger model commits more, and that
|
|
144
|
+
commitment is visible in the entropy.
|
|
145
|
+
|
|
146
|
+
!!! note "The elicited prior correctly fades"
|
|
147
|
+
As real observations accumulate the KDE likelihood increasingly dominates
|
|
148
|
+
the product, and the informed and uniform-prior posteriors converge. The
|
|
149
|
+
prior matters most in the small-data regime — exactly where KDE is weakest
|
|
150
|
+
and you have least else to go on.
|
|
151
|
+
|
|
152
|
+
## When to use it
|
|
153
|
+
|
|
154
|
+
**Good fits**
|
|
155
|
+
|
|
156
|
+
- **Cold start.** You have a model and almost no observations.
|
|
157
|
+
- **Regime change.** The historical base rate is actively misleading.
|
|
158
|
+
- **Domain knowledge with no numeric form.** "The Fed just cut rates" is not a
|
|
159
|
+
feature vector.
|
|
160
|
+
|
|
161
|
+
**Poor fits**
|
|
162
|
+
|
|
163
|
+
- **You have plenty of data.** The empirical base rate is better, and free.
|
|
164
|
+
- **The situation is genuinely unprecedented.** The model has nothing to compress
|
|
165
|
+
and will produce a confident-sounding guess.
|
|
166
|
+
- **You need auditability.** An elicited prior is not reproducible across model
|
|
167
|
+
versions. Record the exact model id and the context string alongside any
|
|
168
|
+
result you intend to defend.
|
|
169
|
+
|
|
170
|
+
## API
|
|
171
|
+
|
|
172
|
+
See [LLM Elicitors](../api/llm-elicitors.md) for the full method list.
|