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,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.
|
data/docs/EXPLORATION.md
ADDED
|
@@ -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
|
+

|
|
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
|
+
```
|