Srishti280992 commited on
Commit
d7204a9
·
verified ·
1 Parent(s): a03e95c

Upload 4 files

Browse files
Files changed (4) hide show
  1. callbacks.py +1251 -0
  2. main.py +739 -0
  3. packages.txt +1 -0
  4. requirements.txt +6 -0
callbacks.py ADDED
@@ -0,0 +1,1251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Root-level Gradio callback bridge for WorldSmithAI.
3
+
4
+ This module is designed to sit beside ``app.py`` in a Hugging Face Space. It
5
+ does not import Gradio and does not assume an ``app/`` package or folder.
6
+
7
+ Responsibilities:
8
+ - Convert prompts into WorldSpec DSL.
9
+ - Parse and validate DSL JSON.
10
+ - Construct runtime World objects.
11
+ - Run deterministic simulations.
12
+ - Generate animation, charts, final render, metrics, and narrative.
13
+ - Return simple values that root-level ``app.py`` can bind to Gradio outputs.
14
+
15
+ Default callback output order:
16
+ 1. world_spec_json
17
+ 2. animation_path
18
+ 3. population_chart_path
19
+ 4. resource_chart_path
20
+ 5. final_image_path
21
+ 6. narrative
22
+ 7. metrics_json
23
+ 8. status_message
24
+
25
+ Example app.py:
26
+ import gradio as gr
27
+ from callbacks import run_worldsmith_callback
28
+
29
+ with gr.Blocks() as demo:
30
+ prompt = gr.Textbox(label="World prompt")
31
+ steps = gr.Slider(1, 200, value=60, step=1)
32
+ run = gr.Button("Run")
33
+
34
+ dsl = gr.Code(language="json")
35
+ animation = gr.Image()
36
+ population = gr.Image()
37
+ resources = gr.Image()
38
+ final_state = gr.Image()
39
+ narrative = gr.Markdown()
40
+ metrics = gr.Code(language="json")
41
+ status = gr.Textbox()
42
+
43
+ run.click(
44
+ run_worldsmith_callback,
45
+ inputs=[prompt, steps],
46
+ outputs=[dsl, animation, population, resources, final_state, narrative, metrics, status],
47
+ )
48
+
49
+ demo.launch()
50
+
51
+ Future extensibility:
52
+ - Add progress-yielding generator callbacks for Gradio progress bars.
53
+ - Add user-selectable SLM backends.
54
+ - Add example-world dropdown support.
55
+ - Add multi-run comparison callbacks.
56
+ - Add downloadable artifact bundles.
57
+ - Add persistent run logs for demo evaluation.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import copy
63
+ import json
64
+ import logging
65
+ import math
66
+ import tempfile
67
+ from collections.abc import Callable, Mapping, MutableSequence, Sequence
68
+ from dataclasses import dataclass, field
69
+ from enum import Enum
70
+ from numbers import Real
71
+ from pathlib import Path
72
+ from typing import Any
73
+
74
+ from dsl.parser import DSLParseError, parse_world_file, parse_world_spec
75
+ from dsl.schema import WorldSpec
76
+ from dsl.validator import ValidationConfig, ValidationReport, ValidationSeverity, validate_world_spec
77
+ from factory.world_factory import (
78
+ WorldBuildResult,
79
+ WorldFactory,
80
+ WorldFactoryConfig,
81
+ WorldFactoryValidationError,
82
+ )
83
+ from llm.narrator import NarrationAudience, NarrationMode, NarrationStyle, NarratorConfig, WorldNarrator
84
+ from llm.world_generator import (
85
+ GenerationMode,
86
+ WorldGenerationConfig,
87
+ WorldGenerationResult,
88
+ WorldGenerator,
89
+ )
90
+ from metrics.diversity import compute_agent_type_diversity, compute_resource_type_diversity
91
+ from metrics.entropy import compute_agent_type_entropy, compute_resource_type_entropy
92
+ from metrics.interestingness import InterestingnessTracker
93
+ from metrics.stability import StabilityMetric, StabilityTracker
94
+ from visualization.animation import (
95
+ AnimationConfig,
96
+ AnimationFormat,
97
+ AnimationFrame,
98
+ AnimationWriterError,
99
+ WorldAnimator,
100
+ normalize_animation_format,
101
+ )
102
+ from visualization.charts import (
103
+ ChartConfig,
104
+ ChartTracker,
105
+ WorldChartRenderer,
106
+ population_chart_config,
107
+ resource_chart_config,
108
+ )
109
+ from visualization.renderer import (
110
+ RendererConfig,
111
+ WorldRenderer,
112
+ close_figure,
113
+ figure_to_rgb_array,
114
+ )
115
+
116
+ logger = logging.getLogger(__name__)
117
+
118
+ DEFAULT_GRADIO_OUTPUT_FIELDS: tuple[str, ...] = (
119
+ "world_spec_json",
120
+ "animation_path",
121
+ "population_chart_path",
122
+ "resource_chart_path",
123
+ "final_image_path",
124
+ "narrative",
125
+ "metrics_json",
126
+ "status_message",
127
+ )
128
+
129
+ DEFAULT_OUTPUT_SUBDIR_PREFIX = "worldsmithai_run_"
130
+
131
+
132
+ class CallbackMode(str, Enum):
133
+ """Supported callback entry modes."""
134
+
135
+ PROMPT = "prompt"
136
+ DSL = "dsl"
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class CallbackArtifacts:
141
+ """File artifacts produced by a callback run."""
142
+
143
+ output_dir: str
144
+ animation_path: str | None = None
145
+ population_chart_path: str | None = None
146
+ resource_chart_path: str | None = None
147
+ final_image_path: str | None = None
148
+
149
+ def to_dict(self) -> dict[str, Any]:
150
+ """Return a JSON-friendly artifact dictionary."""
151
+
152
+ return {
153
+ "output_dir": self.output_dir,
154
+ "animation_path": self.animation_path,
155
+ "population_chart_path": self.population_chart_path,
156
+ "resource_chart_path": self.resource_chart_path,
157
+ "final_image_path": self.final_image_path,
158
+ }
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class CallbackResult:
163
+ """Result returned by high-level callback pipelines."""
164
+
165
+ success: bool
166
+ status_message: str
167
+ world_spec_json: str = ""
168
+ validation_json: str = "{}"
169
+ metrics_json: str = "{}"
170
+ narrative: str = ""
171
+ animation_path: str | None = None
172
+ population_chart_path: str | None = None
173
+ resource_chart_path: str | None = None
174
+ final_image_path: str | None = None
175
+ artifacts: CallbackArtifacts | None = None
176
+ metadata: Mapping[str, Any] = field(default_factory=dict)
177
+
178
+ def as_gradio_tuple(
179
+ self,
180
+ fields: Sequence[str] = DEFAULT_GRADIO_OUTPUT_FIELDS,
181
+ ) -> tuple[Any, ...]:
182
+ """Return a tuple matching a Gradio output component order."""
183
+
184
+ values: list[Any] = []
185
+
186
+ for field_name in fields:
187
+ if not hasattr(self, field_name):
188
+ raise AttributeError(f"CallbackResult has no field {field_name!r}")
189
+ values.append(getattr(self, field_name))
190
+
191
+ return tuple(values)
192
+
193
+ def to_dict(self) -> dict[str, Any]:
194
+ """Return a JSON-friendly result dictionary."""
195
+
196
+ return {
197
+ "success": self.success,
198
+ "status_message": self.status_message,
199
+ "world_spec_json": self.world_spec_json,
200
+ "validation_json": self.validation_json,
201
+ "metrics_json": self.metrics_json,
202
+ "narrative": self.narrative,
203
+ "animation_path": self.animation_path,
204
+ "population_chart_path": self.population_chart_path,
205
+ "resource_chart_path": self.resource_chart_path,
206
+ "final_image_path": self.final_image_path,
207
+ "artifacts": None if self.artifacts is None else self.artifacts.to_dict(),
208
+ "metadata": _json_safe(copy.deepcopy(dict(self.metadata))),
209
+ }
210
+
211
+
212
+ @dataclass
213
+ class CallbackConfig:
214
+ """Configuration for root-level Gradio callbacks.
215
+
216
+ Defaults favor a robust demo:
217
+ - deterministic fallback world generation is enabled,
218
+ - unknown behavior/policy names become validation warnings instead of UI crashes,
219
+ - GIF animation is used by default,
220
+ - charts and narrative are always attempted.
221
+ """
222
+
223
+ output_dir: str | Path | None = None
224
+
225
+ steps: int | None = None
226
+ max_animation_frames: int = 80
227
+ animation_format: AnimationFormat | str = AnimationFormat.GIF
228
+ fps: float = 8.0
229
+
230
+ generate_animation: bool = True
231
+ generate_charts: bool = True
232
+ generate_final_image: bool = True
233
+ generate_narrative: bool = True
234
+ collect_metric_history: bool = True
235
+
236
+ fallback_to_gif_on_animation_error: bool = True
237
+
238
+ renderer_config: RendererConfig = field(default_factory=RendererConfig)
239
+ population_chart_config: ChartConfig = field(default_factory=population_chart_config)
240
+ resource_chart_config: ChartConfig = field(default_factory=resource_chart_config)
241
+
242
+ world_generation_config: WorldGenerationConfig | None = None
243
+ world_factory_config: WorldFactoryConfig | None = None
244
+ narrator_config: NarratorConfig | None = None
245
+
246
+ validation_config: ValidationConfig | None = None
247
+ strict_validation_for_app: bool = False
248
+
249
+ include_validation_json: bool = True
250
+ include_generation_diagnostics: bool = True
251
+
252
+ metadata: Mapping[str, Any] = field(default_factory=dict)
253
+
254
+ def resolved_animation_format(self) -> AnimationFormat:
255
+ """Return normalized animation format."""
256
+
257
+ return normalize_animation_format(self.animation_format)
258
+
259
+ def resolved_output_dir(self) -> Path:
260
+ """Return an existing directory for callback artifacts."""
261
+
262
+ if self.output_dir is not None:
263
+ path = Path(self.output_dir)
264
+ path.mkdir(parents=True, exist_ok=True)
265
+ return path
266
+
267
+ path = Path(tempfile.mkdtemp(prefix=DEFAULT_OUTPUT_SUBDIR_PREFIX))
268
+ path.mkdir(parents=True, exist_ok=True)
269
+ return path
270
+
271
+ def resolved_generation_config(self) -> WorldGenerationConfig:
272
+ """Return generation config."""
273
+
274
+ if self.world_generation_config is not None:
275
+ return self.world_generation_config
276
+
277
+ return WorldGenerationConfig(
278
+ mode=GenerationMode.AUTO,
279
+ default_steps=max(1, int(self.steps or 60)),
280
+ semantic_validation=True,
281
+ strict_semantic_validation=False,
282
+ fallback_on_model_error=True,
283
+ fallback_on_parse_error=True,
284
+ fallback_on_semantic_error=False,
285
+ )
286
+
287
+ def resolved_factory_config(self) -> WorldFactoryConfig:
288
+ """Return world factory config."""
289
+
290
+ if self.world_factory_config is not None:
291
+ return self.world_factory_config
292
+
293
+ return WorldFactoryConfig(
294
+ validate_before_build=True,
295
+ strict_unknown_behaviors=False,
296
+ strict_unknown_policies=False,
297
+ strict_constructor_params=False,
298
+ include_disabled_behaviors=False,
299
+ include_disabled_events=False,
300
+ attach_default_policy=True,
301
+ default_policy_type="rule_policy",
302
+ attach_scheduler=True,
303
+ attach_dsl_spec_to_world=True,
304
+ )
305
+
306
+ def resolved_narrator_config(self) -> NarratorConfig:
307
+ """Return narrator config."""
308
+
309
+ if self.narrator_config is not None:
310
+ return self.narrator_config
311
+
312
+ return NarratorConfig(
313
+ mode=NarrationMode.DETERMINISTIC,
314
+ style=NarrationStyle.ANALYTICAL,
315
+ audience=NarrationAudience.HACKATHON_JUDGE,
316
+ compute_default_metrics=True,
317
+ )
318
+
319
+ def resolved_validation_config(self) -> ValidationConfig:
320
+ """Return semantic validation config."""
321
+
322
+ if self.validation_config is not None:
323
+ return self.validation_config
324
+
325
+ severity = (
326
+ ValidationSeverity.ERROR
327
+ if self.strict_validation_for_app
328
+ else ValidationSeverity.WARNING
329
+ )
330
+
331
+ return ValidationConfig(
332
+ require_known_behaviors=True,
333
+ require_known_policies=True,
334
+ validate_constructor_params=True,
335
+ validate_references=True,
336
+ unknown_registry_item_severity=severity,
337
+ constructor_param_severity=severity,
338
+ unresolved_reference_severity=severity,
339
+ )
340
+
341
+
342
+ @dataclass(frozen=True)
343
+ class SimulationRunData:
344
+ """Internal structured result of a runtime simulation."""
345
+
346
+ final_world: Any
347
+ artifacts: CallbackArtifacts
348
+ metrics: Mapping[str, Any]
349
+ timeline: tuple[Mapping[str, Any], ...]
350
+ population_snapshots: tuple[Any, ...]
351
+ resource_snapshots: tuple[Any, ...]
352
+
353
+
354
+ def run_worldsmith_callback(
355
+ prompt: str,
356
+ steps: int | float | None = None,
357
+ constraints: str | Mapping[str, Any] | None = None,
358
+ animation_format: str = "gif",
359
+ client: Any | None = None,
360
+ ) -> tuple[Any, ...]:
361
+ """Gradio-friendly prompt-to-simulation callback.
362
+
363
+ Default return order:
364
+ 1. world_spec_json
365
+ 2. animation_path
366
+ 3. population_chart_path
367
+ 4. resource_chart_path
368
+ 5. final_image_path
369
+ 6. narrative
370
+ 7. metrics_json
371
+ 8. status_message
372
+
373
+ This function catches exceptions and returns a failure tuple instead of
374
+ crashing the UI.
375
+ """
376
+
377
+ config = CallbackConfig(
378
+ steps=_coerce_optional_int(steps),
379
+ animation_format=animation_format,
380
+ )
381
+
382
+ try:
383
+ result = run_worldsmith_pipeline(
384
+ prompt=prompt,
385
+ constraints=constraints,
386
+ client=client,
387
+ config=config,
388
+ )
389
+ return result.as_gradio_tuple()
390
+ except Exception as exc:
391
+ logger.exception("WorldSmithAI callback failed")
392
+ return callback_failure_result(exc).as_gradio_tuple()
393
+
394
+
395
+ def run_worldsmith_pipeline(
396
+ *,
397
+ prompt: str,
398
+ constraints: str | Mapping[str, Any] | None = None,
399
+ client: Any | None = None,
400
+ config: CallbackConfig | None = None,
401
+ ) -> CallbackResult:
402
+ """Run the full prompt-to-world-to-artifacts pipeline.
403
+
404
+ This function raises exceptions. Use ``run_worldsmith_callback`` for a
405
+ Gradio-safe wrapper that catches errors.
406
+ """
407
+
408
+ callback_config = config or CallbackConfig()
409
+ parsed_constraints = parse_constraints(constraints)
410
+
411
+ generation_result = generate_world_from_prompt(
412
+ prompt=prompt,
413
+ constraints=parsed_constraints,
414
+ client=client,
415
+ config=callback_config,
416
+ )
417
+
418
+ world_spec = apply_step_override(generation_result.spec, callback_config.steps)
419
+ validation_report = validate_world_spec(
420
+ world_spec,
421
+ config=callback_config.resolved_validation_config(),
422
+ )
423
+
424
+ if callback_config.strict_validation_for_app and not validation_report.is_valid:
425
+ raise WorldFactoryValidationError(validation_report)
426
+
427
+ build_result = build_world_from_spec(world_spec, config=callback_config)
428
+ simulation = simulate_world_for_app(
429
+ build_result.world,
430
+ steps=world_spec.simulation.steps,
431
+ config=callback_config,
432
+ )
433
+
434
+ narrative = ""
435
+ if callback_config.generate_narrative:
436
+ narrative = narrate_world_for_app(
437
+ simulation.final_world,
438
+ timeline=simulation.timeline,
439
+ metrics=simulation.metrics,
440
+ client=client,
441
+ config=callback_config,
442
+ )
443
+
444
+ metrics_payload = {
445
+ "success": True,
446
+ "world": {
447
+ "id": world_spec.id,
448
+ "name": world_spec.name,
449
+ "description": world_spec.description,
450
+ "steps": world_spec.simulation.steps,
451
+ },
452
+ "generation": generation_result.to_dict()
453
+ if callback_config.include_generation_diagnostics
454
+ else {"mode": generation_result.mode.value},
455
+ "validation": validation_report.to_dict(),
456
+ "factory": build_result.report.to_dict(),
457
+ "artifacts": simulation.artifacts.to_dict(),
458
+ "metrics": _json_safe(simulation.metrics),
459
+ "timeline": _json_safe(simulation.timeline),
460
+ }
461
+
462
+ status = (
463
+ f"Built and simulated world {world_spec.id!r} for "
464
+ f"{world_spec.simulation.steps} step(s). "
465
+ f"Validation: {len(validation_report.errors)} error(s), "
466
+ f"{len(validation_report.warnings)} warning(s)."
467
+ )
468
+
469
+ return CallbackResult(
470
+ success=True,
471
+ status_message=status,
472
+ world_spec_json=world_spec.to_json_string(indent=2, exclude_none=True),
473
+ validation_json=_safe_json_dumps(validation_report.to_dict()),
474
+ metrics_json=_safe_json_dumps(metrics_payload),
475
+ narrative=narrative,
476
+ animation_path=simulation.artifacts.animation_path,
477
+ population_chart_path=simulation.artifacts.population_chart_path,
478
+ resource_chart_path=simulation.artifacts.resource_chart_path,
479
+ final_image_path=simulation.artifacts.final_image_path,
480
+ artifacts=simulation.artifacts,
481
+ metadata={
482
+ "mode": CallbackMode.PROMPT.value,
483
+ "output_dir": simulation.artifacts.output_dir,
484
+ },
485
+ )
486
+
487
+
488
+ def generate_dsl_callback(
489
+ prompt: str,
490
+ constraints: str | Mapping[str, Any] | None = None,
491
+ client: Any | None = None,
492
+ ) -> tuple[str, str, str]:
493
+ """Gradio-friendly callback that only generates and validates DSL JSON."""
494
+
495
+ try:
496
+ config = CallbackConfig()
497
+ parsed_constraints = parse_constraints(constraints)
498
+ generation_result = generate_world_from_prompt(
499
+ prompt=prompt,
500
+ constraints=parsed_constraints,
501
+ client=client,
502
+ config=config,
503
+ )
504
+ validation_report = validate_world_spec(
505
+ generation_result.spec,
506
+ config=config.resolved_validation_config(),
507
+ )
508
+
509
+ status = (
510
+ f"Generated WorldSpec {generation_result.spec.id!r}. "
511
+ f"Validation: {len(validation_report.errors)} error(s), "
512
+ f"{len(validation_report.warnings)} warning(s)."
513
+ )
514
+
515
+ return (
516
+ generation_result.spec.to_json_string(indent=2, exclude_none=True),
517
+ _safe_json_dumps(validation_report.to_dict()),
518
+ status,
519
+ )
520
+ except Exception as exc:
521
+ logger.exception("DSL generation callback failed")
522
+ return "", "{}", f"Generation failed: {exc}"
523
+
524
+
525
+ def validate_dsl_callback(dsl_json: str) -> tuple[str, str]:
526
+ """Gradio-friendly callback that validates DSL JSON."""
527
+
528
+ try:
529
+ spec = parse_world_spec(dsl_json)
530
+ report = validate_world_spec(
531
+ spec,
532
+ config=CallbackConfig().resolved_validation_config(),
533
+ )
534
+ status = (
535
+ f"Validation complete: {len(report.errors)} error(s), "
536
+ f"{len(report.warnings)} warning(s)."
537
+ )
538
+ return _safe_json_dumps(report.to_dict()), status
539
+ except Exception as exc:
540
+ logger.exception("DSL validation callback failed")
541
+ return "{}", f"Validation failed: {exc}"
542
+
543
+
544
+ def simulate_dsl_callback(
545
+ dsl_json: str,
546
+ steps: int | float | None = None,
547
+ animation_format: str = "gif",
548
+ client: Any | None = None,
549
+ ) -> tuple[Any, ...]:
550
+ """Gradio-friendly callback that simulates provided DSL JSON."""
551
+
552
+ config = CallbackConfig(
553
+ steps=_coerce_optional_int(steps),
554
+ animation_format=animation_format,
555
+ )
556
+
557
+ try:
558
+ result = simulate_dsl_pipeline(
559
+ dsl_json=dsl_json,
560
+ client=client,
561
+ config=config,
562
+ )
563
+ return result.as_gradio_tuple()
564
+ except Exception as exc:
565
+ logger.exception("DSL simulation callback failed")
566
+ return callback_failure_result(exc, world_spec_json=dsl_json).as_gradio_tuple()
567
+
568
+
569
+ def simulate_dsl_pipeline(
570
+ *,
571
+ dsl_json: str,
572
+ client: Any | None = None,
573
+ config: CallbackConfig | None = None,
574
+ ) -> CallbackResult:
575
+ """Parse, validate, build, and simulate a supplied DSL JSON string."""
576
+
577
+ callback_config = config or CallbackConfig()
578
+ world_spec = apply_step_override(parse_world_spec(dsl_json), callback_config.steps)
579
+
580
+ validation_report = validate_world_spec(
581
+ world_spec,
582
+ config=callback_config.resolved_validation_config(),
583
+ )
584
+
585
+ if callback_config.strict_validation_for_app and not validation_report.is_valid:
586
+ raise WorldFactoryValidationError(validation_report)
587
+
588
+ build_result = build_world_from_spec(world_spec, config=callback_config)
589
+ simulation = simulate_world_for_app(
590
+ build_result.world,
591
+ steps=world_spec.simulation.steps,
592
+ config=callback_config,
593
+ )
594
+
595
+ narrative = ""
596
+ if callback_config.generate_narrative:
597
+ narrative = narrate_world_for_app(
598
+ simulation.final_world,
599
+ timeline=simulation.timeline,
600
+ metrics=simulation.metrics,
601
+ client=client,
602
+ config=callback_config,
603
+ )
604
+
605
+ metrics_payload = {
606
+ "success": True,
607
+ "world": {
608
+ "id": world_spec.id,
609
+ "name": world_spec.name,
610
+ "description": world_spec.description,
611
+ "steps": world_spec.simulation.steps,
612
+ },
613
+ "validation": validation_report.to_dict(),
614
+ "factory": build_result.report.to_dict(),
615
+ "artifacts": simulation.artifacts.to_dict(),
616
+ "metrics": _json_safe(simulation.metrics),
617
+ "timeline": _json_safe(simulation.timeline),
618
+ }
619
+
620
+ status = (
621
+ f"Simulated supplied DSL world {world_spec.id!r} for "
622
+ f"{world_spec.simulation.steps} step(s)."
623
+ )
624
+
625
+ return CallbackResult(
626
+ success=True,
627
+ status_message=status,
628
+ world_spec_json=world_spec.to_json_string(indent=2, exclude_none=True),
629
+ validation_json=_safe_json_dumps(validation_report.to_dict()),
630
+ metrics_json=_safe_json_dumps(metrics_payload),
631
+ narrative=narrative,
632
+ animation_path=simulation.artifacts.animation_path,
633
+ population_chart_path=simulation.artifacts.population_chart_path,
634
+ resource_chart_path=simulation.artifacts.resource_chart_path,
635
+ final_image_path=simulation.artifacts.final_image_path,
636
+ artifacts=simulation.artifacts,
637
+ metadata={
638
+ "mode": CallbackMode.DSL.value,
639
+ "output_dir": simulation.artifacts.output_dir,
640
+ },
641
+ )
642
+
643
+
644
+ def load_example_callback(example_path: str | Path) -> tuple[str, str]:
645
+ """Load an example JSON file for a Gradio dropdown or button."""
646
+
647
+ try:
648
+ spec = parse_world_file(example_path)
649
+ return spec.to_json_string(indent=2, exclude_none=True), f"Loaded {example_path}"
650
+ except Exception as exc:
651
+ logger.exception("Could not load example world")
652
+ return "", f"Could not load example: {exc}"
653
+
654
+
655
+ def generate_world_from_prompt(
656
+ *,
657
+ prompt: str,
658
+ constraints: Mapping[str, Any] | str | None,
659
+ client: Any | None,
660
+ config: CallbackConfig,
661
+ ) -> WorldGenerationResult:
662
+ """Generate a ``WorldSpec`` from natural language."""
663
+
664
+ generation_config = config.resolved_generation_config()
665
+ if config.steps is not None:
666
+ generation_config.default_steps = max(1, int(config.steps))
667
+
668
+ generator = WorldGenerator(
669
+ client=client,
670
+ config=generation_config,
671
+ )
672
+ return generator.generate(
673
+ prompt,
674
+ constraints=constraints,
675
+ )
676
+
677
+
678
+ def build_world_from_spec(
679
+ spec: WorldSpec,
680
+ *,
681
+ config: CallbackConfig,
682
+ ) -> WorldBuildResult:
683
+ """Build a runtime world from a ``WorldSpec``."""
684
+
685
+ factory = WorldFactory(config=config.resolved_factory_config())
686
+ return factory.create_world_result(spec)
687
+
688
+
689
+ def simulate_world_for_app(
690
+ world: Any,
691
+ *,
692
+ steps: int,
693
+ config: CallbackConfig,
694
+ ) -> SimulationRunData:
695
+ """Run a deterministic simulation and create app artifacts.
696
+
697
+ The same simulation run is used for:
698
+ - animation frames,
699
+ - population chart snapshots,
700
+ - resource chart snapshots,
701
+ - metric history,
702
+ - final render,
703
+ - narrative context.
704
+ """
705
+
706
+ output_dir = config.resolved_output_dir()
707
+ safe_steps = max(0, int(steps))
708
+
709
+ renderer = WorldRenderer(config=config.renderer_config)
710
+
711
+ animator_config = AnimationConfig(
712
+ frame_count=max(1, min(config.max_animation_frames, safe_steps + 1)),
713
+ steps_per_frame=1,
714
+ format=config.resolved_animation_format(),
715
+ fps=float(config.fps),
716
+ output_dir=output_dir,
717
+ renderer_config=config.renderer_config,
718
+ )
719
+ animator = WorldAnimator(config=animator_config)
720
+
721
+ population_tracker = ChartTracker(
722
+ renderer=WorldChartRenderer(config=config.population_chart_config)
723
+ )
724
+ resource_tracker = ChartTracker(
725
+ renderer=WorldChartRenderer(config=config.resource_chart_config)
726
+ )
727
+
728
+ stability_tracker = StabilityTracker(
729
+ metric=StabilityMetric(collection="agents", group_by_path="type")
730
+ )
731
+ interestingness_tracker = InterestingnessTracker()
732
+
733
+ capture_interval = _capture_interval(
734
+ steps=safe_steps,
735
+ max_frames=max(1, int(config.max_animation_frames)),
736
+ )
737
+
738
+ frames: list[AnimationFrame] = []
739
+ metric_history: list[dict[str, Any]] = []
740
+ timeline: list[Mapping[str, Any]] = []
741
+
742
+ for step_index in range(safe_steps + 1):
743
+ if config.generate_charts:
744
+ population_tracker.update(world)
745
+ resource_tracker.update(world)
746
+
747
+ if config.collect_metric_history:
748
+ metrics_at_step = compute_metric_bundle(
749
+ world,
750
+ stability_tracker=stability_tracker,
751
+ interestingness_tracker=interestingness_tracker,
752
+ )
753
+ metric_history.append(
754
+ {
755
+ "step": _world_step(world),
756
+ "metrics": _compact_metric_history_entry(metrics_at_step),
757
+ }
758
+ )
759
+
760
+ timeline.append(_timeline_snapshot(world, index=step_index))
761
+
762
+ should_capture = (
763
+ config.generate_animation
764
+ and (
765
+ step_index == 0
766
+ or step_index == safe_steps
767
+ or step_index % capture_interval == 0
768
+ )
769
+ )
770
+ if should_capture:
771
+ frames.append(_capture_animation_frame(world, renderer, index=len(frames)))
772
+
773
+ if step_index < safe_steps:
774
+ animator.advance_world(world, steps=1)
775
+
776
+ final_metrics = compute_metric_bundle(
777
+ world,
778
+ stability_tracker=stability_tracker,
779
+ interestingness_tracker=interestingness_tracker,
780
+ )
781
+
782
+ animation_path: str | None = None
783
+ if config.generate_animation and frames:
784
+ animation_path = _write_animation_with_fallback(
785
+ animator=animator,
786
+ frames=frames,
787
+ output_dir=output_dir,
788
+ requested_format=config.resolved_animation_format(),
789
+ fallback_to_gif=config.fallback_to_gif_on_animation_error,
790
+ )
791
+
792
+ population_chart_path: str | None = None
793
+ resource_chart_path: str | None = None
794
+
795
+ if config.generate_charts:
796
+ population_chart_path = population_tracker.save_temp_png(output_dir=output_dir)
797
+ resource_chart_path = resource_tracker.save_temp_png(output_dir=output_dir)
798
+
799
+ final_image_path: str | None = None
800
+ if config.generate_final_image:
801
+ final_image_path = renderer.save_temp_png(world, output_dir=output_dir)
802
+
803
+ artifacts = CallbackArtifacts(
804
+ output_dir=str(output_dir),
805
+ animation_path=animation_path,
806
+ population_chart_path=population_chart_path,
807
+ resource_chart_path=resource_chart_path,
808
+ final_image_path=final_image_path,
809
+ )
810
+
811
+ metrics_payload = {
812
+ "final": _json_safe(final_metrics),
813
+ "history": _json_safe(metric_history),
814
+ }
815
+
816
+ return SimulationRunData(
817
+ final_world=world,
818
+ artifacts=artifacts,
819
+ metrics=metrics_payload,
820
+ timeline=tuple(timeline),
821
+ population_snapshots=tuple(population_tracker.snapshots),
822
+ resource_snapshots=tuple(resource_tracker.snapshots),
823
+ )
824
+
825
+
826
+ def compute_metric_bundle(
827
+ world: Any,
828
+ *,
829
+ stability_tracker: StabilityTracker | None = None,
830
+ interestingness_tracker: InterestingnessTracker | None = None,
831
+ ) -> dict[str, Any]:
832
+ """Compute default metrics for app display."""
833
+
834
+ bundle: dict[str, Any] = {}
835
+
836
+ try:
837
+ bundle["agent_diversity"] = compute_agent_type_diversity(world).to_dict()
838
+ except Exception as exc:
839
+ bundle["agent_diversity_error"] = str(exc)
840
+
841
+ try:
842
+ bundle["resource_diversity"] = compute_resource_type_diversity(
843
+ world,
844
+ weight_by_amount=True,
845
+ ).to_dict()
846
+ except Exception as exc:
847
+ bundle["resource_diversity_error"] = str(exc)
848
+
849
+ try:
850
+ bundle["agent_entropy"] = compute_agent_type_entropy(world).to_dict()
851
+ except Exception as exc:
852
+ bundle["agent_entropy_error"] = str(exc)
853
+
854
+ try:
855
+ bundle["resource_entropy"] = compute_resource_type_entropy(
856
+ world,
857
+ weight_by_amount=True,
858
+ ).to_dict()
859
+ except Exception as exc:
860
+ bundle["resource_entropy_error"] = str(exc)
861
+
862
+ try:
863
+ if stability_tracker is not None:
864
+ bundle["stability"] = stability_tracker.update(world).to_dict()
865
+ else:
866
+ bundle["stability"] = StabilityTracker().update(world).to_dict()
867
+ except Exception as exc:
868
+ bundle["stability_error"] = str(exc)
869
+
870
+ try:
871
+ if interestingness_tracker is not None:
872
+ bundle["interestingness"] = interestingness_tracker.update(world).to_dict()
873
+ else:
874
+ bundle["interestingness"] = InterestingnessTracker().update(world).to_dict()
875
+ except Exception as exc:
876
+ bundle["interestingness_error"] = str(exc)
877
+
878
+ return bundle
879
+
880
+
881
+ def narrate_world_for_app(
882
+ world: Any,
883
+ *,
884
+ timeline: Sequence[Mapping[str, Any]],
885
+ metrics: Mapping[str, Any],
886
+ client: Any | None,
887
+ config: CallbackConfig,
888
+ ) -> str:
889
+ """Create a narrative summary for app display."""
890
+
891
+ narrator = WorldNarrator(
892
+ client=client,
893
+ config=config.resolved_narrator_config(),
894
+ )
895
+ result = narrator.narrate(
896
+ world,
897
+ history=timeline,
898
+ metric_results=_metric_results_for_narrator(metrics),
899
+ extra_context={
900
+ "source": "callbacks.py",
901
+ "artifact_mode": "gradio_root_app",
902
+ },
903
+ )
904
+ return result.text
905
+
906
+
907
+ def parse_constraints(value: str | Mapping[str, Any] | None) -> Mapping[str, Any] | str | None:
908
+ """Parse optional UI constraints.
909
+
910
+ If the input is valid JSON, a mapping/list/scalar is returned from JSON.
911
+ If it is plain text, the text is returned so the LLM prompt can still use it.
912
+ """
913
+
914
+ if value is None:
915
+ return None
916
+
917
+ if isinstance(value, Mapping):
918
+ return copy.deepcopy(dict(value))
919
+
920
+ text = str(value).strip()
921
+ if not text:
922
+ return None
923
+
924
+ try:
925
+ parsed = json.loads(text)
926
+ except json.JSONDecodeError:
927
+ return text
928
+
929
+ if isinstance(parsed, Mapping):
930
+ return copy.deepcopy(dict(parsed))
931
+
932
+ return parsed
933
+
934
+
935
+ def apply_step_override(spec: WorldSpec, steps: int | None) -> WorldSpec:
936
+ """Return a copy of ``spec`` with simulation steps overridden when provided."""
937
+
938
+ if steps is None:
939
+ return spec
940
+
941
+ safe_steps = max(0, int(steps))
942
+ simulation = spec.simulation.model_copy(update={"steps": safe_steps})
943
+ return spec.model_copy(update={"simulation": simulation})
944
+
945
+
946
+ def callback_failure_result(
947
+ error: BaseException,
948
+ *,
949
+ world_spec_json: str = "",
950
+ ) -> CallbackResult:
951
+ """Return a Gradio-safe failure result."""
952
+
953
+ message = f"WorldSmithAI callback failed: {error.__class__.__name__}: {error}"
954
+ payload = {
955
+ "success": False,
956
+ "error_type": error.__class__.__name__,
957
+ "error": str(error),
958
+ }
959
+
960
+ if isinstance(error, WorldFactoryValidationError):
961
+ payload["validation_report"] = error.report.to_dict()
962
+
963
+ if isinstance(error, DSLParseError):
964
+ payload["diagnostics"] = copy.deepcopy(dict(error.diagnostics))
965
+
966
+ return CallbackResult(
967
+ success=False,
968
+ status_message=message,
969
+ world_spec_json=world_spec_json,
970
+ metrics_json=_safe_json_dumps(payload),
971
+ narrative=(
972
+ "The simulation could not be completed. Check the status message "
973
+ "and validation output for details."
974
+ ),
975
+ metadata=payload,
976
+ )
977
+
978
+
979
+ def _capture_animation_frame(
980
+ world: Any,
981
+ renderer: WorldRenderer,
982
+ *,
983
+ index: int,
984
+ ) -> AnimationFrame:
985
+ """Capture one animation frame from the current world state."""
986
+
987
+ render_result = renderer.render_result(world)
988
+ image = figure_to_rgb_array(render_result.figure)
989
+ close_figure(render_result.figure)
990
+
991
+ return AnimationFrame(
992
+ index=index,
993
+ step=_world_step(world),
994
+ image=image,
995
+ snapshot=render_result.snapshot,
996
+ metadata={
997
+ "world_step": _world_step(world),
998
+ "object_count": render_result.snapshot.object_count,
999
+ },
1000
+ )
1001
+
1002
+
1003
+ def _write_animation_with_fallback(
1004
+ *,
1005
+ animator: WorldAnimator,
1006
+ frames: Sequence[AnimationFrame],
1007
+ output_dir: Path,
1008
+ requested_format: AnimationFormat,
1009
+ fallback_to_gif: bool,
1010
+ ) -> str | None:
1011
+ """Write animation frames and optionally fall back from MP4 to GIF."""
1012
+
1013
+ try:
1014
+ result = animator.write_frames(
1015
+ frames,
1016
+ output_path=_artifact_path(output_dir, "simulation", requested_format.value),
1017
+ format=requested_format,
1018
+ )
1019
+ return result.path
1020
+ except AnimationWriterError:
1021
+ if not fallback_to_gif or requested_format is AnimationFormat.GIF:
1022
+ raise
1023
+
1024
+ logger.warning("Animation writer failed for %s; falling back to GIF", requested_format.value)
1025
+ result = animator.write_frames(
1026
+ frames,
1027
+ output_path=_artifact_path(output_dir, "simulation", "gif"),
1028
+ format=AnimationFormat.GIF,
1029
+ )
1030
+ return result.path
1031
+
1032
+
1033
+ def _artifact_path(output_dir: Path, stem: str, suffix: str) -> str:
1034
+ """Return a deterministic artifact path inside an output directory."""
1035
+
1036
+ suffix_text = suffix.lstrip(".")
1037
+ return str(output_dir / f"{stem}.{suffix_text}")
1038
+
1039
+
1040
+ def _capture_interval(*, steps: int, max_frames: int) -> int:
1041
+ """Return how often animation frames should be captured."""
1042
+
1043
+ if steps <= 0:
1044
+ return 1
1045
+
1046
+ if max_frames <= 1:
1047
+ return steps
1048
+
1049
+ return max(1, int(math.ceil((steps + 1) / float(max_frames))))
1050
+
1051
+
1052
+ def _timeline_snapshot(world: Any, *, index: int) -> Mapping[str, Any]:
1053
+ """Return compact timeline snapshot for narration."""
1054
+
1055
+ agents = _iter_collection(getattr(world, "agents", ()))
1056
+ resources = _iter_collection(getattr(world, "resources", ()))
1057
+
1058
+ alive_count = sum(1 for agent in agents if _is_alive(agent))
1059
+ total_resource_amount = sum(_as_float(getattr(resource, "amount", 0.0)) for resource in resources)
1060
+
1061
+ return {
1062
+ "index": index,
1063
+ "step": _world_step(world),
1064
+ "agent_count": len(agents),
1065
+ "alive_agent_count": alive_count,
1066
+ "resource_count": len(resources),
1067
+ "total_resource_amount": total_resource_amount,
1068
+ }
1069
+
1070
+
1071
+ def _compact_metric_history_entry(metrics: Mapping[str, Any]) -> dict[str, Any]:
1072
+ """Return compact metric history values for charts and JSON display."""
1073
+
1074
+ output: dict[str, Any] = {}
1075
+
1076
+ interestingness = _nested_get(metrics, ("interestingness", "score"))
1077
+ if _is_number(interestingness):
1078
+ output["interestingness_score"] = float(interestingness)
1079
+
1080
+ stability = _nested_get(metrics, ("stability", "stability_score"))
1081
+ if _is_number(stability):
1082
+ output["stability_score"] = float(stability)
1083
+
1084
+ entropy = _nested_get(metrics, ("agent_entropy", "normalized_entropy"))
1085
+ if _is_number(entropy):
1086
+ output["agent_normalized_entropy"] = float(entropy)
1087
+
1088
+ diversity = _nested_get(metrics, ("agent_diversity", "gini_simpson_index"))
1089
+ if _is_number(diversity):
1090
+ output["agent_diversity"] = float(diversity)
1091
+
1092
+ return output
1093
+
1094
+
1095
+ def _metric_results_for_narrator(metrics: Mapping[str, Any]) -> Mapping[str, Any]:
1096
+ """Extract final metric result mapping for the narrator."""
1097
+
1098
+ final_metrics = metrics.get("final") if isinstance(metrics.get("final"), Mapping) else metrics
1099
+
1100
+ if not isinstance(final_metrics, Mapping):
1101
+ return {}
1102
+
1103
+ return {
1104
+ "diversity": final_metrics.get("agent_diversity", {}),
1105
+ "entropy": final_metrics.get("agent_entropy", {}),
1106
+ "stability": final_metrics.get("stability", {}),
1107
+ "interestingness": final_metrics.get("interestingness", {}),
1108
+ "resource_diversity": final_metrics.get("resource_diversity", {}),
1109
+ "resource_entropy": final_metrics.get("resource_entropy", {}),
1110
+ }
1111
+
1112
+
1113
+ def _nested_get(mapping: Mapping[str, Any], path: Sequence[str]) -> Any:
1114
+ """Read a nested mapping path."""
1115
+
1116
+ current: Any = mapping
1117
+ for key in path:
1118
+ if not isinstance(current, Mapping) or key not in current:
1119
+ return None
1120
+ current = current[key]
1121
+ return current
1122
+
1123
+
1124
+ def _world_step(world: Any) -> int | None:
1125
+ """Return current world step if available."""
1126
+
1127
+ value = getattr(world, "step_count", None)
1128
+ if isinstance(value, Real) and not isinstance(value, bool):
1129
+ return int(value)
1130
+ return None
1131
+
1132
+
1133
+ def _iter_collection(raw_collection: Any) -> tuple[Any, ...]:
1134
+ """Return items from mapping-backed or sequence-backed collections."""
1135
+
1136
+ if raw_collection is None:
1137
+ return ()
1138
+
1139
+ if isinstance(raw_collection, Mapping):
1140
+ values = raw_collection.values()
1141
+ elif isinstance(raw_collection, Sequence) and not isinstance(raw_collection, (str, bytes)):
1142
+ values = raw_collection
1143
+ else:
1144
+ values = (raw_collection,)
1145
+
1146
+ return tuple(item for item in values if item is not None)
1147
+
1148
+
1149
+ def _is_alive(item: Any) -> bool:
1150
+ """Return whether an item is alive when it exposes an alive field."""
1151
+
1152
+ return bool(getattr(item, "alive", True))
1153
+
1154
+
1155
+ def _is_number(value: Any) -> bool:
1156
+ """Return whether a value is a real numeric scalar, excluding booleans."""
1157
+
1158
+ return isinstance(value, Real) and not isinstance(value, bool)
1159
+
1160
+
1161
+ def _as_float(value: Any, default: float = 0.0) -> float:
1162
+ """Convert numeric-like value to float."""
1163
+
1164
+ if _is_number(value):
1165
+ return float(value)
1166
+ return default
1167
+
1168
+
1169
+ def _coerce_optional_int(value: int | float | str | None) -> int | None:
1170
+ """Convert optional UI numeric value to int."""
1171
+
1172
+ if value is None or value == "":
1173
+ return None
1174
+
1175
+ if isinstance(value, Real) and not isinstance(value, bool):
1176
+ return int(value)
1177
+
1178
+ return int(float(str(value)))
1179
+
1180
+
1181
+ def _safe_json_dumps(value: Any) -> str:
1182
+ """Serialize a value as pretty JSON for Gradio code components."""
1183
+
1184
+ return json.dumps(
1185
+ _json_safe(value),
1186
+ indent=2,
1187
+ sort_keys=True,
1188
+ ensure_ascii=False,
1189
+ )
1190
+
1191
+
1192
+ def _json_safe(value: Any) -> Any:
1193
+ """Return a JSON-friendly representation of arbitrary callback data."""
1194
+
1195
+ if value is None or isinstance(value, (str, bool)):
1196
+ return value
1197
+
1198
+ if isinstance(value, int) and not isinstance(value, bool):
1199
+ return value
1200
+
1201
+ if isinstance(value, float):
1202
+ if not math.isfinite(value):
1203
+ return None
1204
+ return value
1205
+
1206
+ if isinstance(value, Mapping):
1207
+ return {str(key): _json_safe(nested) for key, nested in value.items()}
1208
+
1209
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
1210
+ return [_json_safe(item) for item in value]
1211
+
1212
+ if hasattr(value, "to_dict") and callable(value.to_dict):
1213
+ return _json_safe(value.to_dict())
1214
+
1215
+ if hasattr(value, "model_dump") and callable(value.model_dump):
1216
+ return _json_safe(value.model_dump(mode="json"))
1217
+
1218
+ return str(value)
1219
+
1220
+
1221
+ def _append_bounded(items: MutableSequence[Any], value: Any, max_items: int) -> None:
1222
+ """Append a value while enforcing a max length."""
1223
+
1224
+ items.append(value)
1225
+ if max_items > 0 and len(items) > max_items:
1226
+ del items[: len(items) - max_items]
1227
+
1228
+
1229
+ __all__ = [
1230
+ "CallbackArtifacts",
1231
+ "CallbackConfig",
1232
+ "CallbackMode",
1233
+ "CallbackResult",
1234
+ "DEFAULT_GRADIO_OUTPUT_FIELDS",
1235
+ "SimulationRunData",
1236
+ "apply_step_override",
1237
+ "build_world_from_spec",
1238
+ "callback_failure_result",
1239
+ "compute_metric_bundle",
1240
+ "generate_dsl_callback",
1241
+ "generate_world_from_prompt",
1242
+ "load_example_callback",
1243
+ "narrate_world_for_app",
1244
+ "parse_constraints",
1245
+ "run_worldsmith_callback",
1246
+ "run_worldsmith_pipeline",
1247
+ "simulate_dsl_callback",
1248
+ "simulate_dsl_pipeline",
1249
+ "simulate_world_for_app",
1250
+ "validate_dsl_callback",
1251
+ ]
main.py ADDED
@@ -0,0 +1,739 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Command-line entry point for WorldSmithAI.
3
+
4
+ This module is intended to live at the repository root beside app.py and
5
+ callbacks.py. It provides a local CLI for generating, validating, building,
6
+ simulating, visualizing, and narrating WorldSmithAI worlds.
7
+
8
+ It does not import Gradio and does not assume an app/ package.
9
+
10
+ Example usage:
11
+ python main.py --example research --steps 60
12
+
13
+ python main.py --dsl examples/farm.json --steps 80 --output-dir outputs/farm_run
14
+
15
+ python main.py --prompt "A fantasy kingdom with dragons, mages, trade, and mana" --steps 70
16
+
17
+ python main.py --dsl examples/research.json --validate-only
18
+
19
+ python main.py --prompt "A startup economy with founders and investors" --generate-only
20
+
21
+ Future extensibility:
22
+ - Add optional model-client backends for prompt generation.
23
+ - Add multi-run experiments.
24
+ - Add benchmark mode.
25
+ - Add artifact bundle export.
26
+ - Add CI smoke-test command.
27
+ - Add run comparison summaries.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import logging
35
+ import math
36
+ import os
37
+ import sys
38
+ import traceback
39
+ from collections.abc import Mapping, Sequence
40
+ from dataclasses import dataclass, field
41
+ from datetime import datetime
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ from callbacks import (
46
+ CallbackConfig,
47
+ CallbackResult,
48
+ apply_step_override,
49
+ callback_failure_result,
50
+ generate_world_from_prompt,
51
+ parse_constraints,
52
+ run_worldsmith_pipeline,
53
+ simulate_dsl_pipeline,
54
+ )
55
+ from dsl.parser import parse_world_spec
56
+ from dsl.validator import validate_world_spec
57
+
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+ APP_NAME = "WorldSmithAI"
62
+
63
+ DEFAULT_PROMPT = (
64
+ "Create a compact research ecosystem where scientists, engineers, reviewers, "
65
+ "curators, and funding agents exchange knowledge, collaborate, compete for "
66
+ "attention, allocate funding, and adapt goals over time."
67
+ )
68
+
69
+ DEFAULT_OUTPUT_ROOT = "outputs"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class MainRunResult:
74
+ """Structured CLI result.
75
+
76
+ Attributes:
77
+ callback_result: Framework callback result.
78
+ output_dir: Directory where CLI artifact files were written.
79
+ written_files: Mapping from artifact label to file path.
80
+ source_label: Human-readable source description.
81
+ """
82
+
83
+ callback_result: CallbackResult
84
+ output_dir: Path
85
+ written_files: Mapping[str, str] = field(default_factory=dict)
86
+ source_label: str = "unknown"
87
+
88
+ @property
89
+ def success(self) -> bool:
90
+ """Return whether the underlying callback result succeeded."""
91
+
92
+ return self.callback_result.success
93
+
94
+ def to_dict(self) -> dict[str, Any]:
95
+ """Return a JSON-friendly run summary."""
96
+
97
+ return {
98
+ "success": self.success,
99
+ "source_label": self.source_label,
100
+ "output_dir": str(self.output_dir),
101
+ "written_files": dict(self.written_files),
102
+ "callback_result": self.callback_result.to_dict(),
103
+ }
104
+
105
+
106
+ class MainError(RuntimeError):
107
+ """Raised when CLI arguments or execution are invalid."""
108
+
109
+
110
+ def main(argv: Sequence[str] | None = None) -> int:
111
+ """Run the WorldSmithAI CLI.
112
+
113
+ Args:
114
+ argv: Optional command-line arguments. When omitted, sys.argv is used.
115
+
116
+ Returns:
117
+ Process exit code.
118
+ """
119
+
120
+ parser = build_arg_parser()
121
+ args = parser.parse_args(argv)
122
+
123
+ configure_logging(args.log_level)
124
+
125
+ try:
126
+ if args.list_examples:
127
+ print_examples(args.examples_dir)
128
+ return 0
129
+
130
+ result = run_from_args(args)
131
+
132
+ if not args.quiet:
133
+ print_console_summary(result)
134
+
135
+ if args.print_summary:
136
+ print(json.dumps(_json_safe(result.to_dict()), indent=2, sort_keys=True))
137
+
138
+ return 0 if result.success else 2
139
+
140
+ except KeyboardInterrupt:
141
+ print("Interrupted.", file=sys.stderr)
142
+ return 130
143
+
144
+ except Exception as exc:
145
+ logger.exception("WorldSmithAI CLI failed")
146
+ failure = callback_failure_result(exc)
147
+ if not args.quiet:
148
+ print(failure.status_message, file=sys.stderr)
149
+ if args.debug:
150
+ print(traceback.format_exc(), file=sys.stderr)
151
+ return 1
152
+
153
+
154
+ def build_arg_parser() -> argparse.ArgumentParser:
155
+ """Build the CLI argument parser."""
156
+
157
+ parser = argparse.ArgumentParser(
158
+ prog="worldsmithai",
159
+ description="Run WorldSmithAI from the command line.",
160
+ )
161
+
162
+ input_group = parser.add_mutually_exclusive_group()
163
+ input_group.add_argument(
164
+ "--prompt",
165
+ "-p",
166
+ type=str,
167
+ default=None,
168
+ help="Natural-language world prompt. Uses deterministic fallback generation unless a client is wired externally.",
169
+ )
170
+ input_group.add_argument(
171
+ "--dsl",
172
+ type=str,
173
+ default=None,
174
+ help="Path to a WorldSpec JSON file, or '-' to read from stdin.",
175
+ )
176
+ input_group.add_argument(
177
+ "--example",
178
+ "-e",
179
+ type=str,
180
+ default=None,
181
+ help="Example name or path. Examples include farm, civilization, and research.",
182
+ )
183
+
184
+ parser.add_argument(
185
+ "--list-examples",
186
+ action="store_true",
187
+ help="List available examples and exit.",
188
+ )
189
+ parser.add_argument(
190
+ "--examples-dir",
191
+ type=str,
192
+ default="examples",
193
+ help="Directory containing example JSON files.",
194
+ )
195
+ parser.add_argument(
196
+ "--constraints",
197
+ "-c",
198
+ type=str,
199
+ default=None,
200
+ help="Optional constraints as JSON text, plain text, or @path/to/file.",
201
+ )
202
+ parser.add_argument(
203
+ "--steps",
204
+ "-s",
205
+ type=int,
206
+ default=None,
207
+ help="Override simulation steps.",
208
+ )
209
+ parser.add_argument(
210
+ "--output-dir",
211
+ "-o",
212
+ type=str,
213
+ default=None,
214
+ help="Directory where output artifacts should be written.",
215
+ )
216
+ parser.add_argument(
217
+ "--animation-format",
218
+ choices=("gif", "mp4"),
219
+ default="gif",
220
+ help="Animation format. GIF is safest for local and Spaces demos.",
221
+ )
222
+ parser.add_argument(
223
+ "--max-animation-frames",
224
+ type=int,
225
+ default=int(os.getenv("WORLDSMITHAI_MAX_FRAMES", "80")),
226
+ help="Maximum number of animation frames to capture.",
227
+ )
228
+
229
+ parser.add_argument(
230
+ "--generate-only",
231
+ action="store_true",
232
+ help="Generate DSL from --prompt and write JSON without running simulation.",
233
+ )
234
+ parser.add_argument(
235
+ "--validate-only",
236
+ action="store_true",
237
+ help="Validate DSL input without building or simulating the world.",
238
+ )
239
+ parser.add_argument(
240
+ "--strict",
241
+ action="store_true",
242
+ help="Treat semantic validation issues as errors where configured.",
243
+ )
244
+
245
+ parser.add_argument(
246
+ "--no-animation",
247
+ action="store_true",
248
+ help="Do not generate animation artifacts.",
249
+ )
250
+ parser.add_argument(
251
+ "--no-charts",
252
+ action="store_true",
253
+ help="Do not generate population or resource charts.",
254
+ )
255
+ parser.add_argument(
256
+ "--no-final-image",
257
+ action="store_true",
258
+ help="Do not generate final world-state image.",
259
+ )
260
+ parser.add_argument(
261
+ "--no-narrative",
262
+ action="store_true",
263
+ help="Do not generate narrative summary.",
264
+ )
265
+ parser.add_argument(
266
+ "--no-metric-history",
267
+ action="store_true",
268
+ help="Do not collect per-step metric history.",
269
+ )
270
+
271
+ parser.add_argument(
272
+ "--print-summary",
273
+ action="store_true",
274
+ help="Print full JSON run summary to stdout.",
275
+ )
276
+ parser.add_argument(
277
+ "--quiet",
278
+ "-q",
279
+ action="store_true",
280
+ help="Suppress human-readable console output.",
281
+ )
282
+ parser.add_argument(
283
+ "--debug",
284
+ action="store_true",
285
+ help="Print tracebacks on failure.",
286
+ )
287
+ parser.add_argument(
288
+ "--log-level",
289
+ type=str,
290
+ default=os.getenv("WORLDSMITHAI_LOG_LEVEL", "INFO"),
291
+ help="Logging level.",
292
+ )
293
+
294
+ return parser
295
+
296
+
297
+ def run_from_args(args: argparse.Namespace) -> MainRunResult:
298
+ """Run the requested CLI action from parsed arguments."""
299
+
300
+ output_dir = resolve_output_dir(args.output_dir)
301
+ config = build_callback_config(args, output_dir=output_dir)
302
+
303
+ if args.generate_only:
304
+ return run_generate_only(args, config=config, output_dir=output_dir)
305
+
306
+ if args.validate_only:
307
+ return run_validate_only(args, config=config, output_dir=output_dir)
308
+
309
+ source_kind, source_value, source_label = resolve_world_source(args)
310
+
311
+ if source_kind == "prompt":
312
+ callback_result = run_worldsmith_pipeline(
313
+ prompt=source_value,
314
+ constraints=parse_constraints(read_constraints_argument(args.constraints)),
315
+ client=None,
316
+ config=config,
317
+ )
318
+ elif source_kind == "dsl":
319
+ callback_result = simulate_dsl_pipeline(
320
+ dsl_json=source_value,
321
+ client=None,
322
+ config=config,
323
+ )
324
+ else:
325
+ raise MainError(f"Unsupported source kind: {source_kind}")
326
+
327
+ written_files = write_callback_result_files(
328
+ callback_result,
329
+ output_dir=output_dir,
330
+ source_label=source_label,
331
+ )
332
+
333
+ return MainRunResult(
334
+ callback_result=callback_result,
335
+ output_dir=output_dir,
336
+ written_files=written_files,
337
+ source_label=source_label,
338
+ )
339
+
340
+
341
+ def run_generate_only(
342
+ args: argparse.Namespace,
343
+ *,
344
+ config: CallbackConfig,
345
+ output_dir: Path,
346
+ ) -> MainRunResult:
347
+ """Generate a WorldSpec from prompt and write it without simulation."""
348
+
349
+ prompt = args.prompt or DEFAULT_PROMPT
350
+ constraints = parse_constraints(read_constraints_argument(args.constraints))
351
+
352
+ generation = generate_world_from_prompt(
353
+ prompt=prompt,
354
+ constraints=constraints,
355
+ client=None,
356
+ config=config,
357
+ )
358
+ spec = apply_step_override(generation.spec, config.steps)
359
+ report = validate_world_spec(spec, config=config.resolved_validation_config())
360
+
361
+ status = (
362
+ f"Generated WorldSpec {spec.id!r}: "
363
+ f"{len(spec.agents)} agent(s), {len(spec.resources)} resource(s), "
364
+ f"{len(spec.behavior_names)} behavior reference(s). "
365
+ f"Validation: {len(report.errors)} error(s), {len(report.warnings)} warning(s)."
366
+ )
367
+
368
+ callback_result = CallbackResult(
369
+ success=report.is_valid,
370
+ status_message=status,
371
+ world_spec_json=spec.to_json_string(indent=2, exclude_none=True),
372
+ validation_json=safe_json_dumps(report.to_dict()),
373
+ metrics_json=safe_json_dumps(
374
+ {
375
+ "success": report.is_valid,
376
+ "generation": generation.to_dict(),
377
+ "validation": report.to_dict(),
378
+ }
379
+ ),
380
+ narrative="Generation-only mode completed. No simulation was run.",
381
+ metadata={
382
+ "mode": "generate_only",
383
+ "prompt": prompt,
384
+ },
385
+ )
386
+
387
+ written_files = write_callback_result_files(
388
+ callback_result,
389
+ output_dir=output_dir,
390
+ source_label="prompt:generate_only",
391
+ )
392
+
393
+ return MainRunResult(
394
+ callback_result=callback_result,
395
+ output_dir=output_dir,
396
+ written_files=written_files,
397
+ source_label="prompt:generate_only",
398
+ )
399
+
400
+
401
+ def run_validate_only(
402
+ args: argparse.Namespace,
403
+ *,
404
+ config: CallbackConfig,
405
+ output_dir: Path,
406
+ ) -> MainRunResult:
407
+ """Validate a DSL source and write validation report without simulation."""
408
+
409
+ source_kind, source_value, source_label = resolve_world_source(args)
410
+
411
+ if source_kind == "prompt":
412
+ raise MainError("--validate-only requires --dsl or --example, not --prompt")
413
+
414
+ spec = parse_world_spec(source_value)
415
+ spec = apply_step_override(spec, config.steps)
416
+ report = validate_world_spec(spec, config=config.resolved_validation_config())
417
+
418
+ status = (
419
+ f"Validated WorldSpec {spec.id!r}. "
420
+ f"Validation: {len(report.errors)} error(s), {len(report.warnings)} warning(s)."
421
+ )
422
+
423
+ callback_result = CallbackResult(
424
+ success=report.is_valid,
425
+ status_message=status,
426
+ world_spec_json=spec.to_json_string(indent=2, exclude_none=True),
427
+ validation_json=safe_json_dumps(report.to_dict()),
428
+ metrics_json=safe_json_dumps({"success": report.is_valid, "validation": report.to_dict()}),
429
+ narrative="Validation-only mode completed. No simulation was run.",
430
+ metadata={
431
+ "mode": "validate_only",
432
+ "source_label": source_label,
433
+ },
434
+ )
435
+
436
+ written_files = write_callback_result_files(
437
+ callback_result,
438
+ output_dir=output_dir,
439
+ source_label=source_label,
440
+ )
441
+
442
+ return MainRunResult(
443
+ callback_result=callback_result,
444
+ output_dir=output_dir,
445
+ written_files=written_files,
446
+ source_label=source_label,
447
+ )
448
+
449
+
450
+ def build_callback_config(args: argparse.Namespace, *, output_dir: Path) -> CallbackConfig:
451
+ """Build callback configuration from CLI args."""
452
+
453
+ return CallbackConfig(
454
+ output_dir=output_dir,
455
+ steps=args.steps,
456
+ max_animation_frames=max(1, int(args.max_animation_frames)),
457
+ animation_format=args.animation_format,
458
+ generate_animation=not bool(args.no_animation),
459
+ generate_charts=not bool(args.no_charts),
460
+ generate_final_image=not bool(args.no_final_image),
461
+ generate_narrative=not bool(args.no_narrative),
462
+ collect_metric_history=not bool(args.no_metric_history),
463
+ strict_validation_for_app=bool(args.strict),
464
+ )
465
+
466
+
467
+ def resolve_world_source(args: argparse.Namespace) -> tuple[str, str, str]:
468
+ """Resolve CLI input into a source kind, source text, and source label."""
469
+
470
+ if args.prompt:
471
+ return "prompt", str(args.prompt).strip(), "prompt"
472
+
473
+ if args.dsl:
474
+ dsl_text = read_text_source(args.dsl)
475
+ return "dsl", dsl_text, f"dsl:{args.dsl}"
476
+
477
+ if args.example:
478
+ example_path = resolve_example_path(args.example, examples_dir=args.examples_dir)
479
+ return "dsl", example_path.read_text(encoding="utf-8"), f"example:{example_path.name}"
480
+
481
+ default_example = resolve_default_example(examples_dir=args.examples_dir)
482
+ if default_example is not None:
483
+ return "dsl", default_example.read_text(encoding="utf-8"), f"example:{default_example.name}"
484
+
485
+ return "prompt", DEFAULT_PROMPT, "default_prompt"
486
+
487
+
488
+ def discover_examples(examples_dir: str | Path = "examples") -> dict[str, Path]:
489
+ """Discover available example JSON files."""
490
+
491
+ directory = Path(examples_dir)
492
+ if not directory.exists():
493
+ return {}
494
+
495
+ examples: dict[str, Path] = {}
496
+
497
+ for path in sorted(directory.glob("*.json")):
498
+ examples[path.stem.lower()] = path
499
+ examples[path.stem.replace("_", " ").lower()] = path
500
+ examples[path.name.lower()] = path
501
+
502
+ return examples
503
+
504
+
505
+ def resolve_example_path(example: str, *, examples_dir: str | Path = "examples") -> Path:
506
+ """Resolve an example name or path to a JSON file path."""
507
+
508
+ candidate = Path(example)
509
+ if candidate.exists():
510
+ return candidate
511
+
512
+ if candidate.suffix != ".json":
513
+ json_candidate = candidate.with_suffix(".json")
514
+ if json_candidate.exists():
515
+ return json_candidate
516
+
517
+ examples = discover_examples(examples_dir)
518
+ key = example.strip().lower()
519
+ if key in examples:
520
+ return examples[key]
521
+
522
+ simplified_key = key.replace("_", " ")
523
+ if simplified_key in examples:
524
+ return examples[simplified_key]
525
+
526
+ known = sorted(set(str(path) for path in examples.values()))
527
+ raise MainError(f"Unknown example {example!r}. Known example files: {known}")
528
+
529
+
530
+ def resolve_default_example(*, examples_dir: str | Path = "examples") -> Path | None:
531
+ """Return the preferred default example path, if available."""
532
+
533
+ examples = discover_examples(examples_dir)
534
+ for key in ("research", "farm", "civilization"):
535
+ if key in examples:
536
+ return examples[key]
537
+
538
+ unique_paths = sorted(set(examples.values()))
539
+ return unique_paths[0] if unique_paths else None
540
+
541
+
542
+ def print_examples(examples_dir: str | Path = "examples") -> None:
543
+ """Print available example worlds."""
544
+
545
+ examples = discover_examples(examples_dir)
546
+ unique_paths = sorted(set(examples.values()))
547
+
548
+ if not unique_paths:
549
+ print(f"No examples found in {examples_dir!s}.")
550
+ return
551
+
552
+ print("Available examples:")
553
+ for path in unique_paths:
554
+ print(f" - {path.stem}: {path}")
555
+
556
+
557
+ def read_text_source(source: str) -> str:
558
+ """Read DSL text from a path or stdin marker."""
559
+
560
+ if source == "-":
561
+ return sys.stdin.read()
562
+
563
+ path = Path(source)
564
+ if not path.exists():
565
+ raise MainError(f"DSL file not found: {source}")
566
+
567
+ return path.read_text(encoding="utf-8")
568
+
569
+
570
+ def read_constraints_argument(value: str | None) -> str | Mapping[str, Any] | None:
571
+ """Read constraints from CLI text or @file syntax."""
572
+
573
+ if value is None:
574
+ return None
575
+
576
+ text = str(value).strip()
577
+ if not text:
578
+ return None
579
+
580
+ if text.startswith("@"):
581
+ path = Path(text[1:])
582
+ if not path.exists():
583
+ raise MainError(f"Constraints file not found: {path}")
584
+ return path.read_text(encoding="utf-8")
585
+
586
+ return text
587
+
588
+
589
+ def resolve_output_dir(output_dir_arg: str | None) -> Path:
590
+ """Resolve and create the CLI output directory."""
591
+
592
+ if output_dir_arg:
593
+ output_dir = Path(output_dir_arg)
594
+ elif os.getenv("WORLDSMITHAI_OUTPUT_DIR"):
595
+ output_dir = Path(os.environ["WORLDSMITHAI_OUTPUT_DIR"])
596
+ else:
597
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
598
+ output_dir = Path(DEFAULT_OUTPUT_ROOT) / f"run_{timestamp}"
599
+
600
+ output_dir.mkdir(parents=True, exist_ok=True)
601
+ return output_dir
602
+
603
+
604
+ def write_callback_result_files(
605
+ result: CallbackResult,
606
+ *,
607
+ output_dir: Path,
608
+ source_label: str,
609
+ ) -> dict[str, str]:
610
+ """Write standard JSON, Markdown, and summary files for a callback result."""
611
+
612
+ output_dir.mkdir(parents=True, exist_ok=True)
613
+
614
+ written: dict[str, str] = {}
615
+
616
+ written["world_spec_json"] = write_text_file(
617
+ output_dir / "world_spec.json",
618
+ result.world_spec_json or "{}",
619
+ )
620
+ written["validation_report_json"] = write_text_file(
621
+ output_dir / "validation_report.json",
622
+ result.validation_json or "{}",
623
+ )
624
+ written["metrics_json"] = write_text_file(
625
+ output_dir / "metrics.json",
626
+ result.metrics_json or "{}",
627
+ )
628
+ written["narrative_md"] = write_text_file(
629
+ output_dir / "narrative.md",
630
+ result.narrative or "No narrative generated.",
631
+ )
632
+
633
+ summary_payload = {
634
+ "app": APP_NAME,
635
+ "source_label": source_label,
636
+ "success": result.success,
637
+ "status_message": result.status_message,
638
+ "standard_files": written,
639
+ "artifacts": None if result.artifacts is None else result.artifacts.to_dict(),
640
+ "callback_result": result.to_dict(),
641
+ }
642
+
643
+ written["run_summary_json"] = write_text_file(
644
+ output_dir / "run_summary.json",
645
+ safe_json_dumps(summary_payload),
646
+ )
647
+
648
+ return written
649
+
650
+
651
+ def write_text_file(path: Path, text: str) -> str:
652
+ """Write UTF-8 text to a file and return the path string."""
653
+
654
+ path.parent.mkdir(parents=True, exist_ok=True)
655
+ path.write_text(text, encoding="utf-8")
656
+ return str(path)
657
+
658
+
659
+ def print_console_summary(result: MainRunResult) -> None:
660
+ """Print a concise human-readable run summary."""
661
+
662
+ callback_result = result.callback_result
663
+
664
+ print()
665
+ print(f"{APP_NAME} run summary")
666
+ print("=" * 72)
667
+ print(f"Success: {callback_result.success}")
668
+ print(f"Source: {result.source_label}")
669
+ print(f"Output directory: {result.output_dir}")
670
+ print(f"Status: {callback_result.status_message}")
671
+
672
+ print()
673
+ print("Written files:")
674
+ for label, path in sorted(result.written_files.items()):
675
+ print(f" - {label}: {path}")
676
+
677
+ if callback_result.artifacts is not None:
678
+ artifacts = callback_result.artifacts.to_dict()
679
+ artifact_items = [(key, value) for key, value in artifacts.items() if value and key != "output_dir"]
680
+ if artifact_items:
681
+ print()
682
+ print("Generated artifacts:")
683
+ for label, path in artifact_items:
684
+ print(f" - {label}: {path}")
685
+
686
+ print()
687
+
688
+
689
+ def configure_logging(level: str) -> None:
690
+ """Configure logging for CLI execution."""
691
+
692
+ logging.basicConfig(
693
+ level=str(level).upper(),
694
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
695
+ )
696
+
697
+
698
+ def safe_json_dumps(value: Any) -> str:
699
+ """Serialize a value as pretty JSON."""
700
+
701
+ return json.dumps(
702
+ _json_safe(value),
703
+ indent=2,
704
+ sort_keys=True,
705
+ ensure_ascii=False,
706
+ )
707
+
708
+
709
+ def _json_safe(value: Any) -> Any:
710
+ """Return a JSON-friendly representation of arbitrary values."""
711
+
712
+ if value is None or isinstance(value, (str, bool)):
713
+ return value
714
+
715
+ if isinstance(value, int) and not isinstance(value, bool):
716
+ return value
717
+
718
+ if isinstance(value, float):
719
+ if not math.isfinite(value):
720
+ return None
721
+ return value
722
+
723
+ if isinstance(value, Mapping):
724
+ return {str(key): _json_safe(nested) for key, nested in value.items()}
725
+
726
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
727
+ return [_json_safe(item) for item in value]
728
+
729
+ if hasattr(value, "to_dict") and callable(value.to_dict):
730
+ return _json_safe(value.to_dict())
731
+
732
+ if hasattr(value, "model_dump") and callable(value.model_dump):
733
+ return _json_safe(value.model_dump(mode="json"))
734
+
735
+ return str(value)
736
+
737
+
738
+ if __name__ == "__main__":
739
+ raise SystemExit(main())
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=4.44,<6
2
+ pydantic>=2.7,<3
3
+ numpy>=1.26,<3
4
+ matplotlib>=3.8,<4
5
+ pillow>=10,<12
6
+ huggingface_hub>=0.24,<1