Node-Gen¶
generate a Node subclass from a description.
📄 Docs onlyTags: ai llm codegen
simvx.ai.generate_sync(client, description) asks a model for a scene module, a .py defining
exactly one Node subclass, and checks the reply before anything runs it. A rejected candidate goes
back to the model with the reason, up to a fixed number of attempts. The answer is a
GenerationResult: status, source, node_name, attempts and the errors of the rejected tries.
Generating a node means executing model output, so execution is opt-in:
By default only the static gate runs: the source is parsed with
ast, never imported, and must hold oneNodesubclass, import onlysimvx.coreandmath, and do nothing at top level. A pass isstatus == "unverified". This example prints the source, saves it and stops.generate_sync(..., allow_execution=True)(the--allow-execflag) also runs the candidate for a few frames in a separate, CPU-capped process. Only a candidate that survives is"verified", and only then does this example load it into its own process withload_sceneand run it in a window. The subprocess contains a crash or a hang; it does not stop network or file access, so review generated code before shipping it.
With no endpoint the example uses CannedClient, an LLMClient that answers every request with the
Spinner module in CANNED_SPINNER, so the whole path runs offline. --live generates with the
OpenAI-compatible endpoint named by SIMVX_LLM_BASE_URL, SIMVX_LLM_MODEL and, if it needs one,
SIMVX_LLM_API_KEY, through a CachingClient so a repeated request replays the recorded answer.
--describe "a label that pulses its scale" sets the description.
The cache and the saved scene live under $XDG_CACHE_HOME/simvx/nodegen/ (~/.cache when unset),
or $NODEGEN_CACHE_DIR when that is set.
Source¶
1# /// simvx
2# tags = ["ai", "llm", "codegen"]
3# web = { disabled = true }
4# ///
5"""Node-Gen: generate a Node subclass from a description.
6
7`simvx.ai.generate_sync(client, description)` asks a model for a scene module, a `.py` defining
8exactly one `Node` subclass, and checks the reply before anything runs it. A rejected candidate goes
9back to the model with the reason, up to a fixed number of attempts. The answer is a
10`GenerationResult`: `status`, `source`, `node_name`, `attempts` and the `errors` of the rejected tries.
11
12Generating a node means executing model output, so execution is opt-in:
13
14- By default only the static gate runs: the source is parsed with `ast`, never imported, and must
15 hold one `Node` subclass, import only `simvx.core` and `math`, and do nothing at top level. A
16 pass is `status == "unverified"`. This example prints the source, saves it and stops.
17- `generate_sync(..., allow_execution=True)` (the `--allow-exec` flag) also runs the candidate for a
18 few frames in a separate, CPU-capped process. Only a candidate that survives is `"verified"`, and
19 only then does this example load it into its own process with `load_scene` and run it in a window.
20 The subprocess contains a crash or a hang; it does not stop network or file access, so review
21 generated code before shipping it.
22
23With no endpoint the example uses `CannedClient`, an `LLMClient` that answers every request with the
24`Spinner` module in `CANNED_SPINNER`, so the whole path runs offline. `--live` generates with the
25OpenAI-compatible endpoint named by `SIMVX_LLM_BASE_URL`, `SIMVX_LLM_MODEL` and, if it needs one,
26`SIMVX_LLM_API_KEY`, through a `CachingClient` so a repeated request replays the recorded answer.
27`--describe "a label that pulses its scale"` sets the description.
28
29The cache and the saved scene live under `$XDG_CACHE_HOME/simvx/nodegen/` (`~/.cache` when unset),
30or `$NODEGEN_CACHE_DIR` when that is set.
31"""
32
33import argparse
34import os
35import sys
36from pathlib import Path
37
38from simvx.ai import CachingClient, LLMClient, LLMResponse, OpenAICompatibleClient, generate_sync
39from simvx.core import Anchor, Label, Node2D
40from simvx.core.scene_io import load_scene
41from simvx.graphics import App
42
43CACHE_DIR = Path(
44 os.environ.get("NODEGEN_CACHE_DIR")
45 or Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "simvx" / "nodegen"
46)
47
48# What the offline client "generates": a valid module that draws in its own space, so the node's
49# rotation turns the drawing.
50CANNED_SPINNER = """from simvx.core import Node2D, Property
51
52
53class Spinner(Node2D):
54 speed = Property(2.0, range=(0.0, 10.0))
55
56 def on_update(self, dt):
57 self.rotation += self.speed * dt
58
59 def on_draw(self, renderer):
60 renderer.polygon([(120, 0), (0, 120), (-120, 0), (0, -120)], stroke=(0.35, 0.8, 1.0, 1.0))
61 renderer.line((0, 0), (120, 0), stroke=(1.0, 0.85, 0.3, 1.0), width=3.0)
62 renderer.circle((0, 0), 10.0, fill=(1.0, 0.85, 0.3, 1.0))
63"""
64
65
66class CannedClient(LLMClient):
67 """Stands in for a model: answers every request with CANNED_SPINNER."""
68
69 async def complete(self, messages, **kwargs) -> LLMResponse:
70 return LLMResponse(text=CANNED_SPINNER)
71
72
73class GeneratedScene(Node2D):
74 """Loads a verified scene file, keeps its node centred and captions it."""
75
76 def __init__(self, scene_path: Path, **kwargs):
77 super().__init__(**kwargs)
78 self.scene_path = scene_path
79
80 def on_ready(self):
81 self.generated = self.add_child(load_scene(self.scene_path))
82 name = type(self.generated).__name__
83 self.add_child(Label(f"Generated and verified: {name}", anchor=Anchor.TOP_WIDE, inset=16))
84 self.add_child(Label(f"Loaded from {self.scene_path}", anchor=Anchor.BOTTOM_WIDE, inset=16))
85
86 def on_update(self, dt):
87 self.generated.position = self.viewport_size / 2
88
89
90def main() -> int:
91 parser = argparse.ArgumentParser(description="Generate a SimVX Node subclass from a description.")
92 parser.add_argument("--describe", default="a node that spins", help="what the node should do")
93 parser.add_argument("--allow-exec", action="store_true", help="test the candidate in a subprocess, then run it")
94 parser.add_argument("--live", action="store_true", help="generate with the SIMVX_LLM_* endpoint")
95 args = parser.parse_args()
96
97 client = CachingClient(OpenAICompatibleClient.from_env(), CACHE_DIR) if args.live else CannedClient()
98 result = generate_sync(client, args.describe, allow_execution=args.allow_exec)
99 print(f"status={result.status} node={result.node_name!r} attempts={result.attempts}")
100 for error in result.errors:
101 print(f"rejected: {error.splitlines()[0]}")
102 print(result.source)
103 if not result.ok:
104 return 1
105
106 scene_path = CACHE_DIR / "generated_scene.py"
107 scene_path.parent.mkdir(parents=True, exist_ok=True)
108 scene_path.write_text(result.source, encoding="utf-8")
109 print(f"Saved to {scene_path}")
110 if result.status != "verified":
111 print("Not executed. Pass --allow-exec to test it in a subprocess and then run it.")
112 return 0
113
114 App(title=f"Node-Gen: {result.node_name}", width=1280, height=720).run(GeneratedScene(scene_path))
115 return 0
116
117
118if __name__ == "__main__":
119 sys.exit(main())