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/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # SQA::BI
2
+
3
+ Bayesian inference over discrete outcomes from time series data.
4
+
5
+ `SQA::BI` answers one question: **given this feature vector, what is the
6
+ probability distribution over outcomes?** — where "outcomes" is a small
7
+ ordered set such as `[-2, -1, 0, 1, 2]`, read as strong downtrend through
8
+ strong uptrend.
9
+
10
+ It gives you a full posterior rather than a point estimate, so entropy,
11
+ confidence, and information gain come out of the math instead of being
12
+ bolted on afterward.
13
+
14
+ 📖 **[Full documentation](https://madbomber.github.io/sqa-bi)** — guide, API
15
+ reference, LLM integration, and runnable examples.
16
+
17
+ **⚠️ WARNING:** This is a learning tool, not production software. DO NOT use
18
+ this library when real money is at stake. The probability distributions it
19
+ produces should not be taken seriously. If you lose your shirt playing in
20
+ the stock market, don't come crying to me. Playing in the market is like
21
+ playing in the street — you're going to get run over.
22
+
23
+ ## Where it sits in the workspace
24
+
25
+ ```
26
+ sqa-tai ─┐
27
+ sqa-bi ─┴→ sqa → sqa-cli / sqa-advisor / sqa-rails / sqa-sinatra
28
+ ```
29
+
30
+ `sqa-bi` is a **leaf** gem with no runtime dependencies. The inference math
31
+ is domain-agnostic — it knows nothing about markets — so `sqa` can depend on
32
+ it the same way it depends on `sqa-tai`. The market-facing demo
33
+ (`examples/03_stock_market_prediction_v2.rb`) reaches the other direction
34
+ and needs `sqa`, which is why `Gemfile.local` resolves `sqa` from the
35
+ sibling checkout for development only.
36
+
37
+ ## Quick start
38
+
39
+ ```ruby
40
+ require "sqa/bi"
41
+
42
+ predictor = SQA::BI.predictor(outcomes: [-2, -1, 0, 1, 2], bandwidth: 1.0)
43
+
44
+ predictor.train([1.0, 2.0, 3.0], outcome: 1)
45
+ predictor.train([1.1, 2.1, 2.9], outcome: 1)
46
+ predictor.train([-1.0, -2.0, 0.5], outcome: -2)
47
+
48
+ posterior = predictor.predict([1.05, 2.05, 3.0])
49
+
50
+ posterior.max_outcome # => 1 (MAP estimate)
51
+ posterior.probability(1)# => 0.74
52
+ posterior.confidence # => 0.41 (1 - entropy / max entropy)
53
+ posterior.entropy # => 1.37 bits
54
+ puts posterior.summary
55
+ ```
56
+
57
+ ## Components
58
+
59
+ | Class | Role |
60
+ |---|---|
61
+ | `Prior` | `P(outcome)` — uniform or custom, Laplace-smoothed updates from observed frequencies, weighted combination, entropy |
62
+ | `Likelihood` | `P(features \| outcome)` — Gaussian kernel density estimation over historical observations |
63
+ | `Posterior` | `P(outcome \| features)` — Bayes' theorem, plus entropy, confidence, KL divergence from the prior, MAP, sampling |
64
+ | `TimeSeriesPredictor` | the `train` / `predict` interface tying the three together |
65
+ | `LlmPriorElicitor` | an LLM supplies the prior from a natural-language description |
66
+ | `LlmLikelihoodEstimator` | an LLM acts as a likelihood function over textual evidence |
67
+ | `LlmSupport` | provider resolution, JSON extraction, re-keying, normalization, clamping |
68
+
69
+ **Bayes' theorem:** `P(outcome | data) = P(data | outcome) × P(outcome) / P(data)`
70
+
71
+ **Kernel density estimate for the likelihood:**
72
+ `P(x | outcome) = (1/n) × Σ K((x - xᵢ) / h)`, with `K` Gaussian and `h` the
73
+ bandwidth. Smaller bandwidth tracks local patterns; larger smooths.
74
+
75
+ ## LLM integration
76
+
77
+ The design rule, stated once and applied everywhere:
78
+
79
+ > **The LLM judges. Ruby computes.**
80
+ > Ask the LLM only for isolated, independent judgments (a weight, a
81
+ > conditional probability). Never ask it to accumulate, normalize, or update
82
+ > beliefs — that is Bayes' job, done in Ruby.
83
+
84
+ LLMs and Bayes' theorem have exactly complementary failure modes: an LLM is
85
+ good at "how surprising is this log line if the database were down?" and bad
86
+ at combining five such judgments without anchoring or double-counting. The
87
+ posterior is good at exactly the second thing and has no opinion about the
88
+ first. See [`docs/EXPLORATION.md`](docs/EXPLORATION.md) for the verified
89
+ results behind both patterns.
90
+
91
+ **Prior elicitation** answers the standing objection to Bayesian methods —
92
+ where does the prior come from? — by treating the LLM as a queryable
93
+ compression of domain knowledge:
94
+
95
+ ```ruby
96
+ elicitor = SQA::BI::LlmPriorElicitor.new(
97
+ outcomes: [-2, -1, 0, 1, 2],
98
+ outcome_descriptions: {
99
+ -2 => "strong downtrend", -1 => "mild downtrend", 0 => "sideways",
100
+ 1 => "mild uptrend", 2 => "strong uptrend"
101
+ }
102
+ )
103
+ prior = elicitor.elicit("The Fed unexpectedly cut rates by 50bp...")
104
+ ```
105
+
106
+ **Likelihood estimation** handles evidence that is text rather than numbers:
107
+
108
+ ```ruby
109
+ estimator = SQA::BI::LlmLikelihoodEstimator.new(
110
+ hypotheses: {
111
+ bad_deploy: "The 14:02 deploy introduced a bug",
112
+ database: "The primary database is degraded",
113
+ network: "There is a network partition between AZs"
114
+ }
115
+ )
116
+ estimator.likelihoods("Error rate spiked 2 minutes after deploy")
117
+ # => { bad_deploy: 0.9, database: 0.2, network: 0.15 }
118
+ ```
119
+
120
+ Likelihoods are clamped to `[0.001, 0.999]` so an overconfident model can
121
+ never zero out a hypothesis in a single step (Cromwell's rule) — which is
122
+ what keeps later contradicting evidence able to reverse the belief.
123
+
124
+ `ruby_llm` is required lazily inside `LlmSupport.build_chat`, so the core
125
+ math loads and runs without it. Both LLM classes accept an injectable
126
+ `chat:` object; the test suite makes zero network calls.
127
+
128
+ ### Provider selection — local first
129
+
130
+ Auto-detected in order: LM Studio via `ruby_llm-providers-lms`
131
+ (`http://localhost:1234/v1`), then Apfel / Apple Foundation Models via
132
+ `ruby_llm-providers-apfel` (`http://127.0.0.1:11434/v1`), then cloud.
133
+
134
+ Detection probes each server, so **a local provider is only used when its
135
+ server is actually running with a chat model available**. For LM Studio
136
+ that means two things, neither of which the desktop app does by itself:
137
+
138
+ ```bash
139
+ lms status # Server: ON / OFF
140
+ lms server start # start the HTTP server on :1234
141
+ lms ps # which models are loaded
142
+ lms load qwen/qwen3.8-27b # load one (choose_local_model prefers qwen)
143
+ ```
144
+
145
+ With the server off, or on but offering only embedding models, resolution
146
+ falls through to cloud — silently, by design. That is how an expected
147
+ local run turns into an unexpected cloud bill or a 401 from a stale key.
148
+ The LLM demos therefore print where they are actually sending each
149
+ question before the first call:
150
+
151
+ ```
152
+ LLM: lms — qwen/qwen3.8-27b at http://localhost:1234/v1
153
+ ```
154
+
155
+ `SQA::BI::LlmSupport.current_resolution` returns that line if you want the
156
+ same check in your own code.
157
+
158
+ | Variable | Effect |
159
+ |---|---|
160
+ | `SQA_BI_LLM_PROVIDER` | force `lms`, `apfel`, or `cloud` |
161
+ | `SQA_BI_LLM_MODEL` | force a model id |
162
+ | `LMS_API_BASE`, `APFEL_API_BASE` | override the local server URLs |
163
+
164
+ The unprefixed `BI_LLM_PROVIDER` / `BI_LLM_MODEL` names this library used
165
+ before it moved into the SQA workspace are still honored as a fallback.
166
+
167
+ For local servers, `choose_local_model` prefers qwen models — they honor
168
+ JSON prompts most reliably in LM Studio — then gpt-oss, skipping embedding
169
+ and OCR models. Model quality shows up directly as prior sharpness: qwen3.8-27b
170
+ via LM Studio produced a bullish prior peaked at +1, while Apple's 3B
171
+ on-device model was more cautious and peaked at 0.
172
+
173
+ ## Examples
174
+
175
+ ```bash
176
+ ruby examples/01_coin_flip.rb # the mechanics, minimal
177
+ ruby examples/02_time_series_prediction.rb # synthetic trends
178
+ ruby examples/03_stock_market_prediction_v2.rb # real OHLCV via sqa
179
+ ruby examples/04_llm_elicited_prior.rb # LLM as prior elicitor
180
+ ruby examples/05_llm_likelihood_diagnosis.rb # LLM as likelihood function
181
+ ```
182
+
183
+ Example 03 needs the `sqa` gem, so run it in dev bundle mode
184
+ (`asgard dev` from the workspace root, which points `BUNDLE_GEMFILE` at
185
+ `Gemfile.local`). `examples/pure_ruby_indicators.rb` carries dependency-free
186
+ SMA/EMA/RSI so the other examples stay standalone — production code should
187
+ use `sqa-tai` instead.
188
+
189
+ ## Documentation
190
+
191
+ The detailed docs are an MkDocs site under `docs/`:
192
+
193
+ ```bash
194
+ pip install -r docs/requirements.txt
195
+ mkdocs serve # live reload at http://127.0.0.1:8000
196
+ mkdocs build --strict
197
+ ```
198
+
199
+ `--strict` turns the link and anchor validation configured in `mkdocs.yml` into
200
+ build errors, so a broken cross-reference fails the build rather than shipping.
201
+ `.github/workflows/docs.yml` publishes to GitHub Pages on push to `main`.
202
+
203
+ Diagrams are hand-authored SVG in `docs/assets/diagrams/` — dark theme,
204
+ transparent background, colour used to distinguish function.
205
+
206
+ ## Development
207
+
208
+ ```bash
209
+ asgard test # or: bundle exec rake test
210
+ asgard quality # tests + coverage, RuboCop, Flog, Flay, Reek
211
+ asgard rubocop
212
+ ```
213
+
214
+ Reek runs baseline-aware against `.quality/reek_baseline.txt`; ratchet the
215
+ floor down with `asgard reek_baseline` after a genuine improvement. The
216
+ RuboCop config is generated from the workspace's `.rubocop.yml.common` —
217
+ edit that file and run `asgard sync_rubocop`, not this repo's copy.
218
+
219
+ ## History
220
+
221
+ Ported from the `bayesian_inference` prototype in
222
+ `~/sandbox/git_repos/madbomber/experiments/ai_misc/`. See
223
+ [`decision_support_techniques.md`](decision_support_techniques.md) for the
224
+ investigation log that led here, including why a tree-ensemble forecaster
225
+ and the Laya typed-decision model were each evaluated and set aside.
226
+
227
+ ## License
228
+
229
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ t.warning = false
11
+ end
12
+
13
+ task default: :test
14
+
15
+ desc "Check code style with RuboCop"
16
+ task :rubocop do
17
+ sh "bundle exec rubocop --format simple"
18
+ end
19
+
20
+ desc "Auto-correct RuboCop offenses"
21
+ task :rubocop_fix do
22
+ sh "bundle exec rubocop -A"
23
+ end
24
+
25
+ desc "Check code complexity with Flog (warn >=20, fail >=50)"
26
+ task :flog_check do
27
+ require "flog"
28
+
29
+ warn_threshold = 20.0
30
+ fail_threshold = 50.0
31
+
32
+ flogger = Flog.new(all: true)
33
+ flogger.flog(*Dir.glob("lib/**/*.rb"))
34
+
35
+ warnings = []
36
+ failures = []
37
+
38
+ flogger.each_by_score do |method, score|
39
+ next if method.end_with?("#none")
40
+
41
+ if score > fail_threshold
42
+ failures << "#{format("%.1f", score)}: #{method}"
43
+ elsif score > warn_threshold
44
+ warnings << "#{format("%.1f", score)}: #{method}"
45
+ end
46
+ end
47
+
48
+ unless warnings.empty?
49
+ puts "\nFlog warnings (#{warn_threshold}–#{fail_threshold}) — target for future refactoring:"
50
+ warnings.each { |v| puts " #{v}" }
51
+ end
52
+
53
+ if failures.empty?
54
+ puts "\nFlog: no methods exceed the failure threshold (>=#{fail_threshold})"
55
+ else
56
+ puts "\nFlog failures (>=#{fail_threshold}) — must be refactored:"
57
+ failures.each { |v| puts " #{v}" }
58
+ abort "\nFlog quality gate failed: #{failures.size} method(s) exceed #{fail_threshold}"
59
+ end
60
+ end
61
+
62
+ desc "Check for structural code duplication with Flay (mass >= 50)"
63
+ task :flay_check do
64
+ require "flay"
65
+
66
+ mass_threshold = 50
67
+
68
+ flay = Flay.new(mass: mass_threshold, diff: false, verbose: false, summary: false, timeout: 60)
69
+ flay.process(*Dir.glob("lib/**/*.rb"))
70
+ flay.analyze
71
+
72
+ if flay.hashes.empty?
73
+ puts "\nFlay: no structural duplication detected (mass >= #{mass_threshold})"
74
+ else
75
+ puts "\nFlay found structural duplication (mass >= #{mass_threshold}):"
76
+ flay.report
77
+ abort "\nFlay quality gate failed: #{flay.hashes.length} pattern(s) detected"
78
+ end
79
+ end
80
+
81
+ desc "Check code smells with Reek (fails only on new/worsened files vs .quality/reek_baseline.txt)"
82
+ task :reek_check do
83
+ require "reek"
84
+ require "reek/configuration/app_configuration"
85
+
86
+ # Resolve .reek.yml by walking up from cwd: a gem-level config wins,
87
+ # otherwise the shared workspace-level one is used.
88
+ config_file = nil
89
+ dir = Pathname.new(Dir.pwd).expand_path
90
+ loop do
91
+ candidate = dir.join(".reek.yml")
92
+ if candidate.exist?
93
+ config_file = candidate.to_s
94
+ break
95
+ end
96
+ break if dir.root?
97
+
98
+ dir = dir.parent
99
+ end
100
+ config = config_file ? Reek::Configuration::AppConfiguration.from_path(Pathname.new(config_file)) : nil
101
+
102
+ # Smell count per file (only files with at least one smell).
103
+ current = Dir.glob("lib/**/*.rb").each_with_object({}) do |file, acc|
104
+ count = Reek::Examiner.new(File.read(file), configuration: config).smells.size
105
+ acc[file] = count if count.positive?
106
+ end
107
+
108
+ # Grandfathered smell counts from .quality/reek_baseline.txt ("count\tfile").
109
+ baseline_path = File.join(".quality", "reek_baseline.txt")
110
+ baseline = {}
111
+ if File.exist?(baseline_path)
112
+ File.readlines(baseline_path).each do |line|
113
+ count, file = line.strip.split("\t", 2)
114
+ baseline[file] = count.to_i if file && !file.empty?
115
+ end
116
+ end
117
+
118
+ new_files = current.reject { |file, _| baseline.key?(file) }
119
+ worsened = current.select { |file, count| baseline.key?(file) && count > baseline[file] }
120
+
121
+ puts "\nReek: #{current.values.sum} warning(s) across #{current.size} file(s) " \
122
+ "(#{baseline.values.sum} grandfathered across #{baseline.size} file(s))."
123
+
124
+ if new_files.empty? && worsened.empty?
125
+ puts "Reek: no new or worsened files (quality gate passed)"
126
+ else
127
+ puts "\nReek quality gate failed:"
128
+ new_files.each { |file, count| puts " NEW #{count.to_s.rjust(3)} warning(s): #{file}" }
129
+ worsened.each { |file, count| puts " WORSENED #{baseline[file].to_s.rjust(3)} -> #{count.to_s.rjust(3)}: #{file}" }
130
+ abort "\nReek quality gate failed: #{new_files.size + worsened.size} file(s) regressed. " \
131
+ "Fix smells, or run `asgard reek_baseline` if intentional."
132
+ end
133
+ end
134
+
135
+ desc "Run all quality checks: tests (with coverage), Flog, Flay, and Reek"
136
+ task :quality do
137
+ results = {}
138
+
139
+ puts "\n#{"=" * 60}"
140
+ puts "Quality Gate: Tests + Coverage"
141
+ puts "=" * 60
142
+ results[:tests] = system("bundle exec rake test") ? :pass : :fail
143
+
144
+ puts "\n#{"=" * 60}"
145
+ puts "Quality Gate: Flog Complexity"
146
+ puts "=" * 60
147
+ results[:flog] = system("bundle exec rake flog_check") ? :pass : :fail
148
+
149
+ puts "\n#{"=" * 60}"
150
+ puts "Quality Gate: Flay Duplication"
151
+ puts "=" * 60
152
+ results[:flay] = system("bundle exec rake flay_check") ? :pass : :fail
153
+
154
+ puts "\n#{"=" * 60}"
155
+ puts "Quality Gate: Reek Smells"
156
+ puts "=" * 60
157
+ results[:reek] = system("bundle exec rake reek_check") ? :pass : :fail
158
+
159
+ puts "\n#{"=" * 60}"
160
+ puts "Quality Summary"
161
+ puts "=" * 60
162
+ results.each do |gate, status|
163
+ icon = status == :pass ? "PASS" : "FAIL"
164
+ puts " [#{icon}] #{gate}"
165
+ end
166
+ puts "=" * 60
167
+
168
+ abort "\nQuality gate failed" if results.values.any?(:fail)
169
+ puts "\nAll quality gates passed."
170
+ end