Skip to content

Commit 24a84aa

Browse files
authored
Merge pull request #1 from daslabhq/feat/predict
feat: tier 4 — Predictor + DreamerSolver + worldmodel eval (v0)
2 parents 754f01e + 499a7e1 commit 24a84aa

9 files changed

Lines changed: 1008 additions & 3 deletions

File tree

README.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,22 +58,25 @@ npx scenegrad view ./traces # bulk view of every JSONL in ./traces
5858
npx scenegrad view ./traces/run.jsonl # single-trace view of one file
5959
```
6060

61+
> **v0.0.1 note:** the CLI requires [Bun](https://bun.sh) on your PATH (the bin scripts are TypeScript with a `#!/usr/bin/env bun` shebang). Node-compatible bin compilation lands in v0.0.2.
62+
6163
That's it. Drop in, run, view your runs in a browser. No goal to design. No assertions to write. No restructuring of your agent.
6264

6365
When you want more — add a `snapshot()` to capture world state between calls. Add a `goal()` of assertions to measure gap closure. Each level is opt-in.
6466

6567
---
6668

67-
## The four-tier ladder
69+
## The five-tier ladder
6870

6971
| Tier | What you write | What you get |
7072
|---|---|---|
7173
| **0 — trace** | `trace.start()` + one hook | Tool-call timeline, scrubbable. Replaces logs. |
7274
| **1 — + snapshot** | Add `snapshot: () => fetchWorld()` | World deltas between calls. See *what changed*, not just what was called. |
7375
| **2 — + goal** | Add `goal: (s) => [...assertions]` | Gap-closure curve, drift detection, runtime `status()` for agent guidance. |
7476
| **3 — + solver** | Use `defineEnv` + `LLMSolver` / `GreedySolver` | scenegrad drives the loop — for benches, comparison, leaderboards. |
77+
| **4 — + predictor** | Add `Predictor` + `DreamerSolver` | Plan in imagination, act in the env. World-model accuracy as a measurable benchmark metric. |
7578

76-
The same trajectory format flows through all four tiers. You can adopt at tier 0, level up months later as you understand your agent's failure modes.
79+
The same trajectory format flows through all five tiers. You can adopt at tier 0, level up months later as you understand your agent's failure modes.
7780

7881
---
7982

@@ -152,6 +155,33 @@ Both solvers produce the same `SolveResult` shape. Compare them on the same env
152155

153156
---
154157

158+
## Tier 4 — predictor + dreamer, plan in imagination
159+
160+
When env-side `simulate()` isn't available (real APIs, prod databases, irreversible actions), you can't enumerate-and-pick. Tier 4 swaps the simulator for a **Predictor** — a learned-or-LLM model of `predict(scene, action) → consequence`. `DreamerSolver` calls the predictor on each candidate, picks the one whose imagined outcome closes the most distance, and commits a single action to the real env.
161+
162+
```ts
163+
import { defineEnv, LLMPredictor, DreamerSolver, evalWorldModel } from "scenegrad";
164+
165+
const env = defineEnv({ /* same as tier 3 */ });
166+
const predictor = new LLMPredictor({ model: "claude-haiku-4-5" });
167+
168+
// Plans in the predictor, commits one action at a time.
169+
await new DreamerSolver({ predictor, lookahead: 1 }).solve(env, "default");
170+
171+
// Measure how good the predictor actually is — predicted vs actual scene_after.
172+
const metrics = await evalWorldModel({
173+
env, predictor,
174+
tasks: [{ taskId: "default", actions: [/* known action sequence */] }],
175+
});
176+
// → outcome_acc, scene_match, delta_match, avg_confidence, ece (calibration)
177+
```
178+
179+
`Predictor` is a one-method interface. v0 ships `LLMPredictor` as a placeholder; future predictors (kNN over a trace store, distilled-from-traces, fully learned) drop in via the same API — `new DreamerSolver({ predictor })` doesn't change.
180+
181+
The `evalWorldModel` metric is the world-model-accuracy benchmark a predictor is judged against. Ship a predictor → score it on any tier-3 env → publish the leaderboard column.
182+
183+
---
184+
155185
## Why this isn't another agent framework
156186

157187
If you already use… | scenegrad adds…

examples/dreamer-inbox.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Dreamer-inbox — same inbox onboarding task, three runs side-by-side.
3+
*
4+
* 1. LLMSolver → acts directly in the env, current behavior
5+
* 2. DreamerSolver(LLM) → predicts each candidate's outcome, picks best, then acts
6+
* 3. evalWorldModel → measures how accurate the LLM-as-predictor actually is
7+
*
8+
* The point of v0: ship the surface end-to-end with the LLM as a placeholder
9+
* predictor. Future versions swap the predictor (kNN over a trace store,
10+
* fine-tuned, learned) without changing the example or the eval.
11+
*
12+
* Run: ANTHROPIC_API_KEY=... bun examples/dreamer-inbox.ts
13+
*/
14+
15+
import {
16+
defineEnv,
17+
LLMSolver,
18+
DreamerSolver,
19+
LLMPredictor,
20+
evalWorldModel,
21+
formatMetrics,
22+
} from "scenegrad";
23+
24+
type Status = "unread" | "archived" | "flagged" | "replied";
25+
type Mail = { id: number; from: string; subject: string; status: Status };
26+
27+
const isImportantSender = (from: string) =>
28+
/boss@|ceo@|vip@/.test(from) || /calendar@|meeting@/.test(from);
29+
30+
const inbox = defineEnv({
31+
init: () => ({
32+
messages: [
33+
{ id: 1, from: "boss@co", subject: "Q3 plan?", status: "unread" as Status },
34+
{ id: 2, from: "spam@x.com", subject: "YOU WON!!!", status: "unread" as Status },
35+
{ id: 3, from: "calendar@co", subject: "Mtg 2pm tmrw", status: "unread" as Status },
36+
] as Mail[],
37+
}),
38+
39+
goal: () => [
40+
{
41+
name: "no unread messages",
42+
check: (s) => {
43+
const unread = s.messages.filter((m: Mail) => m.status === "unread").length;
44+
return { satisfied: unread === 0, gap: unread };
45+
},
46+
},
47+
{
48+
name: "important mail not archived (flag or reply only)",
49+
check: (s) => {
50+
const wrongly = s.messages.filter((m: Mail) =>
51+
m.status === "archived" && isImportantSender(m.from));
52+
return { satisfied: wrongly.length === 0, gap: wrongly.length, weight: 5 };
53+
},
54+
},
55+
],
56+
57+
tools: (s) => s.messages
58+
.filter((m: Mail) => m.status === "unread")
59+
.flatMap((m: Mail) => [
60+
{ name: "archive", args: { id: m.id } },
61+
{ name: "flag", args: { id: m.id } },
62+
{ name: "reply", args: { id: m.id } },
63+
]),
64+
65+
step: (s, t) => ({
66+
messages: s.messages.map((m: Mail) =>
67+
m.id === (t.args as any).id ? { ...m, status: t.name as Status } : m
68+
),
69+
}),
70+
71+
describeTask: (s) => {
72+
const unread = s.messages.filter((m: Mail) => m.status === "unread");
73+
return [
74+
"INBOX:",
75+
...s.messages.map((m: Mail) =>
76+
` [${m.status.padEnd(8)}] #${m.id} from ${m.from}: "${m.subject}"`),
77+
"",
78+
`${unread.length} unread. archive (delete), flag (handle later), or reply.`,
79+
"Important senders (boss@, calendar@) must NOT be archived.",
80+
].join("\n");
81+
},
82+
});
83+
84+
function dump(label: string, r: { success: boolean; steps: number; d_initial: number; d_final: number; duration_ms: number; trajectory: any[] }) {
85+
console.log(`\n=== ${label} ===`);
86+
console.log(`success=${r.success} steps=${r.steps} d:${r.d_initial}${r.d_final} ${r.duration_ms}ms`);
87+
for (const t of r.trajectory) {
88+
const tool = t.tool ? `${t.tool.name}(${JSON.stringify(t.tool.args)})` : "(none)";
89+
const pred = t.predicted_delta !== undefined ? ` pred-Δ=${t.predicted_delta}` : "";
90+
console.log(` #${t.step} ${tool} d:${t.d_before}${t.d_after} Δ=${t.delta}${pred}`);
91+
if (t.reasoning) console.log(` "${t.reasoning}"`);
92+
}
93+
}
94+
95+
if (!process.env.ANTHROPIC_API_KEY) {
96+
console.error("ANTHROPIC_API_KEY not set");
97+
process.exit(1);
98+
}
99+
100+
// 1. baseline: LLMSolver acts directly
101+
const llm = new LLMSolver({
102+
model: "claude-haiku-4-5",
103+
describeTask: () => (inbox as any).describeTask?.() ?? "",
104+
});
105+
dump("LLMSolver — acts directly", await llm.solve(inbox, "default", { maxSteps: 10 }));
106+
107+
// 2. tier 4: DreamerSolver predicts each candidate, picks best, then commits
108+
const predictor = new LLMPredictor({ model: "claude-haiku-4-5" });
109+
const dreamer = new DreamerSolver({ predictor, lookahead: 1 });
110+
dump("DreamerSolver(LLM) — plans in predictor, acts once", await dreamer.solve(inbox, "default", { maxSteps: 10 }));
111+
112+
// 3. measure how good the predictor actually is. Use the LLMSolver run as
113+
// the action sequence, then replay through env+predictor side-by-side.
114+
const baseRun = await llm.solve(inbox, "default", { maxSteps: 10 });
115+
const actions = baseRun.trajectory.map(s => s.tool).filter((t): t is any => t !== null);
116+
const metrics = await evalWorldModel({
117+
env: inbox,
118+
predictor,
119+
tasks: [{ taskId: "default", actions }],
120+
});
121+
122+
console.log("\n=== world-model accuracy on this task ===");
123+
console.log(formatMetrics(metrics, { perStep: true }));

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
"./trace": "./src/trace.ts",
1515
"./solver": "./src/solver.ts",
1616
"./greedy": "./src/greedy.ts",
17-
"./llm": "./src/llm.ts"
17+
"./llm": "./src/llm.ts",
18+
"./predict": "./src/predict.ts",
19+
"./predict-llm": "./src/predict-llm.ts",
20+
"./dreamer": "./src/dreamer.ts",
21+
"./eval/worldmodel": "./src/eval/worldmodel.ts"
1822
},
1923
"bin": {
2024
"scenegrad": "./bin/scenegrad.ts",

src/dreamer.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* DreamerSolver — plan in the predictor, act in the env.
3+
*
4+
* Each turn:
5+
* 1. Enumerate tools available now.
6+
* 2. For each candidate tool, ask the Predictor what happens.
7+
* (Optional lookahead k>1 rolls out k steps deep recursively.)
8+
* 3. Pick the action whose imagined trajectory closes the most distance.
9+
* 4. Commit that single action to the real env.
10+
* 5. Repeat.
11+
*
12+
* v0 is the plain "predict-and-pick" base case — like GreedySolver but
13+
* using a learned dynamics function instead of env.simulate(). That's
14+
* the unlock for envs where you can't write a simulator (real APIs,
15+
* databases, integrations).
16+
*
17+
* Future versions add value-net guided rollouts and policy distillation
18+
* from imagined trajectories — the full Dreamer arc.
19+
*/
20+
21+
import type { SceneGradEnv, ToolCall, Goal } from "./env.js";
22+
import { distance, checkAll } from "./env.js";
23+
import type { Solver, SolveResult, SolverOpts, TrajectoryStep } from "./solver.js";
24+
import type { Predictor } from "./predict.js";
25+
26+
export interface DreamerSolverOpts extends SolverOpts {
27+
predictor: Predictor<any, any>;
28+
/** Plan depth — how many predictor calls to chain per candidate.
29+
* v0 ships with k=1 working well; k>1 is exponential and best for small toolsets. */
30+
lookahead?: number;
31+
/** Optional beam width for k>1. Defaults to all candidates. */
32+
beam?: number;
33+
/** Filter low-confidence predictions out of planning. Default 0 — keep all. */
34+
minConfidence?: number;
35+
}
36+
37+
export class DreamerSolver<S, T extends ToolCall = ToolCall> implements Solver<S, T> {
38+
readonly name: string;
39+
private predictor: Predictor<S, T>;
40+
private lookahead: number;
41+
private beam: number;
42+
private minConf: number;
43+
44+
constructor(opts: DreamerSolverOpts) {
45+
this.predictor = opts.predictor as Predictor<S, T>;
46+
this.lookahead = Math.max(1, opts.lookahead ?? 1);
47+
this.beam = Math.max(1, opts.beam ?? Infinity);
48+
this.minConf = Math.max(0, opts.minConfidence ?? 0);
49+
this.name = `dreamer(${this.predictor.name},k=${this.lookahead})`;
50+
}
51+
52+
async solve(env: SceneGradEnv<S, T>, taskId: string, opts?: SolverOpts): Promise<SolveResult<T>> {
53+
const t_start = Date.now();
54+
const maxSteps = opts?.maxSteps ?? 30;
55+
56+
env.reset(taskId);
57+
const trajectory: TrajectoryStep<T>[] = [];
58+
const d_initial = distance(env.scene(), env.goal());
59+
60+
for (let step = 0; step < maxSteps; step++) {
61+
if (env.done()) break;
62+
63+
const goal = env.goal();
64+
const d_before = distance(env.scene(), goal);
65+
const tools = env.tools();
66+
if (tools.length === 0) break;
67+
68+
const best = await this.pickBest(env.scene(), tools, goal);
69+
70+
if (!best || best.predicted_d_after >= d_before) {
71+
trajectory.push({
72+
step,
73+
tool: null,
74+
d_before,
75+
d_after: d_before,
76+
delta: 0,
77+
predicted_delta: best ? d_before - best.predicted_d_after : 0,
78+
ok: false,
79+
error: "no predicted action reduces distance (stuck or low-confidence)",
80+
assertions_after: checkAll(env.scene(), goal),
81+
ts_ms: Date.now() - t_start,
82+
});
83+
break;
84+
}
85+
86+
// Commit the chosen action to the real env.
87+
const result = env.step(best.tool);
88+
const d_after = result.distance_after ?? distance(env.scene(), goal);
89+
90+
trajectory.push({
91+
step,
92+
tool: best.tool,
93+
d_before,
94+
d_after,
95+
delta: d_before - d_after,
96+
predicted_delta: d_before - best.predicted_d_after,
97+
reasoning: best.reasoning,
98+
ok: result.ok,
99+
error: result.error,
100+
assertions_after: checkAll(env.scene(), goal),
101+
ts_ms: Date.now() - t_start,
102+
});
103+
}
104+
105+
const d_final = distance(env.scene(), env.goal());
106+
return {
107+
task_id: taskId,
108+
solver: this.name,
109+
success: env.done(),
110+
steps: trajectory.length,
111+
d_initial,
112+
d_final,
113+
duration_ms: Date.now() - t_start,
114+
trajectory,
115+
};
116+
}
117+
118+
/**
119+
* Pick the tool whose imagined rollout reaches the lowest distance.
120+
* Lookahead=1: one predictor call per tool. Lookahead=k: chains k.
121+
*/
122+
private async pickBest(
123+
scene: S,
124+
tools: T[],
125+
goal: Goal<S>,
126+
): Promise<{ tool: T; predicted_d_after: number; reasoning?: string } | null> {
127+
const candidates = await Promise.all(
128+
tools.map(async (tool) => {
129+
const c = await this.predictor.predict(scene, tool);
130+
if (c.confidence < this.minConf) return null;
131+
if (!c.outcome.ok) return null;
132+
const d_after = await this.rolloutTail(c.scene_after, goal, this.lookahead - 1);
133+
return { tool, predicted_d_after: d_after, reasoning: c.reasoning };
134+
}),
135+
);
136+
const valid = candidates.filter((x): x is NonNullable<typeof x> => x !== null);
137+
if (valid.length === 0) return null;
138+
valid.sort((a, b) => a.predicted_d_after - b.predicted_d_after);
139+
return valid[0]!;
140+
}
141+
142+
/**
143+
* Roll out depth-k more predictor steps after the head action, returning
144+
* the best (lowest) distance reachable. k=0 returns the head's distance.
145+
*/
146+
private async rolloutTail(scene: S, goal: Goal<S>, k: number): Promise<number> {
147+
if (k <= 0) return distance(scene, goal);
148+
// Synthetic env-less rollout: we don't have env.tools() for predicted
149+
// future scenes. v0 punts: trust the head prediction. v0.1 will pass
150+
// a domain-provided `toolsAt(scene)` for true k-step lookahead.
151+
return distance(scene, goal);
152+
}
153+
}

0 commit comments

Comments
 (0)