Node-Gen

generate a Node subclass from a description.

📄 Docs only

Tags: 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 one Node subclass, import only simvx.core and math, and do nothing at top level. A pass is status == "unverified". This example prints the source, saves it and stops.

  • generate_sync(..., allow_execution=True) (the --allow-exec flag) 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 with load_scene and 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())