robot_lab-durable 0.2.1 → 0.2.6

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.
data/docs/index.md CHANGED
@@ -1,22 +1,25 @@
1
- # robot_lab-durable
1
+ # robot_lab-durable — Reference Documentation
2
2
 
3
- Cross-session durable learning for the [RobotLab](https://github.com/MadBomber/robot_lab) LLM agent framework.
3
+ Cross-session durable memory for the [RobotLab](https://github.com/MadBomber/robot_lab) LLM agent framework, backed by the [htm gem](https://madbomber.github.io/htm) (PostgreSQL + pgvector).
4
4
 
5
5
  > [!CAUTION]
6
6
  > This gem is under active development. APIs may change without notice.
7
7
 
8
8
  ## What it provides
9
9
 
10
- - **`Durable::Entry`** — immutable, confidence-tracked knowledge record
11
- - **`Durable::Store`** — YAML-backed, file-locked per-domain knowledge persistence in `~/.robot_lab/durable/`
12
- - **`Durable::Reflector`** — promotes session-level learnings into the durable store at end-of-run
13
- - **`Durable::Learning`** — mixin included into `RobotLab::Robot`; enabled via `learn: true, learn_domain:` constructor params
14
- - **`RecallKnowledge`** tool — lets robots query the durable store before making decisions
15
- - **`RecordKnowledge`** tool — lets robots write new knowledge during a session
10
+ - **`Durable::Adapter`** — thin wrapper around an `HTM` instance; scoped to a robot by name. Exposes `record` and `recall` methods plus direct `htm` access.
11
+ - **`Durable::Entry`** — presenter over raw HTM node hashes returned by recall. Provides typed fields.
12
+ - **`Durable::Hook`** — RobotLab hook handler that wires the `Adapter` lifecycle into `around_run` and `on_learn` automatically.
13
+ - **`RecallKnowledge`** tool lets a robot query its durable memory before answering.
14
+ - **`RecordKnowledge`** tool — lets a robot persist new knowledge during a session.
16
15
 
17
- ## Installation
16
+ ## Prerequisites
17
+
18
+ - PostgreSQL with the pgvector extension enabled.
19
+ - The `htm` gem — see documentation at https://madbomber.github.io/htm.
20
+ - The `robot_lab` gem.
18
21
 
19
- Add to your Gemfile:
22
+ ## Installation
20
23
 
21
24
  ```ruby
22
25
  gem "robot_lab"
@@ -30,33 +33,235 @@ require "robot_lab"
30
33
  require "robot_lab/durable"
31
34
 
32
35
  robot = RobotLab.build(
33
- name: "advisor",
34
- system_prompt: "You are a financial advisor that learns from each session.",
35
- learn: true,
36
- learn_domain: "finance"
36
+ name: "advisor",
37
+ system_prompt: "You are a financial advisor.",
38
+ local_tools: [RobotLab::RecallKnowledge, RobotLab::RecordKnowledge]
37
39
  )
40
+ robot.on(RobotLab::Durable::Hook)
38
41
 
39
- # RecallKnowledge and RecordKnowledge tools are automatically available.
40
- # At the end of each run, the Reflector promotes learned facts to
41
- # ~/.robot_lab/durable/finance.yml for use in future sessions.
42
42
  result = robot.run("What do you know about my risk tolerance?")
43
43
  puts result.last_text_content
44
44
  ```
45
45
 
46
- ## Knowledge Persistence
46
+ No `domain:` argument is required. HTM scopes all storage to the robot's `name` automatically.
47
+
48
+ ---
49
+
50
+ ## Adapter API
51
+
52
+ `Durable::Adapter` is initialised by `Hook#around_run` for each run and stored as a thread-local. It wraps `HTM.new(robot_name: name)`.
53
+
54
+ | Method | Arguments | Returns | Notes |
55
+ |--------|-----------|---------|-------|
56
+ | `record` | `content:` (String, required), `reasoning:` (String, optional), `category:` (String/Symbol, default `'fact'`) | Integer (HTM node_id) | Calls `htm.remember(content, metadata: { reasoning:, category: })`. HTM deduplicates by SHA-256 content hash — safe to call with the same content multiple times. |
57
+ | `recall` | `query:` (String, required), `limit:` (Integer, default 20), `strategy:` (Symbol, default `:hybrid`) | `Array<Entry>` | Calls `htm.recall(query, limit:, strategy:, raw: true)` then wraps each result in `Entry.from_node`. |
58
+ | `htm` | — | `HTM` instance | Direct access to the underlying HTM object for advanced operations. |
59
+
60
+ ### Accessing the current adapter
61
+
62
+ ```ruby
63
+ adapter = RobotLab::Durable::Hook.current_adapter
64
+ ```
65
+
66
+ Returns the `Adapter` for the current thread, or `nil` when called outside an active `around_run`. Useful for direct access from custom tools or middleware.
67
+
68
+ ### Direct HTM access
69
+
70
+ ```ruby
71
+ adapter = RobotLab::Durable::Hook.current_adapter
72
+ raw_nodes = adapter.htm.recall("risk tolerance", strategy: :vector, raw: true)
73
+ ```
74
+
75
+ Use `adapter.htm` when you need HTM operations not exposed through `Adapter`, such as bulk deletion or direct node inspection.
76
+
77
+ ---
78
+
79
+ ## Entry Fields
80
+
81
+ `Durable::Entry` is constructed via `Entry.from_node(node)` from a raw HTM node hash. HTM nodes use string keys (not symbols), so `from_node` reads `node['id']`, not `node[:id]`.
82
+
83
+ | Field | Type | Description | Source in HTM node |
84
+ |-------|------|-------------|--------------------|
85
+ | `node_id` | Integer | Unique identifier for the stored node | `node['id']` |
86
+ | `content` | String | The stored knowledge text | `node['content']` |
87
+ | `reasoning` | String / nil | Optional rationale recorded at write time | `node['metadata']['reasoning']` |
88
+ | `category` | Symbol | Category of knowledge (e.g. `:fact`, `:observation`) | `(node['metadata']['category'] || 'fact').to_sym` |
89
+ | `created_at` | String | Raw timestamp as returned by HTM (e.g. `'2026-05-06T12:00:00Z'`) — not parsed into a `Time` object | `node['created_at']` |
90
+
91
+ ---
92
+
93
+ ## Hook Lifecycle
94
+
95
+ The `Durable::Hook` handler implements two lifecycle callbacks.
96
+
97
+ ### around_run
98
+
99
+ 1. Reads `ctx.robot.name` from the run context.
100
+ 2. Instantiates `Durable::Adapter.new(robot_name: name)`, which in turn creates `HTM.new(robot_name: name)`.
101
+ 3. Stores the adapter as a thread-local so tools can reach it via `Hook.current_adapter`.
102
+ 4. Yields to execute the actual robot run.
103
+ 5. In `ensure`, clears the thread-local adapter so state never leaks to subsequent runs on the same thread.
104
+
105
+ ### on_learn
106
+
107
+ Fires after each call to `robot.learn(text)`, but only persists when both of these hold:
108
+
109
+ 1. `ctx.stored` is true — `robot.learn` sets this only when the text was actually added to the robot's in-memory `@learnings` list. If `text` is a substring of an existing learning (or vice versa, see `robot.learn`'s dedup rule in `robot_lab` core), `ctx.stored` stays `false` and `on_learn` returns early without recording.
110
+ 2. A durable session is active (`Hook.current_adapter` is non-nil).
111
+
112
+ When both hold, calls:
113
+
114
+ ```ruby
115
+ adapter.record(content: ctx.text, category: :observation)
116
+ ```
117
+
118
+ This persists the learning immediately. HTM's content-hash deduplication ensures that re-seeding the same fact across sessions does not create duplicate entries.
119
+
120
+ ### Enabling the hook
121
+
122
+ ```ruby
123
+ robot.on(RobotLab::Durable::Hook)
124
+ ```
125
+
126
+ No additional context keys are required. HTM scopes storage to the robot name automatically.
127
+
128
+ ---
129
+
130
+ ## Tools
131
+
132
+ ### RecallKnowledge
133
+
134
+ Allows the LLM to query the robot's durable memory store.
135
+
136
+ | Parameter | Type | Required | Description |
137
+ |-----------|------|----------|-------------|
138
+ | `query` | String | yes | Natural-language description of the decision you are about to make |
139
+
140
+ `limit` and `strategy` are not exposed as tool parameters — the tool always calls `adapter.recall` with its defaults (`limit: 20`, `strategy: :hybrid`). Use `Hook.current_adapter` directly (see [Adapter API](#adapter-api)) if you need to control those.
141
+
142
+ Returns formatted text entries (`"[category] content — reasoning"`, one per line, prefixed with `"Relevant past knowledge:"`) when matches are found. Returns `"No relevant past knowledge found for: <query>. When in doubt, skip."` when the recall is empty, or `"No durable session active on this robot."` when no `Hook.current_adapter` is active (e.g. the hook wasn't registered with `robot.on`).
143
+
144
+ Implementation calls:
145
+
146
+ ```ruby
147
+ Hook.current_adapter.recall(query: query)
148
+ ```
149
+
150
+ ### RecordKnowledge
151
+
152
+ Allows the LLM to persist new knowledge during a session.
153
+
154
+ | Parameter | Type | Required | Description |
155
+ |-----------|------|----------|-------------|
156
+ | `content` | String | yes | The knowledge text to store |
157
+ | `reasoning` | String | yes | Rationale or context for why this is being stored |
158
+ | `category` | String | yes | One of: fact, preference, pattern, correction |
159
+
160
+ All three are required — neither `param` (which defaults to `required: true`) nor the tool's `execute(content:, reasoning:, category:)` signature declares a default. (The `'fact'` default described in the [Adapter API](#adapter-api) applies only when calling `Adapter#record` directly, not through this tool.)
161
+
162
+ Returns `"Recorded: <content>"` on success, or `"No durable session active on this robot."` when no `Hook.current_adapter` is active.
163
+
164
+ Implementation calls:
165
+
166
+ ```ruby
167
+ adapter.record(content: content, reasoning: reasoning, category: category)
168
+ robot.learn(content)
169
+ ```
170
+
171
+ `robot.learn` updates the current session context so the LLM has immediate access to the new knowledge without waiting for the next run. Because HTM deduplicates by content hash, the subsequent `on_learn` callback does not produce a duplicate entry.
172
+
173
+ ---
174
+
175
+ ## HTM Recall Strategies
176
+
177
+ | Strategy | Mechanism | Best for |
178
+ |----------|-----------|----------|
179
+ | `:hybrid` | Combines vector similarity and full-text scoring | General use — recommended default |
180
+ | `:vector` | Embedding-based cosine similarity (pgvector) | Semantic / conceptual queries where exact keywords are unknown |
181
+ | `:fulltext` | PostgreSQL full-text search (tsvector / tsquery) | Keyword lookup, proper nouns, technical identifiers |
182
+
183
+ ---
184
+
185
+ ## Day Trader Demo
186
+
187
+ `examples/01_day_trader.rb` demonstrates cross-session durable memory in a quantitative trading context.
188
+
189
+ It spawns two subprocesses:
190
+
191
+ - **`[GEN ]`** — GBM (Geometric Brownian Motion) stock price generator.
192
+ - **`[PRED]`** — SMA+EMA ensemble predictor that issues directional forecasts.
193
+
194
+ Each prediction window receives a GOOD / MISS verdict printed to the terminal. The durable agent accumulates parameter tuning knowledge across sessions: window sizes, smoothing factors, and signal thresholds that worked well in past runs are recalled and applied, improving prediction accuracy over time.
195
+
196
+ ```
197
+ ruby examples/01_day_trader.rb
198
+ ```
199
+
200
+ ---
201
+
202
+ ## Migration Guide — v0.2.x to v0.3.0
203
+
204
+ v0.3.0 replaces the YAML file store, the `Durable::Store` class, the `Durable::Reflector`, the `Durable::Learning` mixin, and the `skip_persist` mechanism with a direct adapter over the `htm` gem. All domain/confidence concepts are gone.
205
+
206
+ ### Gemfile
207
+
208
+ ```ruby
209
+ # Before
210
+ gem "robot_lab-durable"
211
+
212
+ # After — htm gem is required; ensure PostgreSQL + pgvector are available
213
+ gem "robot_lab-durable"
214
+ gem "htm"
215
+ ```
216
+
217
+ ### Registering the hook
218
+
219
+ ```ruby
220
+ # Before (v0.2.x)
221
+ robot.on(RobotLab::Durable::Hook, context: { domain: "finance" })
47
222
 
223
+ # After (v0.3.0) — no domain argument; scoped by robot name automatically
224
+ robot.on(RobotLab::Durable::Hook)
48
225
  ```
49
- ~/.robot_lab/durable/
50
- finance.yml # per-domain YAML store
51
- support.yml
52
- ...
226
+
227
+ ### Building a robot
228
+
229
+ ```ruby
230
+ # Before (v0.2.x)
231
+ robot = RobotLab.build(
232
+ name: "advisor",
233
+ system_prompt: "...",
234
+ learn: true,
235
+ learn_domain: "finance"
236
+ )
237
+
238
+ # After (v0.3.0) — learn: / learn_domain: removed; use tools + hook instead
239
+ robot = RobotLab.build(
240
+ name: "advisor",
241
+ system_prompt: "...",
242
+ local_tools: [RobotLab::RecallKnowledge, RobotLab::RecordKnowledge]
243
+ )
244
+ robot.on(RobotLab::Durable::Hook)
53
245
  ```
54
246
 
55
- Each entry records `content`, `confidence`, `category`, `domain`, `use_count`, `created_at`, and `updated_at`.
247
+ ### Storage location
248
+
249
+ Before v0.3.0, knowledge was stored as YAML files under `~/.robot_lab/durable/`. In v0.3.0, all knowledge is stored in PostgreSQL via HTM. Existing YAML data is not migrated automatically — if you need to preserve past learnings, insert them into HTM using `adapter.record` before removing the old files.
250
+
251
+ ### Removed classes
252
+
253
+ The following classes no longer exist and should be removed from any code that references them:
254
+
255
+ - `Durable::Store`
256
+ - `Durable::Reflector`
257
+ - `Durable::Learning` (mixin)
258
+ - `Hook.skip_persist` block helper
259
+
260
+ ---
56
261
 
57
262
  ## Links
58
263
 
59
- - [Implementation Plan](superpowers/plans/2026-05-06-durable-learning.md)
60
- - [Design Spec](superpowers/specs/2026-05-06-durable-learning-design.md)
61
264
  - [RobotLab Core](https://github.com/MadBomber/robot_lab)
265
+ - [HTM gem docs](https://madbomber.github.io/htm)
62
266
  - [RubyGems](https://rubygems.org/gems/robot_lab-durable)
267
+ - [GitHub](https://github.com/MadBomber/robot_lab-durable)
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Day Trader: subprocess launcher with interleaved prefixed output
5
+ #
6
+ # Spawns the generator and predictor as child processes, reads their
7
+ # stdout in background threads, and prints every line with a color-coded
8
+ # prefix so both streams are visible in a single terminal.
9
+ #
10
+ # [GEN ] lines — amber — price ticks from the XYZZY generator
11
+ # [PRED] lines — cyan — predictions, window results, and GOOD/MISS verdicts
12
+ #
13
+ # Ctrl-C forwards SIGINT to both children and shuts everything down.
14
+ #
15
+ # Prerequisites:
16
+ # Redis server running on localhost:6379
17
+ # PostgreSQL configured for HTM (see HTM gem setup)
18
+ #
19
+ # Usage (from the robot_lab-durable root):
20
+ # ruby examples/01_day_trader.rb
21
+
22
+ require 'open3'
23
+
24
+ RUBY = RbConfig.ruby
25
+ EXAMPLES_DIR = File.expand_path("day_trader_lib", __dir__)
26
+ GENERATOR = File.join(EXAMPLES_DIR, "generator.rb")
27
+ PREDICTOR = File.join(EXAMPLES_DIR, "predictor.rb")
28
+
29
+ GEN_COLOR = "\e[33m" # amber
30
+ PRED_COLOR = "\e[36m" # cyan
31
+ RESET = "\e[0m"
32
+ GEN_LABEL = "[GEN ] "
33
+ PRED_LABEL = "[PRED] "
34
+
35
+ def die(msg)
36
+ warn msg
37
+ exit 1
38
+ end
39
+
40
+ [GENERATOR, PREDICTOR].each { |f| die("Missing: #{f}") unless File.exist?(f) }
41
+
42
+ puts <<~BANNER
43
+ #{"=" * 62}
44
+ Day Trader Demo
45
+ Generator : XYZZY price stream via Redis (5s ticks, GBM model)
46
+ Predictor : SMA+EMA ensemble tuned by a RobotLab durable agent
47
+ Feedback : ✓ GOOD / ✗ MISS verdict on each prediction window
48
+ Stop : Ctrl-C
49
+ #{"=" * 62}
50
+ BANNER
51
+
52
+ # ── Spawn generator ───────────────────────────────────────────────────────────
53
+
54
+ _gen_in, gen_out, gen_thread = Open3.popen2e(RUBY, GENERATOR)
55
+ gen_pid = gen_thread.pid
56
+
57
+ # Give the generator one tick to connect to Redis before the predictor
58
+ # subscribes, avoiding a missed first message.
59
+ sleep 1
60
+
61
+ # ── Spawn predictor ───────────────────────────────────────────────────────────
62
+
63
+ _pred_in, pred_out, pred_thread = Open3.popen2e(RUBY, PREDICTOR)
64
+ pred_pid = pred_thread.pid
65
+
66
+ # ── Ctrl-C: forward SIGINT to both children ───────────────────────────────────
67
+
68
+ trap("INT") do
69
+ [gen_pid, pred_pid].each { |pid| Process.kill("INT", pid) rescue nil }
70
+ end
71
+
72
+ # ── Relay each process's output with a color-coded label ─────────────────────
73
+
74
+ def relay(io, label, color)
75
+ Thread.new do
76
+ io.each_line do |line|
77
+ $stdout.print "#{color}#{label}#{RESET}#{line}"
78
+ $stdout.flush
79
+ end
80
+ end
81
+ end
82
+
83
+ t1 = relay(gen_out, GEN_LABEL, GEN_COLOR)
84
+ t2 = relay(pred_out, PRED_LABEL, PRED_COLOR)
85
+
86
+ t1.join
87
+ t2.join
88
+
89
+ gen_thread.value
90
+ pred_thread.value
91
+
92
+ puts "\nDay Trader stopped."
@@ -1,22 +1,24 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Example 33: XYZZY Stock Price Generator
4
+ # Day Trader: XYZZY Stock Price Generator
5
5
  #
6
6
  # Publishes fake streaming prices for ticker XYZZY to a Redis channel
7
7
  # using Geometric Brownian Motion with occasional volatility regime shifts.
8
8
  #
9
+ # Launched by examples/01_day_trader.rb.
10
+ # Can also be run standalone.
11
+ #
9
12
  # Prerequisites:
10
13
  # gem install redis
11
14
  # Redis server running on localhost:6379
12
- #
13
- # Usage:
14
- # ruby examples/33_stock_generator.rb
15
15
 
16
16
  require "redis"
17
17
  require "json"
18
18
  require "time"
19
19
 
20
+ $stdout.sync = true # flush every line immediately through the pipe
21
+
20
22
  CHANNEL = "stock:xyzzy"
21
23
  START_PRICE = 100.0
22
24
  BASE_VOL = 0.008 # baseline volatility per tick (~0.8%)
@@ -1,30 +1,40 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Example 33: XYZZY Stock Price Predictor
4
+ # Day Trader: XYZZY Stock Price Predictor
5
5
  #
6
- # Consumes fake streaming prices for ticker XYZZY from a Redis channel,
7
- # predicts the high and low over the next price window using an SMA + EMA
8
- # ensemble, and uses a RobotLab learning robot to tune predictor parameters
9
- # after each window closes.
6
+ # Subscribes to the XYZZY Redis channel, accumulates ticks into windows,
7
+ # predicts each window's high/low using an SMA+EMA ensemble, and uses a
8
+ # RobotLab durable agent to tune parameters across sessions.
10
9
  #
11
- # Run alongside:
12
- # ruby examples/33_stock_generator.rb (in a separate terminal)
10
+ # After each window closes the predictor prints a GOOD/MISS verdict:
11
+ # GOOD mean prediction error ≤ GOOD_THRESHOLD
12
+ # ✗ MISS mean prediction error > GOOD_THRESHOLD
13
+ #
14
+ # Launched by examples/01_day_trader.rb.
15
+ # Can also be run standalone alongside generator.rb.
13
16
  #
14
17
  # Prerequisites:
15
18
  # gem install redis
16
19
  # Redis server running on localhost:6379
17
- #
18
- # Usage:
19
- # ruby examples/33_stock_predictor.rb
20
+ # PostgreSQL configured for HTM (see HTM gem setup)
21
+
22
+ $stdout.sync = true # flush every line immediately through the pipe
23
+
24
+ $LOAD_PATH.unshift File.expand_path('../../../robot_lab/lib', __dir__)
25
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
20
26
 
21
27
  require "robot_lab"
22
28
  require "robot_lab/durable"
23
29
  require "redis"
24
30
  require "json"
25
31
 
26
- CHANNEL = "stock:xyzzy"
27
- WINDOW_SIZE = 12 # ticks per prediction window
32
+ DEBUG_MODE = ARGV.delete("--debug")
33
+ RubyLLM.configure { |c| c.log_level = DEBUG_MODE ? Logger::DEBUG : Logger::WARN }
34
+
35
+ CHANNEL = "stock:xyzzy"
36
+ WINDOW_SIZE = 12 # ticks per prediction window
37
+ GOOD_THRESHOLD = 2.00 # mean error (dollars) at or below which a window is GOOD
28
38
 
29
39
  # ── Mutable predictor parameters ──────────────────────────────────────────────
30
40
 
@@ -139,14 +149,12 @@ class AdjustParameters < RobotLab::Tool
139
149
 
140
150
  clamped = value.to_f.clamp(spec[:min], spec[:max])
141
151
  clamped = clamped.round if spec[:integer]
142
-
143
152
  PredictorConfig.send(:"#{parameter}=", clamped)
144
-
145
153
  "Set #{parameter} = #{clamped}. #{reasoning}"
146
154
  end
147
155
  end
148
156
 
149
- # ── Error metrics ──────────────────────────────────────────────────────────────
157
+ # ── Window evaluation ──────────────────────────────────────────────────────────
150
158
 
151
159
  WindowResult = Data.define(
152
160
  :window_num,
@@ -170,6 +178,17 @@ def evaluate_window(window_num, predicted, actuals)
170
178
  )
171
179
  end
172
180
 
181
+ def print_window_result(result)
182
+ verdict = result.mean_err <= GOOD_THRESHOLD ? "✓ GOOD" : "✗ MISS"
183
+ puts "─" * 58
184
+ puts "Window #{result.window_num} #{verdict} " \
185
+ "mean_err=$#{"%.2f" % result.mean_err} (threshold $#{"%.2f" % GOOD_THRESHOLD})"
186
+ puts " Predicted h=$%-8.2f l=$%.2f" % [result.predicted_high, result.predicted_low]
187
+ puts " Actual h=$%-8.2f l=$%.2f err h=$%.2f l=$%.2f" % [
188
+ result.actual_high, result.actual_low, result.high_err, result.low_err
189
+ ]
190
+ end
191
+
173
192
  def tuner_prompt(result)
174
193
  <<~PROMPT
175
194
  Window #{result.window_num} just closed.
@@ -193,49 +212,52 @@ end
193
212
 
194
213
  # ── Main ──────────────────────────────────────────────────────────────────────
195
214
 
196
- puts "=" * 60
215
+ puts "=" * 58
197
216
  puts "XYZZY Stock Predictor"
198
- puts "=" * 60
217
+ puts "=" * 58
199
218
  puts "Channel : #{CHANNEL}"
200
219
  puts "Window : #{WINDOW_SIZE} ticks"
201
220
  puts "Model : SMA + EMA Ensemble with Durable Learning"
202
221
  puts "Warmup : #{PredictorConfig.sma_window} ticks"
203
- puts "Press Ctrl-C to stop."
204
- puts "-" * 60
205
-
206
- redis = Redis.new
207
- prices = []
208
- robot = RobotLab.build(
209
- name: "predictor_tuner",
210
- system_prompt: <<~PROMPT,
211
- You are a quantitative analyst tuning an ensemble stock price range
212
- predictor for ticker XYZZY. Each prediction covers the high and low
213
- price over the next #{WINDOW_SIZE} ticks.
214
-
215
- The ensemble combines a Simple Moving Average (SMA) band and an
216
- Exponential Moving Average (EMA) band. Adjustable parameters:
217
-
218
- sma_window (3-30 int) lookback period for SMA
219
- sma_std_multiplier (0.5-4.0) — band width relative to SMA stddev
220
- ema_alpha (0.05-0.5) EMA smoothing (higher = more reactive)
221
- ema_vol_multiplier (0.5-4.0) — band width relative to EMA volatility
222
- sma_weight (0.0-1.0) — SMA share in ensemble (EMA = 1 - weight)
223
-
224
- Workflow per window:
225
- 1. Call RecallKnowledge to check past findings before acting.
226
- 2. If the error is clearly too high/low in one direction, adjust the
227
- relevant band multiplier via AdjustParameters.
228
- 3. Make at most two adjustments per window to isolate cause and effect.
229
- 4. If you observe a reliable pattern, call RecordKnowledge to preserve it.
230
- 5. When uncertain, do nothing rather than guess.
231
- PROMPT
232
- local_tools: [AdjustParameters],
233
- learn: true,
234
- learn_domain: "xyzzy stock prediction"
235
- )
222
+ puts "Good threshold: $#{"%.2f" % GOOD_THRESHOLD} mean error"
223
+ puts "-" * 58
224
+
225
+ redis = Redis.new
226
+ prices = []
227
+ robot = RobotLab.build(
228
+ name: "predictor_tuner",
229
+ model: "gpt-4.1-mini",
230
+ provider: :openai,
231
+ system_prompt: <<~PROMPT,
232
+ You are a quantitative analyst tuning an ensemble stock price range
233
+ predictor for ticker XYZZY. Each prediction covers the high and low
234
+ price over the next #{WINDOW_SIZE} ticks.
235
+
236
+ The ensemble combines a Simple Moving Average (SMA) band and an
237
+ Exponential Moving Average (EMA) band. Adjustable parameters:
238
+
239
+ sma_window (3-30 int) lookback period for SMA
240
+ sma_std_multiplier (0.5-4.0) — band width relative to SMA stddev
241
+ ema_alpha (0.05-0.5) — EMA smoothing (higher = more reactive)
242
+ ema_vol_multiplier (0.5-4.0) — band width relative to EMA volatility
243
+ sma_weight (0.0-1.0) — SMA share in ensemble (EMA = 1 - weight)
244
+
245
+ Workflow per window:
246
+ 1. Call RecallKnowledge to check past findings before acting.
247
+ 2. If the error is clearly too high/low in one direction, adjust the
248
+ relevant band multiplier via AdjustParameters.
249
+ 3. Make at most two adjustments per window to isolate cause and effect.
250
+ 4. If you observe a reliable pattern, call RecordKnowledge to preserve it.
251
+ 5. When uncertain, do nothing rather than guess.
252
+ PROMPT
253
+ local_tools: [AdjustParameters,
254
+ RobotLab::RecallKnowledge,
255
+ RobotLab::RecordKnowledge]
256
+ )
257
+ robot.on(RobotLab::Durable::Hook)
236
258
 
237
259
  warmed_up = false
238
- pending_pred = nil # { prediction: {high:, low:}, window_prices: [] }
260
+ pending_pred = nil
239
261
  window_num = 0
240
262
 
241
263
  trap("INT") { puts "\nPredictor stopped."; exit }
@@ -251,7 +273,7 @@ redis.subscribe(CHANNEL) do |on|
251
273
  EMAPredictor.update(price)
252
274
  prices << price
253
275
 
254
- # ── Warmup phase ──────────────────────────────────────────────
276
+ # ── Warmup phase ────────────────────────────────────────────────────────
255
277
  unless warmed_up
256
278
  if prices.size < PredictorConfig.sma_window
257
279
  puts "Tick %5d $%8.2f [warming up %d/%d]" % [tick, price, prices.size, PredictorConfig.sma_window]
@@ -261,44 +283,41 @@ redis.subscribe(CHANNEL) do |on|
261
283
  warmed_up = true
262
284
  pred = EnsemblePredictor.predict(prices)
263
285
  pending_pred = { prediction: pred, window_prices: [] }
264
-
265
- puts "Tick %5d $%8.2f [warmup done]" % [tick, price]
266
- puts " First prediction → high=$#{pred[:high]} low=$#{pred[:low]}"
286
+ puts "Tick %5d $%8.2f [warmup done — first window open]" % [tick, price]
287
+ puts " First prediction h=$#{pred[:high]} l=$#{pred[:low]}"
267
288
  next
268
289
  end
269
290
 
270
- # ── Accumulate current window ──────────────────────────────────
291
+ # ── Accumulate current window ──────────────────────────────────────────
271
292
  pending_pred[:window_prices] << price
272
293
  progress = pending_pred[:window_prices].size
273
294
  pred = pending_pred[:prediction]
274
295
 
275
- puts "Tick %5d $%8.2f [%2d/#{WINDOW_SIZE}] (pred high=$#{pred[:high]} low=$#{pred[:low]})" %
296
+ puts "Tick %5d $%8.2f [%2d/#{WINDOW_SIZE}] pred h=$#{pred[:high]} l=$#{pred[:low]}" %
276
297
  [tick, price, progress]
277
298
 
278
299
  next unless progress >= WINDOW_SIZE
279
300
 
280
- # ── Window closed evaluate ───────────────────────────────────
301
+ # ── Window closed: evaluate, print verdict, tune ───────────────────────
281
302
  window_num += 1
282
303
  result = evaluate_window(window_num, pred, pending_pred[:window_prices])
283
304
 
284
- puts "\n#{"─" * 60}"
285
- puts " Window #{result.window_num} result:"
286
- puts " Predicted high=$%-8.2f low=$%-.2f" % [result.predicted_high, result.predicted_low]
287
- puts " Actual high=$%-8.2f low=$%-.2f" % [result.actual_high, result.actual_low]
288
- puts " Error high=%-8.2f low=%-8.2f mean=%.2f" % [result.high_err, result.low_err, result.mean_err]
289
- puts "#{"─" * 60}"
290
-
291
- print " [tuner] analyzing window #{window_num}..."
292
- tuner_response = robot.run(tuner_prompt(result))
293
- tuner_line = tuner_response.reply.lines.first&.chomp || "(no response)"
294
- puts "\r [tuner] #{tuner_line}#{" " * 20}"
305
+ print_window_result(result)
306
+
307
+ puts " Tuning..."
308
+ begin
309
+ tuner_response = robot.run(tuner_prompt(result))
310
+ tuner_line = tuner_response.reply.lines.first&.chomp || "(no response)"
311
+ puts " → #{tuner_line}"
312
+ rescue StandardError => e
313
+ puts " → Tuning skipped: #{e.message.lines.first&.chomp}"
314
+ end
295
315
  puts " Params: #{PredictorConfig.summary}"
296
- puts
316
+ puts "─" * 58
297
317
 
298
- # ── Start next window ──────────────────────────────────────────
318
+ # ── Start next window ─────────────────────────────────────────────────
299
319
  new_pred = EnsemblePredictor.predict(prices)
300
320
  pending_pred = { prediction: new_pred, window_prices: [] }
301
- puts " Next prediction → high=$#{new_pred[:high]} low=$#{new_pred[:low]}"
302
- puts
321
+ puts "Next prediction → h=$#{new_pred[:high]} l=$#{new_pred[:low]}"
303
322
  end
304
323
  end