Bayesian inference over discrete outcomes from time series data.
SQA::BI answers one question: given this feature vector, what is the
probability distribution over outcomes? — where "outcomes" is a small
ordered set such as [-2, -1, 0, 1, 2], read as strong downtrend through
strong uptrend.
It gives you a full posterior rather than a point estimate, so entropy, confidence, and information gain come out of the math instead of being bolted on afterward.
📖 Full documentation — guide, API reference, LLM integration, and runnable examples.
sqa-tai ─┐
sqa-bi ─┴→ sqa → sqa-cli / sqa-advisor / sqa-rails / sqa-sinatra
sqa-bi is a leaf gem with no runtime dependencies. The inference math
is domain-agnostic — it knows nothing about markets — so sqa can depend on
it the same way it depends on sqa-tai. The market-facing demo
(examples/03_stock_market_prediction_v2.rb) reaches the other direction
and needs sqa, which is why Gemfile.local resolves sqa from the
sibling checkout for development only.
require "sqa/bi"
predictor = SQA::BI.predictor(outcomes: [-2, -1, 0, 1, 2], bandwidth: 1.0)
predictor.train([1.0, 2.0, 3.0], outcome: 1)
predictor.train([1.1, 2.1, 2.9], outcome: 1)
predictor.train([-1.0, -2.0, 0.5], outcome: -2)
posterior = predictor.predict([1.05, 2.05, 3.0])
posterior.max_outcome # => 1 (MAP estimate)
posterior.probability(1)# => 0.74
posterior.confidence # => 0.41 (1 - entropy / max entropy)
posterior.entropy # => 1.37 bits
puts posterior.summary| Class | Role |
|---|---|
Prior |
P(outcome) — uniform or custom, Laplace-smoothed updates from observed frequencies, weighted combination, entropy |
Likelihood |
P(features | outcome) — Gaussian kernel density estimation over historical observations |
Posterior |
P(outcome | features) — Bayes' theorem, plus entropy, confidence, KL divergence from the prior, MAP, sampling |
TimeSeriesPredictor |
the train / predict interface tying the three together |
LlmPriorElicitor |
an LLM supplies the prior from a natural-language description |
LlmLikelihoodEstimator |
an LLM acts as a likelihood function over textual evidence |
LlmSupport |
provider resolution, JSON extraction, re-keying, normalization, clamping |
Bayes' theorem: P(outcome | data) = P(data | outcome) × P(outcome) / P(data)
Kernel density estimate for the likelihood:
P(x | outcome) = (1/n) × Σ K((x - xᵢ) / h), with K Gaussian and h the
bandwidth. Smaller bandwidth tracks local patterns; larger smooths.
The design rule, stated once and applied everywhere:
The LLM judges. Ruby computes. Ask the LLM only for isolated, independent judgments (a weight, a conditional probability). Never ask it to accumulate, normalize, or update beliefs — that is Bayes' job, done in Ruby.
LLMs and Bayes' theorem have exactly complementary failure modes: an LLM is
good at "how surprising is this log line if the database were down?" and bad
at combining five such judgments without anchoring or double-counting. The
posterior is good at exactly the second thing and has no opinion about the
first. See docs/EXPLORATION.md for the verified
results behind both patterns.
Prior elicitation answers the standing objection to Bayesian methods — where does the prior come from? — by treating the LLM as a queryable compression of domain knowledge:
elicitor = SQA::BI::LlmPriorElicitor.new(
outcomes: [-2, -1, 0, 1, 2],
outcome_descriptions: {
-2 => "strong downtrend", -1 => "mild downtrend", 0 => "sideways",
1 => "mild uptrend", 2 => "strong uptrend"
}
)
prior = elicitor.elicit("The Fed unexpectedly cut rates by 50bp...")Likelihood estimation handles evidence that is text rather than numbers:
estimator = SQA::BI::LlmLikelihoodEstimator.new(
hypotheses: {
bad_deploy: "The 14:02 deploy introduced a bug",
database: "The primary database is degraded",
network: "There is a network partition between AZs"
}
)
estimator.likelihoods("Error rate spiked 2 minutes after deploy")
# => { bad_deploy: 0.9, database: 0.2, network: 0.15 }Likelihoods are clamped to [0.001, 0.999] so an overconfident model can
never zero out a hypothesis in a single step (Cromwell's rule) — which is
what keeps later contradicting evidence able to reverse the belief.
ruby_llm is required lazily inside LlmSupport.build_chat, so the core
math loads and runs without it. Both LLM classes accept an injectable
chat: object; the test suite makes zero network calls.
Auto-detected in order: LM Studio via ruby_llm-providers-lms
(http://localhost:1234/v1), then Apfel / Apple Foundation Models via
ruby_llm-providers-apfel (http://127.0.0.1:11434/v1), then cloud.
Detection probes each server, so a local provider is only used when its server is actually running with a chat model available. For LM Studio that means two things, neither of which the desktop app does by itself:
lms status # Server: ON / OFF
lms server start # start the HTTP server on :1234
lms ps # which models are loaded
lms load qwen/qwen3.8-27b # load one (choose_local_model prefers qwen)With the server off, or on but offering only embedding models, resolution falls through to cloud — silently, by design. That is how an expected local run turns into an unexpected cloud bill or a 401 from a stale key. The LLM demos therefore print where they are actually sending each question before the first call:
LLM: lms — qwen/qwen3.8-27b at http://localhost:1234/v1
SQA::BI::LlmSupport.current_resolution returns that line if you want the
same check in your own code.
| Variable | Effect |
|---|---|
SQA_BI_LLM_PROVIDER |
force lms, apfel, or cloud |
SQA_BI_LLM_MODEL |
force a model id |
LMS_API_BASE, APFEL_API_BASE |
override the local server URLs |
The unprefixed BI_LLM_PROVIDER / BI_LLM_MODEL names this library used
before it moved into the SQA workspace are still honored as a fallback.
For local servers, choose_local_model prefers qwen models — they honor
JSON prompts most reliably in LM Studio — then gpt-oss, skipping embedding
and OCR models. Model quality shows up directly as prior sharpness: qwen3.8-27b
via LM Studio produced a bullish prior peaked at +1, while Apple's 3B
on-device model was more cautious and peaked at 0.
ruby examples/01_coin_flip.rb # the mechanics, minimal
ruby examples/02_time_series_prediction.rb # synthetic trends
ruby examples/03_stock_market_prediction_v2.rb # real OHLCV via sqa
ruby examples/04_llm_elicited_prior.rb # LLM as prior elicitor
ruby examples/05_llm_likelihood_diagnosis.rb # LLM as likelihood functionExample 03 needs the sqa gem, so run it in dev bundle mode
(asgard dev from the workspace root, which points BUNDLE_GEMFILE at
Gemfile.local). examples/pure_ruby_indicators.rb carries dependency-free
SMA/EMA/RSI so the other examples stay standalone — production code should
use sqa-tai instead.
The detailed docs are an MkDocs site under docs/:
pip install -r docs/requirements.txt
mkdocs serve # live reload at http://127.0.0.1:8000
mkdocs build --strict--strict turns the link and anchor validation configured in mkdocs.yml into
build errors, so a broken cross-reference fails the build rather than shipping.
.github/workflows/docs.yml publishes to GitHub Pages on push to main.
Diagrams are hand-authored SVG in docs/assets/diagrams/ — dark theme,
transparent background, colour used to distinguish function.
asgard test # or: bundle exec rake test
asgard quality # tests + coverage, RuboCop, Flog, Flay, Reek
asgard rubocopReek runs baseline-aware against .quality/reek_baseline.txt; ratchet the
floor down with asgard reek_baseline after a genuine improvement. The
RuboCop config is generated from the workspace's .rubocop.yml.common —
edit that file and run asgard sync_rubocop, not this repo's copy.
Ported from the bayesian_inference prototype in
~/sandbox/git_repos/madbomber/experiments/ai_misc/. See
decision_support_techniques.md for the
investigation log that led here, including why a tree-ensemble forecaster
and the Laya typed-decision model were each evaluated and set aside.
MIT. See LICENSE.txt.