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
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
+ ![Prior × Likelihood → Posterior](assets/diagrams/bayes-pipeline.svg)
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
+ ![LLM–Bayes loop](../assets/diagrams/llm-bayes-loop.svg)
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.