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,391 @@
1
+ # Decision Support Techniques — Log
2
+
3
+ Running log of investigations into non-LLM (and LLM-adjacent) decision-making
4
+ techniques for the SQA ecosystem. Newest entries at the bottom. Each entry
5
+ records what was asked, what was actually verified (with evidence), what was
6
+ concluded, and what was left open.
7
+
8
+ ---
9
+
10
+ ## 2026-09-20 — Laya (System 1 decision model) evaluation
11
+
12
+ ### Question
13
+
14
+ Laya is a new kind of model getting attention for decision-making. Does it have
15
+ a place in SQA? Specifically: can it forecast whether a stock's price will rise
16
+ or fall 1–10 days after a historical event, given end-of-day OHLCV plus a set of
17
+ TA indicators — producing a probability distribution of rise/fall across that
18
+ 1–10 day horizon?
19
+
20
+ Sources:
21
+ - https://laya.convaiinnovations.com/
22
+ - https://github.com/NandhaKishorM/laya
23
+
24
+ Starting assumption (mine): LM Studio is serving 3 Laya models locally, and the
25
+ `ruby_llm` provider gems `lms` and `apfel` would reach them.
26
+
27
+ ### What Laya actually is
28
+
29
+ Not a generative LLM. A **non-autoregressive bidirectional encoder** on
30
+ ModernBERT-large (421M) or mmBERT-base (322M) backbones, trained with
31
+ reinforcement learning with calibrated decisions (RLCD). Apache 2.0.
32
+
33
+ - Outputs **typed decisions only**, never text:
34
+ - `choice` — pick from options; returns key + per-option probabilities
35
+ - `score` — ordinal scale; returns expected level + distribution
36
+ - `noul` — boolean question; returns calibrated P(true) 0.0–1.0
37
+ - ~33ms single forward pass (T4 GPU), 7.2ms/question batched
38
+ - All questions answered in one forward pass
39
+ - Calibrated confidence (ships over-confident; temperature fitting moves ECE
40
+ 0.466 → 0.081)
41
+ - Three checkpoints: `laya` (English), `laya-multilingual` (100+ langs),
42
+ `laya-typed-decisions` (fine-tuned domain tasks)
43
+ - Python SDK only: `laya.load()`, `Router#predict(state, questions)`,
44
+ `Agent#predict`. Input is text / JSON / structured state.
45
+
46
+ Explicitly **not OpenAI chat-completion compatible**. There is no standalone
47
+ HTTP server mode — inference lives inside the Python package.
48
+
49
+ ### Finding 1 — the `ruby_llm` provider path does not reach Laya
50
+
51
+ Read both installed provider gems (v0.2.1 each):
52
+
53
+ - `ruby_llm-providers-lms` — LM Studio. Covers three protocols on one port:
54
+ OpenAI (`/v1/chat/completions`, `/v1/responses`, `/v1/embeddings`), Anthropic
55
+ (`/v1/messages`), and LM Studio native REST (`/api/v0`, `/api/v1`). All of it
56
+ is **chat completions and embeddings**.
57
+ - `ruby_llm-providers-apfel` — Apple on-device Foundation Model via the `apfel`
58
+ CLI's OpenAI-compatible server. Same story.
59
+
60
+ Neither exposes anything resembling a typed-decision primitive, and LM Studio
61
+ has no knowledge of Laya's classification heads. Even with the base encoder
62
+ weights loaded, hitting `/v1/embeddings` gives you raw encoder output with the
63
+ decision heads and calibration skipped entirely — which is the whole point of
64
+ the model. Whatever the "3 laya models in LM Studio" are, the decision logic is
65
+ not reachable through any endpoint LM Studio serves.
66
+
67
+ ### Finding 2 — fine-tuning Laya in Ruby is not viable
68
+
69
+ Verified against the installed gems:
70
+
71
+ | Path | Status |
72
+ |---|---|
73
+ | `ruby_llm` + `:lms` / `:apfel` | LM Studio and Apfel are inference servers. No training endpoint exists in any of their protocols. Categorical dead end. |
74
+ | `transformers-rb` 0.2.0 | Model zoo is exactly `auto, bert, deberta_v2, distilbert, mpnet, xlm_roberta, vit`. **No ModernBERT, no mmBERT** — both of Laya's backbones are absent. |
75
+ | `red-candle` 1.8.1 | Inference only — `llm, embedding_model, reranker, ner, vlm, tool`. No autograd/optimizer surface. |
76
+ | `torch-rb` 0.26.0 | **Can genuinely train** — full optimizer suite (`adam, adamw, sgd, rmsprop, adagrad, adadelta, adamax, asgd, rprop`, plus `lr_scheduler`). |
77
+
78
+ So fine-tuning a plain BERT/DistilBERT in Ruby is real and available. It is
79
+ ModernBERT specifically that is missing. Going that route would mean hand-porting
80
+ ModernBERT (rotary embeddings, alternating local/global attention, GeGLU,
81
+ unpadding) into torch-rb **and** reverse-engineering Laya's decision-head and
82
+ question-templating scheme out of the Python SDK. Weeks of work to reproduce
83
+ something that already exists in Python.
84
+
85
+ ### Finding 3 — the task is tabular, not textual
86
+
87
+ The decisive point. The features are OHLCV + TA indicators (numeric); the target
88
+ is direction at k = 1…10. That is tabular binary classification × 10 horizons.
89
+
90
+ Laya is a 421M-parameter **text** encoder. Using it here means serializing floats
91
+ into digit-tokens and asking self-attention to do arithmetic. Its own docs note
92
+ base checkpoints are weak zero-shot on specialized tasks (0.36 accuracy vs 0.46
93
+ random baseline on typed-decisions) and that ordinal scoring is its weakest
94
+ primitive (0.37 on SST-5). A gradient-boosted tree on the same columns should
95
+ beat it, train in seconds on CPU, and predict in microseconds.
96
+
97
+ Every property that made Laya attractive is reproducible in Ruby:
98
+
99
+ | Laya property | Ruby equivalent |
100
+ |---|---|
101
+ | `noul` → calibrated P(true) | binary GBDT + Platt/isotonic calibration on a held-out split |
102
+ | calibrated confidence (ECE 0.466 → 0.081 via temp fitting) | same technique, and validatable against our own backtests |
103
+ | all questions in one forward pass, ~33ms | 10 small models, <1ms total |
104
+ | schema-safe, no hallucination | inherent to a classifier |
105
+ | `choice` / `score` primitives | multiclass / ordinal — `rumale` has both |
106
+
107
+ ### Verified Ruby ML stack on this machine
108
+
109
+ Richer than expected. All already installed:
110
+
111
+ - `eps` 0.7.0 — **already a `sqa` gemspec dependency**. `Eps::Model#train(data, target:, algorithm: :lightgbm)`, `#predict_probability`, `#evaluate`, `#summary`.
112
+ - `lightgbm` 0.5.0
113
+ - `rumale` 2.2.0 — full suite: `ensemble, linear_model, neural_network, tree, naive_bayes, model_selection, evaluation_measure, preprocessing, pipeline, kernel_machine, nearest_neighbors, decomposition, clustering, manifold, metric_learning, feature_extraction, kernel_approximation`
114
+ - `torch-rb` 0.26.0, `transformers-rb` 0.2.0, `tokenizers` 0.6.4
115
+ - `red-candle` 1.8.1, `informers` 1.3.0, `onnxruntime` 0.11.7
116
+ - `numo-narray` 0.9.2.1 + `numo-linalg` / `numo-openblas` / `numo-optimize`
117
+ - `polars-df` 0.27.1 (already backing `SQA::DataFrame`), `rover-df`, `daru`
118
+
119
+ The tabular path therefore needs **zero new dependencies, zero Python, zero GPU**.
120
+
121
+ Relevant existing `sqa` machinery: `ensemble.rb` (majority/weighted/confidence
122
+ voting over strategies), `gp.rb` (genetic programming), `pattern_matcher.rb`,
123
+ `market_regime.rb`, `backtest.rb`, `strategy_generator.rb`.
124
+
125
+ ### Conclusion
126
+
127
+ > **Partially superseded — see the next entry.** Item 1 below was revised once
128
+ > the existing `bayesian_inference` project came to light. Item 2 still stands,
129
+ > and got sharper.
130
+
131
+ Split the two concerns.
132
+
133
+ 1. **Price-direction forecasting — pure Ruby, no Laya.** Build on `eps`
134
+ (LightGBM) + explicit calibration, living in `sqa`. Keep a Laya-style
135
+ typed-decision API surface (`noul`-shaped calibrated probabilities) because
136
+ the ergonomics are good, but the engine underneath is a tree ensemble on
137
+ tabular features.
138
+
139
+ 2. **Laya for the text side — genuinely worth a POC, later.** `sqa-advisor`
140
+ currently spends LLM calls on work Laya is purpose-built for: triaging news
141
+ headlines, classifying filing/earnings-call sentiment, routing which of its
142
+ 14 tools to run, guardrailing inputs. 33ms vs 500–2000ms per decision,
143
+ calibrated, hallucination-free. That POC needs a small Python sidecar
144
+ wrapping `Router`/`Agent` (stdlib `http.server` is enough) plus a thin
145
+ `Net::HTTP` Ruby client — **not** `ruby_llm`, since Laya is not chat-shaped.
146
+
147
+ ### Open / next
148
+
149
+ - Proposed but not yet built: Ruby forecaster POC — feature builder off
150
+ `SQA::DataFrame` (OHLCV + selected TAI indicators), 10 calibrated horizon
151
+ models, and a `RiseFallForecast` value object rendering the 1–10 day
152
+ rise/fall probability distribution. Awaiting go-ahead.
153
+ - Undecided: exact indicator set for the feature vector.
154
+ - Undecided: whether historical event/outcome pairs come from live Alpha Vantage
155
+ pulls or a cached local dataset.
156
+ - Not started: the `sqa-advisor` Laya text-decision sidecar POC.
157
+
158
+ ---
159
+
160
+ ## 2026-09-20 (follow-up) — The Bayesian thread was the real one
161
+
162
+ ### Trigger
163
+
164
+ Noted mid-conversation: separate ongoing work on Bayesian inference, with a
165
+ suspicion that the JEV/Laya excitement had gotten tangled up in it.
166
+
167
+ It had — but the tangle was informative rather than a mistake, and it changes
168
+ the conclusion above.
169
+
170
+ ### Discovery — the engine already exists
171
+
172
+ `~/sandbox/git_repos/madbomber/experiments/ai_misc/bayesian_inference/` already
173
+ implements most of what the entry above proposed building from scratch.
174
+
175
+ From its README and source layout:
176
+
177
+ - **Purpose**: Bayesian inference over *discrete outcomes* from time series —
178
+ exactly the problem shape in question.
179
+ - **Outcomes**: `[-2, -1, 0, 1, 2]`, labeled strong downtrend / mild downtrend /
180
+ … / strong uptrend. That is the rise-fall question, already modeled as an
181
+ **ordinal** outcome.
182
+ - **Method**: priors (uniform or custom, auto-updating from observations),
183
+ Gaussian **kernel density estimation** for likelihoods, full posterior via
184
+ Bayes' theorem.
185
+ - **Uncertainty**: entropy, confidence scores, sampling from the posterior —
186
+ native, not bolted on.
187
+ - **Library**: `prior.rb`, `likelihood.rb`, `posterior.rb`,
188
+ `time_series_predictor.rb`, `llm_prior_elicitor.rb`,
189
+ `llm_likelihood_estimator.rb`, `llm_support.rb`.
190
+ - **Examples**: `02_time_series_prediction.rb`,
191
+ `03_stock_market_prediction_v2.rb`, `04_llm_elicited_prior.rb`,
192
+ `05_llm_likelihood_diagnosis.rb`, plus `sqa_polars_compat.rb` (already
193
+ bridging to `SQA::DataFrame`) and `pure_ruby_indicators.rb`.
194
+ - **LLM integration**: providers auto-detected **local-first** — LM Studio
195
+ (`ruby_llm-providers-lms`), then Apfel (`ruby_llm-providers-apfel`), then
196
+ cloud. Overridable via `BI_LLM_PROVIDER` / `BI_LLM_MODEL`. This is the actual
197
+ origin of the `lms` / `apfel` thread in the original question.
198
+
199
+ ### Why Laya pattern-matched so strongly
200
+
201
+ Because Laya emits **the same output shape this project already produces**.
202
+
203
+ | Laya primitive | Equivalent here |
204
+ |---|---|
205
+ | `score` — expected ordinal level + distribution over ranks | `outcomes: [-2,-1,0,1,2]` with a posterior over them |
206
+ | `noul` — calibrated P(true) | posterior probability of a binary outcome |
207
+ | calibrated confidence, no hallucination, typed decisions | entropy / confidence from the posterior, by construction |
208
+
209
+ Laya's entire pitch is a description of this gem's architecture. The difference
210
+ is only in *how the mapping is learned*: Laya learns it from text via RLCD; this
211
+ learns it from historical numeric data via KDE + Bayes. For OHLCV + TA indicator
212
+ features, the latter is the right mechanism — no text serialization required.
213
+
214
+ ### Revision to the previous entry
215
+
216
+ The earlier recommendation — build a new `eps`/LightGBM forecaster with Platt
217
+ calibration inside `sqa` — is a step sideways given what already exists. The
218
+ Bayesian predictor is better suited on three counts:
219
+
220
+ 1. It yields a **full posterior**, not a point probability — uncertainty about
221
+ the uncertainty.
222
+ 2. It handles the **ordinal structure natively**. Ten independent binary GBDTs
223
+ would discard both the ordinal ordering and the strong correlation between
224
+ adjacent horizons (P(rise at day 3) and P(rise at day 4) are not independent).
225
+ 3. Calibration and confidence are intrinsic rather than post-hoc.
226
+
227
+ `eps` / LightGBM keeps a role as a **benchmark** to score the posterior against,
228
+ not as the primary engine.
229
+
230
+ ### Where the two threads actually converge
231
+
232
+ At `llm_likelihood_estimator.rb` — and this is the genuinely interesting result.
233
+
234
+ That file currently uses an LLM as a likelihood function over textual evidence:
235
+ 500–2000ms per call, with no calibration guarantee. Laya does exactly that job
236
+ in ~33ms, calibrated, hallucination-free, with typed output. The dispatch seam
237
+ already exists (`llm_support.rb`, provider auto-detection).
238
+
239
+ **Laya as a fast calibrated likelihood over text (news, filings, earnings calls),
240
+ feeding a Bayesian posterior over price direction.** The Python sidecar is still
241
+ required, since Laya inference lives only in the Python package — but it plugs
242
+ into an architecture that is already built, rather than replacing it.
243
+
244
+ This also subsumes the `sqa-advisor` idea from the previous entry: same sidecar,
245
+ same client, two consumers.
246
+
247
+ ### On JEV
248
+
249
+ Unresolved. The only encounter so far is as a benchmark baseline inside Laya's
250
+ own README: Laya 0.766 vs Jev 0.727 on typed-decisions, with Jev reportedly
251
+ stronger on high-cardinality choices (50+ options). No independent knowledge of
252
+ it; deliberately not guessed at. Worth proper research before it influences any
253
+ design decision here.
254
+
255
+ ### Revised open / next
256
+
257
+ - **Dropped**: building a fresh `eps`/LightGBM forecaster in `sqa`. Superseded
258
+ by the existing Bayesian predictor.
259
+ - **Proposed, not yet started**: extend `time_series_predictor` to multi-horizon
260
+ (k = 1…10), ideally sharing strength across horizons rather than fitting each
261
+ independently. Needs a read of `EXPLORATION.md` and
262
+ `03_stock_market_prediction_v2.rb` first.
263
+ - **Proposed**: Laya sidecar as a drop-in alternative likelihood backend behind
264
+ the existing `llm_support.rb` provider dispatch, benchmarked against the
265
+ current LLM path on both latency and calibration (ECE).
266
+ - **Open**: research JEV properly.
267
+ - **Open**: indicator set for the feature vector; historical data source
268
+ (live Alpha Vantage vs cached) — both carried over from the previous entry.
269
+ - **Open**: whether `bayesian_inference` graduates from `experiments/ai_misc/`
270
+ into the `sqa_project` workspace as a proper dependency, given
271
+ `sqa_polars_compat.rb` already reaches toward `SQA::DataFrame`.
272
+
273
+ ---
274
+
275
+ ## 2026-09-20 (port) — `bayesian_inference` graduates into the workspace as `sqa-bi`
276
+
277
+ ### Trigger
278
+
279
+ The open item at the end of the previous entry — "whether
280
+ `bayesian_inference` graduates from `experiments/ai_misc/` into the
281
+ `sqa_project` workspace as a proper dependency" — answered yes. A bare
282
+ `bundle gem sqa-bi` skeleton existed; this entry records filling it in.
283
+
284
+ ### What moved
285
+
286
+ All of it, unchanged in substance:
287
+
288
+ - `lib/bayesian_inference/*.rb` → `lib/sqa/bi/*.rb`, namespace
289
+ `BayesianInference` → `SQA::BI`, entry point `require "sqa/bi"`.
290
+ - All 65 test methods, all 5 numbered examples, `EXPLORATION.md` (now
291
+ `docs/`), `llm_bayes_loop.svg`, `common.rb`, `pure_ruby_indicators.rb`.
292
+ - Environment overrides renamed `SQA_BI_LLM_PROVIDER` / `SQA_BI_LLM_MODEL`,
293
+ with the old `BI_LLM_*` names kept as a fallback via a new
294
+ `LlmSupport.env_value`.
295
+
296
+ ### Finding 1 — the ruby_llm conflict was never a real blocker
297
+
298
+ The prototype's `Gemfile` carried a comment asserting that `sqa` "cannot
299
+ live in this bundle" because `sqa` → `ruby_llm-mcp` pins `ruby_llm ~> 1.9`
300
+ against the `ruby_llm 2.0` the local provider gems need. Example 03 was
301
+ therefore documented as "run directly, unbundled."
302
+
303
+ The pin is real — `ruby_llm-mcp` 0.8.0 and 1.0.1 both require
304
+ `ruby_llm ~> 1.9` — but the dependency is not on `sqa` directly. It comes
305
+ in through `shared_tools`, and Bundler simply backs `shared_tools` down to
306
+ 0.2.3, which has no `ruby_llm-mcp` dependency at all. That is the same
307
+ resolution `sqa`'s own `Gemfile.local.lock` already sits on.
308
+
309
+ Verified: `sqa-bi/Gemfile.local` resolves `sqa 0.4.0` + `ruby_llm 2.0.0` +
310
+ `shared_tools 0.2.3` + both provider gems, and `examples/03` runs end to
311
+ end under `bundle exec` — 6761 days of T, 6739 feature vectors, full
312
+ confusion matrix.
313
+
314
+ ### Finding 2 — two shim files had gone stale
315
+
316
+ - `examples/sqa_polars_compat.rb` patched `sqa 0.0.38` against modern
317
+ `polars-df` (`read_csv dtypes:`, `Series#apply`, `with_column`, `sort
318
+ reverse:`) and forced Alpha Vantage compact fetches. Against `sqa 0.4.0`
319
+ none of it is needed — `grep` finds no remaining `.apply(`,
320
+ `with_column(`, `reverse:` or `dtypes:` in `sqa/lib`, and example 03
321
+ produces identical output with the require removed. **Deleted.**
322
+ - `examples/pure_ruby_indicators.rb` claimed to exist because
323
+ "sqa-tai's ta_lib_ffi hangs against ta-lib 0.8.x". `sqa-tai` 0.4.0
324
+ replaced `ta_lib_ffi`; `SQA::TAI.sma` and `.rsi` both return correct
325
+ values immediately. **Kept, rationale rewritten** — it now exists so the
326
+ examples carry no `sqa-tai` dependency, with a pointer to `sqa-tai` for
327
+ production use.
328
+
329
+ ### Architectural decision — `sqa-bi` is a leaf, not an extension
330
+
331
+ ```
332
+ sqa-tai ─┐
333
+ sqa-bi ─┴→ sqa → sqa-cli / sqa-advisor / sqa-rails / sqa-sinatra
334
+ ```
335
+
336
+ The gemspec declares **no runtime dependencies** and deliberately does not
337
+ depend on `sqa`. The inference math is domain-agnostic, so `sqa` can
338
+ depend on `sqa-bi` the way it depends on `sqa-tai`; a `sqa` dependency
339
+ here would invert the graph and produce a cycle the moment `sqa` picks it
340
+ up. `asgard` classifies it correctly on its own — `list_libs` returns
341
+ `sqa`, `sqa-bi`, `sqa-tai`; it is absent from the extension set that
342
+ `sync_rakefiles` and `sync_versions` target.
343
+
344
+ `ruby_llm` stays a development dependency, required lazily inside
345
+ `LlmSupport.build_chat`, so the KDE path loads without it.
346
+
347
+ ### Verification
348
+
349
+ | Check | Result |
350
+ |---|---|
351
+ | Test parity with the prototype | 65 test methods in, 65 out |
352
+ | `rake test` (prod bundle) | 65 tests, 130 assertions, 0 failures; 85.1% line coverage |
353
+ | `rake test` (dev bundle, with `sqa`) | same |
354
+ | `rake quality` | tests / flog / flay / reek all PASS |
355
+ | `rubocop` | 19 files, no offenses |
356
+ | Reek | 18 smells → 10 after fixing the trivial ones; 10 baselined |
357
+ | Example 01, 02 | run clean |
358
+ | Example 03 (needs `sqa`) | runs end to end under `bundle exec` |
359
+ | Example 04 (LLM prior) | gemini-2.5-flash: data-only tie 0.358/0.358 → informed 0.531 for mild uptrend, confidence 19.9% → 37.2% — matches the prototype's documented result |
360
+ | Example 05 (LLM likelihood) | network hypothesis reaches 99.7%, belief reverses on evidence 3–5 as documented |
361
+ | Provider dispatch after the env rename | falls through lms → apfel → cloud correctly |
362
+
363
+ Reek smells fixed rather than grandfathered: six `o` → `outcome` block
364
+ params, `w` → `weight`, `u` → `normalized_distance`, `v1`/`v2` →
365
+ `query`/`observed`, and a triple `features.size` call. The 10 left in
366
+ `.quality/reek_baseline.txt` are deliberate: FeatureEnvy inside the
367
+ `reduce`-based `entropy` / `kl_divergence_from_prior` /
368
+ `euclidean_distance`, TooManyStatements in `configure_ruby_llm` and
369
+ `compute_posterior`, and the BooleanParameter/ControlParameter pairs on
370
+ `TimeSeriesPredictor#initialize(update_prior:)` and `#reset!(keep_prior:)`
371
+ — API shape, not accidents.
372
+
373
+ ### Open / next
374
+
375
+ Unchanged from the previous entry, minus the graduation question:
376
+
377
+ - **Proposed, not started**: multi-horizon prediction (k = 1…10) in
378
+ `TimeSeriesPredictor`, sharing strength across horizons rather than
379
+ fitting each independently. Adjacent horizons are strongly correlated
380
+ and the outcome set is ordinal; neither should be discarded.
381
+ - **Proposed**: Laya sidecar as a drop-in alternative likelihood backend
382
+ behind `LlmSupport`'s provider dispatch, benchmarked against the current
383
+ LLM path on latency and calibration (ECE).
384
+ - **Kept as benchmark only**: `eps` / LightGBM, to score the posterior
385
+ against.
386
+ - **Open**: research JEV properly.
387
+ - **Open**: indicator set for the feature vector; historical data source.
388
+ - **New, minor**: `sqa-bi` is at 0.1.0 while the workspace versions in
389
+ lockstep at 0.4.0 (`asgard set_version` / `bump` hit all components).
390
+ Decide whether to fold it into the lockstep or let it release
391
+ independently first.
@@ -0,0 +1,128 @@
1
+ # Bayesian Inference × LLMs — an Exploration
2
+
3
+ This document maps the intersection between this library's Bayesian
4
+ machinery and large language models, and records what has been built
5
+ and verified so far.
6
+
7
+ ## The Core Insight
8
+
9
+ LLMs and Bayes' theorem have exactly complementary failure modes:
10
+
11
+ | | Semantic judgment | Probability arithmetic |
12
+ |---|---|---|
13
+ | **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 |
14
+ | **This library** | none — KDE needs numeric feature vectors | exact — normalization, entropy, KL divergence, sequential updates |
15
+
16
+ So the design rule for every experiment here:
17
+
18
+ > **The LLM judges. Ruby computes.**
19
+ > Ask the LLM only for isolated, independent judgments (a weight, a
20
+ > conditional probability). Never ask it to accumulate, normalize, or
21
+ > update beliefs — that is Bayes' job, done in Ruby.
22
+
23
+ ![LLM–Bayes loop](assets/diagrams/llm-bayes-loop.svg)
24
+
25
+ ## Pattern 1: LLM as Prior Elicitor (built ✅)
26
+
27
+ The eternal objection to Bayesian methods is "where does the prior come
28
+ from?" An LLM is a queryable compression of domain knowledge — exactly
29
+ the thing a prior is supposed to encode.
30
+
31
+ `LlmPriorElicitor` takes a natural-language situation description and
32
+ the outcome set, asks the LLM for relative weights, then floors,
33
+ normalizes, and validates them through the existing `Prior` class.
34
+
35
+ ```ruby
36
+ elicitor = SQA::BI::LlmPriorElicitor.new(
37
+ outcomes: [-2, -1, 0, 1, 2],
38
+ outcome_descriptions: { -2 => 'strong downtrend', ..., 2 => 'strong uptrend' }
39
+ )
40
+ prior = elicitor.elicit("The Fed unexpectedly cut rates by 50bp...")
41
+ ```
42
+
43
+ **Verified result** (`examples/04_llm_elicited_prior.rb`, gemini-2.5-flash):
44
+ with only 5 training observations, the data-only posterior was a dead
45
+ tie between "sideways" and "mild uptrend" (0.358 each). The LLM read
46
+ the bullish market context, produced a prior peaked at +1/+2, and the
47
+ informed posterior broke the tie: mild uptrend at 50.0%, confidence up
48
+ from 19.9% to 30.1%. As real data accumulates, the KDE likelihood
49
+ dominates and the influence of the elicited prior correctly fades.
50
+
51
+ **When it matters:** cold starts, regime changes, and any small-data
52
+ setting — precisely where KDE is weakest.
53
+
54
+ ## Pattern 2: LLM as Likelihood Function (built ✅)
55
+
56
+ `Likelihood` (KDE) requires numeric features and history. But much
57
+ evidence is text: log lines, incident reports, witness statements.
58
+ `LlmLikelihoodEstimator` asks the LLM one narrow question per
59
+ (evidence, hypothesis) pair — *"assuming H is true, how probable is
60
+ this evidence?"* — and Ruby chains the updates:
61
+
62
+ ```
63
+ posterior_n ∝ P(evidence_n | H) × posterior_{n-1}
64
+ ```
65
+
66
+ Likelihoods are clamped to [0.001, 0.999] so an overconfident LLM can
67
+ never zero out a hypothesis in one step (Cromwell's rule), which is
68
+ what keeps later contradicting evidence able to reverse the belief.
69
+
70
+ **Verified result** (`examples/05_llm_likelihood_diagnosis.rb`): a
71
+ production-outage diagnosis over three hypotheses. After evidence 1–2,
72
+ `bad_deploy` led at 78.8%. Evidence 3 ("rollback didn't help") and 4
73
+ ("cross-AZ ping 1ms → 900ms") cleanly reversed the belief; final
74
+ posterior: `network` at 99.96%. Information gain per update ranged
75
+ 0.025–0.959 bits — the engine also tells you *which evidence mattered*.
76
+
77
+ A single LLM asked to track the same five facts in prose typically
78
+ anchors on the deploy story. Externalizing the belief state into a
79
+ posterior removes that failure mode entirely.
80
+
81
+ ## Patterns Not Yet Built (future experiments)
82
+
83
+ 1. **Bayesian calibration of LLM outputs** — treat the LLM as a noisy
84
+ sensor with a measured confusion matrix P(LLM says j | truth is i),
85
+ estimated from a labeled set. Then an LLM classification becomes a
86
+ likelihood column and Bayes yields *calibrated* posteriors instead
87
+ of the LLM's overconfident self-reported probabilities.
88
+ 2. **Self-consistency as sampling** — query the LLM k times at
89
+ temperature > 0 and treat the answers as draws; update a Dirichlet
90
+ posterior over the answer distribution. Gives error bars on LLM
91
+ judgments and a principled stopping rule ("stop sampling when the
92
+ 95% credible interval no longer overlaps").
93
+ 3. **Bandwidth/hyperparameter elicitation** — let an LLM read a
94
+ dataset description and suggest KDE bandwidth and outcome coding,
95
+ closing the last manual knob in `TimeSeriesPredictor`.
96
+ 4. **Posterior narration** — the inverse direction: hand the LLM a
97
+ posterior + KL trail and have it write the incident-report
98
+ paragraph. Zero math risk, pure language work.
99
+
100
+ ## Engineering Notes
101
+
102
+ - **New files:** `lib/sqa/bi/llm_support.rb` (JSON
103
+ extraction, re-keying, normalization, clamping — all module
104
+ functions, testable in isolation), `llm_prior_elicitor.rb`,
105
+ `llm_likelihood_estimator.rb`.
106
+ - **Dependency discipline:** `ruby_llm` is required lazily inside
107
+ `LlmSupport.build_chat`, so the core math library still loads without
108
+ it. Both classes accept an injectable `chat:` object; the test suite
109
+ (`FakeChat` in `test/test_helper.rb`) runs with zero network calls.
110
+ - **Provider selection — local first:** `LlmSupport.resolve_provider`
111
+ auto-detects, in order: LM Studio via `ruby_llm-providers-lms`
112
+ (`http://localhost:1234/v1`), Apfel / Apple Foundation Models via
113
+ `ruby_llm-providers-apfel` (`http://127.0.0.1:11434/v1`), then cloud.
114
+ `SQA_BI_LLM_PROVIDER` (`lms` | `apfel` | `cloud`) and `SQA_BI_LLM_MODEL`
115
+ override detection. For local servers, `choose_local_model` prefers
116
+ qwen models (they honor JSON prompts most reliably in LM Studio),
117
+ then gpt-oss, skipping embedding/OCR models. Both local paths are
118
+ verified: qwen3.8-27b via LM Studio gave a bullish prior peaked at
119
+ +1; Apple's 3B on-device model gave a more cautious prior peaked
120
+ at 0 — model quality shows up directly as prior sharpness. Note: as
121
+ of 2026-09-18 the `ANTHROPIC_API_KEY` in this machine's environment
122
+ returns 401; the OpenAI key has no credits; the Gemini key works.
123
+ - **Ruby 4 note:** `logger` is no longer a default gem; it is in the
124
+ Gemfile because `ruby_llm` 1.16 requires it.
125
+ - **Test suite:** 65 runs, 0 failures, 85% line coverage.
126
+ `test_helper.rb` does `Object.include(DebugMe)` — the lib classes call
127
+ `debug_me` and previously only the examples included it, so the suite
128
+ could not run under `rake test`.
data/docs/api/index.md ADDED
@@ -0,0 +1,66 @@
1
+ # API Reference
2
+
3
+ Every public method, with its contract. For narrative explanation see the
4
+ [Guide](../guide/prior.md).
5
+
6
+ ## Namespace
7
+
8
+ ```ruby
9
+ require "sqa/bi" # NOT "sqa-bi"
10
+ ```
11
+
12
+ Everything lives under `SQA::BI`. The module can be included for brevity:
13
+
14
+ ```ruby
15
+ include SQA::BI
16
+
17
+ predictor = TimeSeriesPredictor.new(outcomes: [-1, 0, 1])
18
+ ```
19
+
20
+ ## Module methods
21
+
22
+ ### `SQA::BI.predictor(outcomes:, bandwidth: 1.0, **options)`
23
+
24
+ Convenience constructor. Forwards everything to
25
+ [`TimeSeriesPredictor.new`](time-series-predictor.md).
26
+
27
+ ```ruby
28
+ SQA::BI.predictor(outcomes: [-2, -1, 0, 1, 2], bandwidth: 0.5)
29
+ ```
30
+
31
+ ### `SQA::BI::VERSION`
32
+
33
+ The gem version string.
34
+
35
+ ### `SQA::BI::Error`
36
+
37
+ `StandardError` subclass. Raised for malformed LLM responses, an unavailable
38
+ local chat model, and a failed `ruby_llm` load.
39
+
40
+ ## Classes
41
+
42
+ | Class | Responsibility |
43
+ |---|---|
44
+ | [`Prior`](prior.md) | `P(outcome)` — belief before evidence |
45
+ | [`Likelihood`](likelihood.md) | `P(features \| outcome)` — Gaussian KDE |
46
+ | [`Posterior`](posterior.md) | `P(outcome \| features)` — Bayes plus uncertainty measures |
47
+ | [`TimeSeriesPredictor`](time-series-predictor.md) | the `train` / `predict` facade |
48
+ | [`LlmPriorElicitor`](llm-elicitors.md#sqabillmpriorelicitor) | a prior from prose |
49
+ | [`LlmLikelihoodEstimator`](llm-elicitors.md#sqabillmlikelihoodestimator) | likelihoods from text evidence |
50
+ | [`LlmSupport`](llm-support.md) | provider resolution and LLM-response plumbing |
51
+
52
+ ## Conventions
53
+
54
+ **Immutability.** `Prior` and `Posterior` never mutate. `Prior#update_from_observations`
55
+ and `#combine` return new instances.
56
+
57
+ **Keyword arguments** for anything optional or non-obvious — `outcome:`,
58
+ `bandwidth:`, `smoothing:`, `weight:`, `rng:`.
59
+
60
+ **`ArgumentError` for contract violations** — bad dimensions, unknown outcomes,
61
+ probabilities that don't sum to 1. `SQA::BI::Error` for LLM and provider
62
+ failures.
63
+
64
+ **Hash keys are your outcome objects**, not strings. Integers, symbols, and
65
+ strings all work; `rekey_to_outcomes` maps JSON's string keys back onto
66
+ whatever you used.
@@ -0,0 +1,78 @@
1
+ # SQA::BI::Likelihood
2
+
3
+ `P(features | outcome)` by Gaussian kernel density estimation over stored
4
+ observations.
5
+
6
+ Unlike `Prior` and `Posterior`, this class **is** mutable — `add_observation`
7
+ appends in place.
8
+
9
+ ## Constructor
10
+
11
+ ### `.new(observations = [], bandwidth: 1.0)`
12
+
13
+ | Parameter | Type | Description |
14
+ |---|---|---|
15
+ | `observations` | `Array<Hash>` | each `{ features: [...], outcome: ... }` |
16
+ | `bandwidth:` | `Float` | kernel width `h`; default `1.0` |
17
+
18
+ ```ruby
19
+ SQA::BI::Likelihood.new(
20
+ [{ features: [1.0, 2.0], outcome: 1 }],
21
+ bandwidth: 0.5
22
+ )
23
+ ```
24
+
25
+ ## Attributes
26
+
27
+ | Reader | Type | Description |
28
+ |---|---|---|
29
+ | `observations` | `Array<Hash>` | the stored observations |
30
+ | `bandwidth` | `Float` | current kernel width |
31
+
32
+ ## Instance methods
33
+
34
+ ### `#add_observation(features, outcome)` → `void`
35
+
36
+ Appends an observation and regroups by outcome.
37
+
38
+ ```ruby
39
+ likelihood.add_observation([1.1, 2.1], 1)
40
+ ```
41
+
42
+ ### `#compute(features, outcomes)` → `Hash`
43
+
44
+ Likelihood for every outcome, normalized to sum to 1.
45
+
46
+ ```ruby
47
+ likelihood.compute([1.0, 2.0], [-1, 0, 1])
48
+ # => {-1 => 0.05, 0 => 0.15, 1 => 0.80}
49
+ ```
50
+
51
+ With no observations at all, returns a uniform distribution — the posterior then
52
+ equals the prior, which is correct.
53
+
54
+ ### `#estimate_density(features, outcome)` → `Float`
55
+
56
+ Unnormalized kernel density for a single outcome.
57
+
58
+ $$\frac{1}{n}\sum_{i=1}^{n} K\!\left(\frac{\lVert x - x_i\rVert}{h}\right), \quad K(u) = \frac{1}{\sqrt{2\pi}}e^{-u^2/2}$$
59
+
60
+ Returns `1e-10` — never `0.0` — for an outcome with no observations.
61
+
62
+ **Raises `ArgumentError`** when a stored vector's dimension differs from the
63
+ query's.
64
+
65
+ ### `#size` → `Integer`
66
+
67
+ Number of stored observations.
68
+
69
+ ### `#empty?` → `Boolean`
70
+
71
+ ### `#outcome_counts` → `Hash`
72
+
73
+ `{outcome => Integer}`. Feeds
74
+ [the predictor's prior update](../guide/prior.md#automatic-updating-inside-the-predictor).
75
+
76
+ ```ruby
77
+ likelihood.outcome_counts # => {1 => 3, -2 => 1}
78
+ ```