robot_lab-durable 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/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +83 -0
- data/Rakefile +8 -0
- data/docs/index.md +62 -0
- data/docs/superpowers/plans/2026-05-06-durable-learning.md +1247 -0
- data/docs/superpowers/specs/2026-05-06-durable-learning-design.md +182 -0
- data/examples/33_stock_generator.rb +80 -0
- data/examples/33_stock_predictor.rb +304 -0
- data/lib/robot_lab/durable/entry.rb +49 -0
- data/lib/robot_lab/durable/learning.rb +39 -0
- data/lib/robot_lab/durable/reflector.rb +47 -0
- data/lib/robot_lab/durable/store.rb +119 -0
- data/lib/robot_lab/durable/version.rb +7 -0
- data/lib/robot_lab/durable.rb +24 -0
- data/lib/robot_lab/recall_knowledge.rb +30 -0
- data/lib/robot_lab/record_knowledge.rb +37 -0
- data/mkdocs.yml +116 -0
- metadata +82 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Durable Learning for RobotLab
|
|
2
|
+
|
|
3
|
+
**Date**: 2026-05-06
|
|
4
|
+
**Status**: Draft
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
Robots built with RobotLab have no memory between sessions and no mechanism for improving their judgment over time. Each run starts from zero. The newsletter reader robot is the motivating case: it should get better at deciding what content belongs in the Obsidian PKM vault without requiring the user to explain their preferences every time.
|
|
9
|
+
|
|
10
|
+
## Goals
|
|
11
|
+
|
|
12
|
+
- Robots accumulate knowledge within a session (explicit during operation, reflection at end)
|
|
13
|
+
- Knowledge persists across sessions and across projects
|
|
14
|
+
- Conservative bias: when uncertain and no relevant past learning exists, skip rather than guess
|
|
15
|
+
- Human-readable, auditable, directly editable storage
|
|
16
|
+
- General capability — not newsletter-specific
|
|
17
|
+
|
|
18
|
+
## Non-goals
|
|
19
|
+
|
|
20
|
+
- Automated curator/consolidation (YAGNI for now)
|
|
21
|
+
- Confidence decay over time (YAGNI for now)
|
|
22
|
+
- Fine-tuning or model-level learning (out of scope)
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Design
|
|
27
|
+
|
|
28
|
+
### `RobotLab::Durable::Entry`
|
|
29
|
+
|
|
30
|
+
A single knowledge record. Immutable value object (Ruby `Data.define`).
|
|
31
|
+
|
|
32
|
+
| Field | Type | Description |
|
|
33
|
+
|--------------|---------|----------------------------------------------------------------|
|
|
34
|
+
| `content` | String | The fact, pattern, or preference learned, in plain language |
|
|
35
|
+
| `reasoning` | String | Why — captured from discussion or observation |
|
|
36
|
+
| `category` | Symbol | `:fact`, `:preference`, `:pattern`, `:correction` |
|
|
37
|
+
| `domain` | String | Topic area ("Ruby tooling", "newsletter curation") |
|
|
38
|
+
| `confidence` | Float | 0.1 initial, increments by 0.1 each confirmed application, max 1.0 |
|
|
39
|
+
| `use_count` | Integer | Times recalled and applied |
|
|
40
|
+
| `created_at` | String | ISO8601 timestamp |
|
|
41
|
+
| `updated_at` | String | ISO8601 timestamp |
|
|
42
|
+
|
|
43
|
+
### `RobotLab::Durable::Store`
|
|
44
|
+
|
|
45
|
+
Reads and writes `Entry` records. Storage: Markdown files with YAML frontmatter in `lib/robot_lab/durable/`, one file per domain (e.g., `newsletter_curation.md`, `ruby_tooling.md`).
|
|
46
|
+
|
|
47
|
+
File format mirrors the Obsidian PKM convention — intentionally familiar:
|
|
48
|
+
|
|
49
|
+
```markdown
|
|
50
|
+
---
|
|
51
|
+
domain: newsletter curation
|
|
52
|
+
entries:
|
|
53
|
+
- content: Skip LangChain content
|
|
54
|
+
reasoning: User is Ruby-only and considers Python tooling irrelevant
|
|
55
|
+
category: preference
|
|
56
|
+
confidence: 0.4
|
|
57
|
+
use_count: 3
|
|
58
|
+
created_at: "2026-05-06T12:00:00Z"
|
|
59
|
+
updated_at: "2026-05-06T14:30:00Z"
|
|
60
|
+
---
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Key methods:
|
|
64
|
+
- `recall(query:, domain: nil, min_confidence: 0.0)` — returns relevant entries, sorted by confidence
|
|
65
|
+
- `record(entry)` — appends or updates an entry in the appropriate domain file
|
|
66
|
+
- `confirm(entry)` — increments `confidence` by 0.1 and `use_count` by 1, updates `updated_at`
|
|
67
|
+
|
|
68
|
+
Matching strategy: keyword overlap on `content` + `domain` fields. Semantic search via fastembed is a future enhancement.
|
|
69
|
+
|
|
70
|
+
### `RobotLab::Durable::Reflector`
|
|
71
|
+
|
|
72
|
+
A lightweight robot that runs at session end. Receives the session's `Memory` (messages, results, data) and the existing `Store`. Its job: identify observations from the session worth promoting to durable knowledge and call `store.record` for each.
|
|
73
|
+
|
|
74
|
+
Implemented as a `RobotLab.build` robot with a focused system prompt. Uses `RecordKnowledge` tool internally.
|
|
75
|
+
|
|
76
|
+
### `RobotLab::Durable::Learning` (mixin)
|
|
77
|
+
|
|
78
|
+
Included in a `Robot` to activate learning capability.
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
robot = RobotLab.build(
|
|
82
|
+
name: "newsletter_analyst",
|
|
83
|
+
system_prompt: "...",
|
|
84
|
+
local_tools: [FetchLatestNewsletter],
|
|
85
|
+
learn: true, # activates Durable::Learning
|
|
86
|
+
learn_domain: "newsletter curation"
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
What the mixin does:
|
|
91
|
+
1. Adds `RecallKnowledge` and `RecordKnowledge` to the robot's tool list
|
|
92
|
+
2. Runs a `recall` pass on session start, injecting relevant entries as context
|
|
93
|
+
3. Registers an `on_session_end` hook that triggers `Durable::Reflector`
|
|
94
|
+
|
|
95
|
+
### `RecallKnowledge` tool
|
|
96
|
+
|
|
97
|
+
The robot calls this before acting on anything uncertain.
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
Input: query (String), domain (String, optional)
|
|
101
|
+
Output: Array of matching Entry records as formatted context
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `RecordKnowledge` tool
|
|
105
|
+
|
|
106
|
+
The robot calls this during the session when it judges something worth keeping.
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
Input: content, reasoning, category, domain
|
|
110
|
+
Output: confirmation the entry was written
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Learning Loop
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
Session start
|
|
119
|
+
└─ Store.recall → inject relevant entries as context
|
|
120
|
+
|
|
121
|
+
During session
|
|
122
|
+
└─ Robot discusses/decides
|
|
123
|
+
└─ Robot calls RecordKnowledge (explicit, in-moment captures)
|
|
124
|
+
└─ Robot calls RecallKnowledge before uncertain decisions
|
|
125
|
+
└─ On confirmed application → Store.confirm (confidence++)
|
|
126
|
+
|
|
127
|
+
Session end
|
|
128
|
+
└─ Reflector reviews session Memory
|
|
129
|
+
└─ Promotes observations not yet explicitly recorded
|
|
130
|
+
└─ Writes to Store
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Conservative Bias
|
|
136
|
+
|
|
137
|
+
When `recall_knowledge` returns no relevant entries AND the robot's confidence on a candidate decision is below 0.5, the robot skips the action and logs the reason. This is enforced through system prompt guidance, not framework code.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Storage Location
|
|
142
|
+
|
|
143
|
+
`lib/robot_lab/durable/` — already exists as a placeholder. Domain files live here:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
lib/robot_lab/durable/
|
|
147
|
+
newsletter_curation.md
|
|
148
|
+
ruby_tooling.md
|
|
149
|
+
...
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Newsletter Reader Integration
|
|
155
|
+
|
|
156
|
+
The newsletter robot uses `learn: true, learn_domain: "newsletter curation"`. Its workflow:
|
|
157
|
+
|
|
158
|
+
1. Fetch newsletter RSS
|
|
159
|
+
2. Follow links one level deep, extract article content
|
|
160
|
+
3. For each article: `RecallKnowledge` to check against past decisions
|
|
161
|
+
4. High-confidence match → act autonomously (include or skip)
|
|
162
|
+
5. No match or low confidence → skip and log
|
|
163
|
+
6. Discussion with user → `RecordKnowledge` captures reasoning
|
|
164
|
+
7. Session end → `Reflector` promotes any missed observations
|
|
165
|
+
|
|
166
|
+
Over time, the confidence on frequently-confirmed patterns rises, and the robot handles more autonomously with fewer discussions required.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Files to Create
|
|
171
|
+
|
|
172
|
+
| File | Purpose |
|
|
173
|
+
|------|---------|
|
|
174
|
+
| `lib/robot_lab/durable/entry.rb` | `Entry` data class |
|
|
175
|
+
| `lib/robot_lab/durable/store.rb` | Read/write/confirm operations |
|
|
176
|
+
| `lib/robot_lab/durable/reflector.rb` | End-of-session reflection robot |
|
|
177
|
+
| `lib/robot_lab/durable/learning.rb` | `Learning` mixin for Robot |
|
|
178
|
+
| `lib/robot_lab/tools/recall_knowledge.rb` | `RecallKnowledge` tool |
|
|
179
|
+
| `lib/robot_lab/tools/record_knowledge.rb` | `RecordKnowledge` tool |
|
|
180
|
+
| `test/robot_lab/durable/entry_test.rb` | Unit tests |
|
|
181
|
+
| `test/robot_lab/durable/store_test.rb` | Unit tests |
|
|
182
|
+
| `examples/32_newsletter_reader.rb` | Updated with `learn: true` |
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 33: XYZZY Stock Price Generator
|
|
5
|
+
#
|
|
6
|
+
# Publishes fake streaming prices for ticker XYZZY to a Redis channel
|
|
7
|
+
# using Geometric Brownian Motion with occasional volatility regime shifts.
|
|
8
|
+
#
|
|
9
|
+
# Prerequisites:
|
|
10
|
+
# gem install redis
|
|
11
|
+
# Redis server running on localhost:6379
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# ruby examples/33_stock_generator.rb
|
|
15
|
+
|
|
16
|
+
require "redis"
|
|
17
|
+
require "json"
|
|
18
|
+
require "time"
|
|
19
|
+
|
|
20
|
+
CHANNEL = "stock:xyzzy"
|
|
21
|
+
START_PRICE = 100.0
|
|
22
|
+
BASE_VOL = 0.008 # baseline volatility per tick (~0.8%)
|
|
23
|
+
DRIFT = 0.0001 # slight upward drift per tick
|
|
24
|
+
|
|
25
|
+
# Box-Muller transform — standard normal sample
|
|
26
|
+
def randn
|
|
27
|
+
Math.sqrt(-2.0 * Math.log(rand)) * Math.cos(2.0 * Math::PI * rand)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Occasionally shift the volatility regime to create interesting price dynamics
|
|
31
|
+
def current_volatility(tick)
|
|
32
|
+
case tick % 60
|
|
33
|
+
when 0..10 then BASE_VOL * 2.0 # high volatility burst
|
|
34
|
+
when 30..35 then BASE_VOL * 0.4 # low volatility squeeze
|
|
35
|
+
else BASE_VOL
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
redis = Redis.new
|
|
42
|
+
price = START_PRICE
|
|
43
|
+
tick = 0
|
|
44
|
+
|
|
45
|
+
trap("INT") { puts "\nGenerator stopped."; exit }
|
|
46
|
+
|
|
47
|
+
puts "=" * 50
|
|
48
|
+
puts "XYZZY Stock Generator"
|
|
49
|
+
puts "=" * 50
|
|
50
|
+
puts "Channel : #{CHANNEL}"
|
|
51
|
+
puts "Interval: 5 seconds per tick"
|
|
52
|
+
puts "Model : Geometric Brownian Motion"
|
|
53
|
+
puts "Press Ctrl-C to stop."
|
|
54
|
+
puts "-" * 50
|
|
55
|
+
|
|
56
|
+
loop do
|
|
57
|
+
tick += 1
|
|
58
|
+
|
|
59
|
+
vol = current_volatility(tick)
|
|
60
|
+
price = (price * Math.exp((DRIFT - 0.5 * vol**2) + vol * randn)).round(2)
|
|
61
|
+
price = [price, 1.0].max # floor at $1.00
|
|
62
|
+
|
|
63
|
+
regime = case vol
|
|
64
|
+
when BASE_VOL * 2.0 then " [HIGH VOL]"
|
|
65
|
+
when BASE_VOL * 0.4 then " [low vol]"
|
|
66
|
+
else ""
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
payload = JSON.generate(
|
|
70
|
+
ticker: "XYZZY",
|
|
71
|
+
price: price,
|
|
72
|
+
tick: tick,
|
|
73
|
+
timestamp: Time.now.iso8601
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
redis.publish(CHANNEL, payload)
|
|
77
|
+
puts "Tick %5d $%8.2f vol=%.3f%s" % [tick, price, vol, regime]
|
|
78
|
+
|
|
79
|
+
sleep 5
|
|
80
|
+
end
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 33: XYZZY Stock Price Predictor
|
|
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.
|
|
10
|
+
#
|
|
11
|
+
# Run alongside:
|
|
12
|
+
# ruby examples/33_stock_generator.rb (in a separate terminal)
|
|
13
|
+
#
|
|
14
|
+
# Prerequisites:
|
|
15
|
+
# gem install redis
|
|
16
|
+
# Redis server running on localhost:6379
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# ruby examples/33_stock_predictor.rb
|
|
20
|
+
|
|
21
|
+
require "robot_lab"
|
|
22
|
+
require "robot_lab/durable"
|
|
23
|
+
require "redis"
|
|
24
|
+
require "json"
|
|
25
|
+
|
|
26
|
+
CHANNEL = "stock:xyzzy"
|
|
27
|
+
WINDOW_SIZE = 12 # ticks per prediction window
|
|
28
|
+
|
|
29
|
+
# ── Mutable predictor parameters ──────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
module PredictorConfig
|
|
32
|
+
@sma_window = 10
|
|
33
|
+
@sma_std_multiplier = 1.5
|
|
34
|
+
@ema_alpha = 0.2
|
|
35
|
+
@ema_vol_multiplier = 2.0
|
|
36
|
+
@sma_weight = 0.5
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
attr_accessor :sma_window, :sma_std_multiplier, :ema_alpha,
|
|
40
|
+
:ema_vol_multiplier, :sma_weight
|
|
41
|
+
|
|
42
|
+
def summary
|
|
43
|
+
format(
|
|
44
|
+
"sma_window=%d sma_std=%.2f ema_alpha=%.2f ema_vol=%.2f sma_weight=%.2f",
|
|
45
|
+
sma_window, sma_std_multiplier, ema_alpha, ema_vol_multiplier, sma_weight
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ── SMA predictor ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
module SMAPredictor
|
|
54
|
+
def self.predict(prices)
|
|
55
|
+
window = prices.last(PredictorConfig.sma_window)
|
|
56
|
+
mean = window.sum / window.size.to_f
|
|
57
|
+
var = window.sum { |p| (p - mean)**2 } / window.size.to_f
|
|
58
|
+
std = Math.sqrt(var)
|
|
59
|
+
mult = PredictorConfig.sma_std_multiplier
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
high: (mean + mult * std).round(2),
|
|
63
|
+
low: [mean - mult * std, 1.0].max.round(2)
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# ── EMA predictor (stateful — updated every tick) ──────────────────────────────
|
|
69
|
+
|
|
70
|
+
module EMAPredictor
|
|
71
|
+
@ema = nil
|
|
72
|
+
@var_ema = nil
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
def update(price)
|
|
76
|
+
alpha = PredictorConfig.ema_alpha
|
|
77
|
+
if @ema.nil?
|
|
78
|
+
@ema = price
|
|
79
|
+
@var_ema = 0.0
|
|
80
|
+
else
|
|
81
|
+
delta = price - @ema
|
|
82
|
+
@ema = alpha * price + (1 - alpha) * @ema
|
|
83
|
+
@var_ema = alpha * delta**2 + (1 - alpha) * @var_ema
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def predict
|
|
88
|
+
return nil if @ema.nil?
|
|
89
|
+
|
|
90
|
+
vol = Math.sqrt(@var_ema) * PredictorConfig.ema_vol_multiplier
|
|
91
|
+
{
|
|
92
|
+
high: (@ema + vol).round(2),
|
|
93
|
+
low: [@ema - vol, 1.0].max.round(2)
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ── Ensemble predictor ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
module EnsemblePredictor
|
|
102
|
+
def self.predict(prices)
|
|
103
|
+
sma = SMAPredictor.predict(prices)
|
|
104
|
+
ema = EMAPredictor.predict
|
|
105
|
+
return sma unless ema
|
|
106
|
+
|
|
107
|
+
w = PredictorConfig.sma_weight
|
|
108
|
+
{
|
|
109
|
+
high: (w * sma[:high] + (1 - w) * ema[:high]).round(2),
|
|
110
|
+
low: (w * sma[:low] + (1 - w) * ema[:low]).round(2)
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ── AdjustParameters tool ──────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
class AdjustParameters < RobotLab::Tool
|
|
118
|
+
description "Adjust one predictor parameter to improve future prediction accuracy. " \
|
|
119
|
+
"Make at most one or two targeted changes per window."
|
|
120
|
+
|
|
121
|
+
param :parameter, type: "string",
|
|
122
|
+
desc: "Parameter to adjust: sma_window, sma_std_multiplier, ema_alpha, ema_vol_multiplier, sma_weight"
|
|
123
|
+
param :value, type: "number",
|
|
124
|
+
desc: "New value (sma_window: 3-30 int; std/vol multipliers: 0.5-4.0; ema_alpha: 0.05-0.5; sma_weight: 0.0-1.0)"
|
|
125
|
+
param :reasoning, type: "string",
|
|
126
|
+
desc: "Why this change should reduce prediction error"
|
|
127
|
+
|
|
128
|
+
LIMITS = {
|
|
129
|
+
"sma_window" => { min: 3, max: 30, integer: true },
|
|
130
|
+
"sma_std_multiplier" => { min: 0.5, max: 4.0, integer: false },
|
|
131
|
+
"ema_alpha" => { min: 0.05, max: 0.5, integer: false },
|
|
132
|
+
"ema_vol_multiplier" => { min: 0.5, max: 4.0, integer: false },
|
|
133
|
+
"sma_weight" => { min: 0.0, max: 1.0, integer: false }
|
|
134
|
+
}.freeze
|
|
135
|
+
|
|
136
|
+
def execute(parameter:, value:, reasoning:)
|
|
137
|
+
spec = LIMITS[parameter]
|
|
138
|
+
return "Unknown parameter '#{parameter}'. Valid: #{LIMITS.keys.join(", ")}" unless spec
|
|
139
|
+
|
|
140
|
+
clamped = value.to_f.clamp(spec[:min], spec[:max])
|
|
141
|
+
clamped = clamped.round if spec[:integer]
|
|
142
|
+
|
|
143
|
+
PredictorConfig.send(:"#{parameter}=", clamped)
|
|
144
|
+
|
|
145
|
+
"Set #{parameter} = #{clamped}. #{reasoning}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ── Error metrics ──────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
WindowResult = Data.define(
|
|
152
|
+
:window_num,
|
|
153
|
+
:predicted_high, :predicted_low,
|
|
154
|
+
:actual_high, :actual_low,
|
|
155
|
+
:high_err, :low_err, :mean_err
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def evaluate_window(window_num, predicted, actuals)
|
|
159
|
+
actual_high = actuals.max.round(2)
|
|
160
|
+
actual_low = actuals.min.round(2)
|
|
161
|
+
high_err = (predicted[:high] - actual_high).abs.round(2)
|
|
162
|
+
low_err = (predicted[:low] - actual_low).abs.round(2)
|
|
163
|
+
mean_err = ((high_err + low_err) / 2.0).round(2)
|
|
164
|
+
|
|
165
|
+
WindowResult.new(
|
|
166
|
+
window_num:,
|
|
167
|
+
predicted_high: predicted[:high], predicted_low: predicted[:low],
|
|
168
|
+
actual_high:, actual_low:,
|
|
169
|
+
high_err:, low_err:, mean_err:
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def tuner_prompt(result)
|
|
174
|
+
<<~PROMPT
|
|
175
|
+
Window #{result.window_num} just closed.
|
|
176
|
+
|
|
177
|
+
Prediction vs Actual:
|
|
178
|
+
Predicted: high=$#{result.predicted_high} low=$#{result.predicted_low}
|
|
179
|
+
Actual: high=$#{result.actual_high} low=$#{result.actual_low}
|
|
180
|
+
Error: high_err=$#{result.high_err} low_err=$#{result.low_err} mean_err=$#{result.mean_err}
|
|
181
|
+
|
|
182
|
+
Current parameters:
|
|
183
|
+
#{PredictorConfig.summary}
|
|
184
|
+
|
|
185
|
+
Window size: #{WINDOW_SIZE} ticks.
|
|
186
|
+
|
|
187
|
+
First call RecallKnowledge to check what has worked before.
|
|
188
|
+
Then decide whether to adjust a parameter via AdjustParameters.
|
|
189
|
+
If the error is acceptable or you are uncertain, do nothing.
|
|
190
|
+
If you notice a clear pattern worth preserving, call RecordKnowledge.
|
|
191
|
+
PROMPT
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
puts "=" * 60
|
|
197
|
+
puts "XYZZY Stock Predictor"
|
|
198
|
+
puts "=" * 60
|
|
199
|
+
puts "Channel : #{CHANNEL}"
|
|
200
|
+
puts "Window : #{WINDOW_SIZE} ticks"
|
|
201
|
+
puts "Model : SMA + EMA Ensemble with Durable Learning"
|
|
202
|
+
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
|
+
)
|
|
236
|
+
|
|
237
|
+
warmed_up = false
|
|
238
|
+
pending_pred = nil # { prediction: {high:, low:}, window_prices: [] }
|
|
239
|
+
window_num = 0
|
|
240
|
+
|
|
241
|
+
trap("INT") { puts "\nPredictor stopped."; exit }
|
|
242
|
+
|
|
243
|
+
puts "Connecting to Redis and subscribing to #{CHANNEL}..."
|
|
244
|
+
|
|
245
|
+
redis.subscribe(CHANNEL) do |on|
|
|
246
|
+
on.message do |_channel, payload|
|
|
247
|
+
data = JSON.parse(payload, symbolize_names: true)
|
|
248
|
+
tick = data[:tick]
|
|
249
|
+
price = data[:price].to_f
|
|
250
|
+
|
|
251
|
+
EMAPredictor.update(price)
|
|
252
|
+
prices << price
|
|
253
|
+
|
|
254
|
+
# ── Warmup phase ──────────────────────────────────────────────
|
|
255
|
+
unless warmed_up
|
|
256
|
+
if prices.size < PredictorConfig.sma_window
|
|
257
|
+
puts "Tick %5d $%8.2f [warming up %d/%d]" % [tick, price, prices.size, PredictorConfig.sma_window]
|
|
258
|
+
next
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
warmed_up = true
|
|
262
|
+
pred = EnsemblePredictor.predict(prices)
|
|
263
|
+
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]}"
|
|
267
|
+
next
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# ── Accumulate current window ──────────────────────────────────
|
|
271
|
+
pending_pred[:window_prices] << price
|
|
272
|
+
progress = pending_pred[:window_prices].size
|
|
273
|
+
pred = pending_pred[:prediction]
|
|
274
|
+
|
|
275
|
+
puts "Tick %5d $%8.2f [%2d/#{WINDOW_SIZE}] (pred high=$#{pred[:high]} low=$#{pred[:low]})" %
|
|
276
|
+
[tick, price, progress]
|
|
277
|
+
|
|
278
|
+
next unless progress >= WINDOW_SIZE
|
|
279
|
+
|
|
280
|
+
# ── Window closed — evaluate ───────────────────────────────────
|
|
281
|
+
window_num += 1
|
|
282
|
+
result = evaluate_window(window_num, pred, pending_pred[:window_prices])
|
|
283
|
+
|
|
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}"
|
|
295
|
+
puts " Params: #{PredictorConfig.summary}"
|
|
296
|
+
puts
|
|
297
|
+
|
|
298
|
+
# ── Start next window ──────────────────────────────────────────
|
|
299
|
+
new_pred = EnsemblePredictor.predict(prices)
|
|
300
|
+
pending_pred = { prediction: new_pred, window_prices: [] }
|
|
301
|
+
puts " Next prediction → high=$#{new_pred[:high]} low=$#{new_pred[:low]}"
|
|
302
|
+
puts
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module Durable
|
|
5
|
+
Entry = Data.define(:content, :reasoning, :category, :domain, :confidence, :use_count, :created_at, :updated_at) do
|
|
6
|
+
CONFIDENCE_INCREMENT = 0.1
|
|
7
|
+
MAX_CONFIDENCE = 1.0
|
|
8
|
+
|
|
9
|
+
# Return a new Entry with confidence incremented and use_count bumped.
|
|
10
|
+
def confirm
|
|
11
|
+
new_confidence = [confidence + CONFIDENCE_INCREMENT, MAX_CONFIDENCE].min
|
|
12
|
+
with(
|
|
13
|
+
confidence: new_confidence.round(10),
|
|
14
|
+
use_count: use_count + 1,
|
|
15
|
+
updated_at: Time.now.iso8601
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Serialize to a plain Hash with string keys (safe for YAML round-trip).
|
|
20
|
+
def to_h
|
|
21
|
+
{
|
|
22
|
+
"content" => content,
|
|
23
|
+
"reasoning" => reasoning,
|
|
24
|
+
"category" => category.to_s,
|
|
25
|
+
"domain" => domain,
|
|
26
|
+
"confidence" => confidence,
|
|
27
|
+
"use_count" => use_count,
|
|
28
|
+
"created_at" => created_at,
|
|
29
|
+
"updated_at" => updated_at
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Deserialize from a Hash (string or symbol keys).
|
|
34
|
+
def self.from_h(hash)
|
|
35
|
+
h = hash.transform_keys(&:to_s)
|
|
36
|
+
new(
|
|
37
|
+
content: h["content"],
|
|
38
|
+
reasoning: h["reasoning"],
|
|
39
|
+
category: h["category"]&.to_sym,
|
|
40
|
+
domain: h["domain"],
|
|
41
|
+
confidence: h["confidence"].to_f,
|
|
42
|
+
use_count: h["use_count"].to_i,
|
|
43
|
+
created_at: h["created_at"],
|
|
44
|
+
updated_at: h["updated_at"]
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module Durable
|
|
5
|
+
module Learning
|
|
6
|
+
# Configure durable learning on a robot after initialization.
|
|
7
|
+
#
|
|
8
|
+
# @param domain [String] topic area for this robot's knowledge
|
|
9
|
+
# @param store_path [String, nil] override default ~/.robot_lab/durable path
|
|
10
|
+
def setup_durable_learning(domain:, store_path: nil)
|
|
11
|
+
@learn_domain = domain.to_s
|
|
12
|
+
opts = store_path ? { path: store_path } : {}
|
|
13
|
+
@durable_store = Store.new(**opts)
|
|
14
|
+
|
|
15
|
+
seed_from_store
|
|
16
|
+
@local_tools = (@local_tools + [RecallKnowledge, RecordKnowledge]).uniq
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Run the end-of-session reflection pass.
|
|
20
|
+
# Called automatically from Robot#run when durable learning is active.
|
|
21
|
+
def run_reflector
|
|
22
|
+
return unless @durable_store && @learn_domain && @learnings&.any?
|
|
23
|
+
|
|
24
|
+
Reflector.new(store: @durable_store, domain: @learn_domain).reflect(@learnings)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def seed_from_store
|
|
30
|
+
return unless @durable_store && @learn_domain
|
|
31
|
+
|
|
32
|
+
entries = @durable_store.recall(query: @learn_domain, domain: @learn_domain, min_confidence: 0.0)
|
|
33
|
+
entries.each do |e|
|
|
34
|
+
learn("[#{e.category}] #{e.content}: #{e.reasoning}")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|