Commit ·
3313e24
1
Parent(s): 2fccde8
refactor: replace unsloth with plain transformers+peft for GRPO training
Browse filesDrop unsloth/vllm dependency chain to eliminate huggingface-hub version
conflicts. Use AutoModelForCausalLM + BitsAndBytesConfig + LoraConfig
directly. Flash attention falls back to SDPA if flash-attn is missing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
training/notebooks/fusion_design_lab_training.ipynb
CHANGED
|
@@ -35,23 +35,13 @@
|
|
| 35 |
"id": "9a63283cbaf04dbcab1f6479b197f3a8",
|
| 36 |
"metadata": {},
|
| 37 |
"outputs": [],
|
| 38 |
-
"source":
|
| 39 |
-
"%%capture\n",
|
| 40 |
-
"# Build deps for constellaration (booz-xform compiles from source)\n",
|
| 41 |
-
"!apt-get update -qq && apt-get install -y -qq cmake ninja-build g++ gfortran libnetcdf-dev libnetcdff-dev > /dev/null\n",
|
| 42 |
-
"\n",
|
| 43 |
-
"!pip install unsloth vllm\n",
|
| 44 |
-
"!pip install --no-deps trl\n",
|
| 45 |
-
"!pip install matplotlib requests"
|
| 46 |
-
]
|
| 47 |
},
|
| 48 |
{
|
| 49 |
"cell_type": "markdown",
|
| 50 |
"id": "8dd0d8092fe74a7c96281538738b07e2",
|
| 51 |
"metadata": {},
|
| 52 |
-
"source":
|
| 53 |
-
"## 2. Load Model with Unsloth"
|
| 54 |
-
]
|
| 55 |
},
|
| 56 |
{
|
| 57 |
"cell_type": "code",
|
|
@@ -59,7 +49,7 @@
|
|
| 59 |
"id": "72eea5119410473aa328ad9291626812",
|
| 60 |
"metadata": {},
|
| 61 |
"outputs": [],
|
| 62 |
-
"source": "
|
| 63 |
},
|
| 64 |
{
|
| 65 |
"cell_type": "markdown",
|
|
@@ -368,99 +358,7 @@
|
|
| 368 |
"id": "3ed186c9a28b402fb0bc4494df01f08d",
|
| 369 |
"metadata": {},
|
| 370 |
"outputs": [],
|
| 371 |
-
"source": [
|
| 372 |
-
"import random\n",
|
| 373 |
-
"\n",
|
| 374 |
-
"FastLanguageModel.for_inference(model)\n",
|
| 375 |
-
"\n",
|
| 376 |
-
"\n",
|
| 377 |
-
"def reward_term_summary(step_or_obs: object) -> str:\n",
|
| 378 |
-
" breakdown_obj = getattr(step_or_obs, \"reward_breakdown\")\n",
|
| 379 |
-
" breakdown = (\n",
|
| 380 |
-
" breakdown_obj.model_dump() if hasattr(breakdown_obj, \"model_dump\") else breakdown_obj\n",
|
| 381 |
-
" )\n",
|
| 382 |
-
" terms = []\n",
|
| 383 |
-
" for key, value in breakdown.items():\n",
|
| 384 |
-
" if key in {\n",
|
| 385 |
-
" \"intent\",\n",
|
| 386 |
-
" \"total\",\n",
|
| 387 |
-
" \"evaluation_failed\",\n",
|
| 388 |
-
" \"recovered_from_failure\",\n",
|
| 389 |
-
" \"reference_constraints_satisfied\",\n",
|
| 390 |
-
" \"reference_score\",\n",
|
| 391 |
-
" \"reference_feasibility\",\n",
|
| 392 |
-
" \"reference_max_elongation\",\n",
|
| 393 |
-
" \"initial_reference_score\",\n",
|
| 394 |
-
" \"terminal_score_ratio\",\n",
|
| 395 |
-
" }:\n",
|
| 396 |
-
" continue\n",
|
| 397 |
-
" if isinstance(value, (int, float)) and float(value) != 0.0:\n",
|
| 398 |
-
" terms.append(f\"{key}={float(value):+.3f}\")\n",
|
| 399 |
-
" return \", \".join(terms) if terms else \"none\"\n",
|
| 400 |
-
"\n",
|
| 401 |
-
"\n",
|
| 402 |
-
"def run_episode_with_model(seed_idx: int) -> tuple[float, list[str]]:\n",
|
| 403 |
-
" \"\"\"Run one episode using the trained model.\"\"\"\n",
|
| 404 |
-
" env = StellaratorEnvironment()\n",
|
| 405 |
-
" obs = env.reset(seed=seed_idx)\n",
|
| 406 |
-
" prompt = build_prompt(obs)\n",
|
| 407 |
-
" inputs = tokenizer(prompt, return_tensors=\"pt\").to(model.device)\n",
|
| 408 |
-
" outputs = model.generate(\n",
|
| 409 |
-
" **inputs,\n",
|
| 410 |
-
" max_new_tokens=MAX_COMPLETION_LENGTH,\n",
|
| 411 |
-
" temperature=0.7,\n",
|
| 412 |
-
" do_sample=True,\n",
|
| 413 |
-
" )\n",
|
| 414 |
-
" completion = tokenizer.decode(\n",
|
| 415 |
-
" outputs[0][inputs[\"input_ids\"].shape[1] :], skip_special_tokens=True\n",
|
| 416 |
-
" )\n",
|
| 417 |
-
" actions = parse_action_plan(completion)\n",
|
| 418 |
-
" episode = run_episode_with_actions(actions, seed_idx=seed_idx)\n",
|
| 419 |
-
" trace = [\n",
|
| 420 |
-
" (\n",
|
| 421 |
-
" f\"{step.action_label} → reward={step.reward:.3f} \"\n",
|
| 422 |
-
" f\"score={step.p1_score:.4f} feasible={step.constraints_satisfied} \"\n",
|
| 423 |
-
" f\"terms={reward_term_summary(step)}\"\n",
|
| 424 |
-
" )\n",
|
| 425 |
-
" for step in episode.steps\n",
|
| 426 |
-
" ]\n",
|
| 427 |
-
" return episode.total_reward, trace\n",
|
| 428 |
-
"\n",
|
| 429 |
-
"\n",
|
| 430 |
-
"def run_random_episode(seed_idx: int) -> float:\n",
|
| 431 |
-
" \"\"\"Run one episode with random actions for comparison.\"\"\"\n",
|
| 432 |
-
" actions = [StellaratorAction(**random.choice(RUN_ACTION_SPECS)) for _ in range(BUDGET)]\n",
|
| 433 |
-
" return run_episode_with_actions(actions, seed_idx=seed_idx).total_reward\n",
|
| 434 |
-
"\n",
|
| 435 |
-
"\n",
|
| 436 |
-
"# Evaluate\n",
|
| 437 |
-
"print(\"=\" * 60)\n",
|
| 438 |
-
"print(\"TRAINED MODEL EPISODES\")\n",
|
| 439 |
-
"print(\"=\" * 60)\n",
|
| 440 |
-
"trained_rewards = []\n",
|
| 441 |
-
"for seed in range(len(RESET_SEEDS)):\n",
|
| 442 |
-
" reward, trace = run_episode_with_model(seed)\n",
|
| 443 |
-
" trained_rewards.append(reward)\n",
|
| 444 |
-
" print(f\"\\nSeed {seed} — Total reward: {reward:.3f}\")\n",
|
| 445 |
-
" for line in trace:\n",
|
| 446 |
-
" print(f\" {line}\")\n",
|
| 447 |
-
"\n",
|
| 448 |
-
"print(f\"\\nMean trained reward: {sum(trained_rewards) / len(trained_rewards):.3f}\")\n",
|
| 449 |
-
"\n",
|
| 450 |
-
"print(\"\\n\" + \"=\" * 60)\n",
|
| 451 |
-
"print(\"RANDOM BASELINE (10 episodes per seed)\")\n",
|
| 452 |
-
"print(\"=\" * 60)\n",
|
| 453 |
-
"random_rewards = []\n",
|
| 454 |
-
"for seed in range(len(RESET_SEEDS)):\n",
|
| 455 |
-
" seed_rewards = [run_random_episode(seed) for _ in range(10)]\n",
|
| 456 |
-
" random_rewards.extend(seed_rewards)\n",
|
| 457 |
-
" print(\n",
|
| 458 |
-
" f\"Seed {seed} — Mean: {sum(seed_rewards) / len(seed_rewards):.3f}, Best: {max(seed_rewards):.3f}\"\n",
|
| 459 |
-
" )\n",
|
| 460 |
-
"\n",
|
| 461 |
-
"print(f\"\\nMean random reward: {sum(random_rewards) / len(random_rewards):.3f}\")\n",
|
| 462 |
-
"print(f\"Mean trained reward: {sum(trained_rewards) / len(trained_rewards):.3f}\")"
|
| 463 |
-
]
|
| 464 |
},
|
| 465 |
{
|
| 466 |
"cell_type": "markdown",
|
|
@@ -474,59 +372,7 @@
|
|
| 474 |
"id": "379cbbc1e968416e875cc15c1202d7eb",
|
| 475 |
"metadata": {},
|
| 476 |
"outputs": [],
|
| 477 |
-
"source": [
|
| 478 |
-
"import requests\n",
|
| 479 |
-
"\n",
|
| 480 |
-
"from fusion_lab.client import FusionLabClient\n",
|
| 481 |
-
"\n",
|
| 482 |
-
"HF_SPACE_URL = \"https://creativeengineer-fusion-design-lab.hf.space\"\n",
|
| 483 |
-
"\n",
|
| 484 |
-
"# Check health\n",
|
| 485 |
-
"health = requests.get(f\"{HF_SPACE_URL}/health\").json()\n",
|
| 486 |
-
"print(f\"HF Space status: {health['status']}\")\n",
|
| 487 |
-
"\n",
|
| 488 |
-
"# Get task description\n",
|
| 489 |
-
"task = requests.get(f\"{HF_SPACE_URL}/task\").json()\n",
|
| 490 |
-
"print(f\"\\nTask: {task['description']}\")\n",
|
| 491 |
-
"print(f\"Constraints: {task['constraints']}\")\n",
|
| 492 |
-
"print(f\"Budget: {task['budget']}\")\n",
|
| 493 |
-
"\n",
|
| 494 |
-
"with FusionLabClient(base_url=HF_SPACE_URL) as env:\n",
|
| 495 |
-
" reset_result = env.reset(seed=42)\n",
|
| 496 |
-
" remote_obs = reset_result.observation\n",
|
| 497 |
-
" print(f\"\\nRemote reset — max_elongation: {remote_obs.max_elongation:.4f}\")\n",
|
| 498 |
-
" print(f\" aspect_ratio: {remote_obs.aspect_ratio:.4f}\")\n",
|
| 499 |
-
" print(f\" constraints_satisfied: {remote_obs.constraints_satisfied}\")\n",
|
| 500 |
-
" print(f\" budget_remaining: {remote_obs.budget_remaining}\")\n",
|
| 501 |
-
"\n",
|
| 502 |
-
" # Generate an action plan from the trained model\n",
|
| 503 |
-
" prompt = build_prompt(remote_obs)\n",
|
| 504 |
-
" inputs = tokenizer(prompt, return_tensors=\"pt\").to(model.device)\n",
|
| 505 |
-
" outputs = model.generate(\n",
|
| 506 |
-
" **inputs, max_new_tokens=MAX_COMPLETION_LENGTH, temperature=0.7, do_sample=True\n",
|
| 507 |
-
" )\n",
|
| 508 |
-
" completion = tokenizer.decode(\n",
|
| 509 |
-
" outputs[0][inputs[\"input_ids\"].shape[1] :], skip_special_tokens=True\n",
|
| 510 |
-
" )\n",
|
| 511 |
-
" actions = parse_action_plan(completion)\n",
|
| 512 |
-
"\n",
|
| 513 |
-
" print(f\"\\nTrained model generated {len(actions)} actions for remote env:\")\n",
|
| 514 |
-
" for i, action in enumerate(actions[:BUDGET], start=1):\n",
|
| 515 |
-
" if action.intent == \"submit\":\n",
|
| 516 |
-
" continue\n",
|
| 517 |
-
" result = env.step(action)\n",
|
| 518 |
-
" step_obs = result.observation\n",
|
| 519 |
-
" reward = float(result.reward) if result.reward is not None else 0.0\n",
|
| 520 |
-
" print(\n",
|
| 521 |
-
" f\" Step {i}: {action.intent} {action.parameter or ''} \"\n",
|
| 522 |
-
" f\"{action.direction or ''} {action.magnitude or ''} \"\n",
|
| 523 |
-
" f\"→ reward={reward:.3f}, score={step_obs.p1_score:.4f}, terms={reward_term_summary(step_obs)}\"\n",
|
| 524 |
-
" )\n",
|
| 525 |
-
" if result.done:\n",
|
| 526 |
-
" print(f\" Episode done. Final score: {step_obs.p1_score:.4f}\")\n",
|
| 527 |
-
" break\n",
|
| 528 |
-
"print(\"\\nEnvironment is live and accessible for training and evaluation.\")"
|
| 529 |
-
]
|
| 530 |
}
|
| 531 |
],
|
| 532 |
"metadata": {
|
|
|
|
| 35 |
"id": "9a63283cbaf04dbcab1f6479b197f3a8",
|
| 36 |
"metadata": {},
|
| 37 |
"outputs": [],
|
| 38 |
+
"source": "%%capture\n# Build deps for constellaration (booz-xform compiles from source)\n!apt-get update -qq && apt-get install -y -qq cmake ninja-build g++ gfortran libnetcdf-dev libnetcdff-dev > /dev/null\n\n!pip install trl peft bitsandbytes datasets matplotlib accelerate\n!pip install \"transformers>=4.51\" \"huggingface-hub<1.0\""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
},
|
| 40 |
{
|
| 41 |
"cell_type": "markdown",
|
| 42 |
"id": "8dd0d8092fe74a7c96281538738b07e2",
|
| 43 |
"metadata": {},
|
| 44 |
+
"source": "## 2. Load Model with LoRA"
|
|
|
|
|
|
|
| 45 |
},
|
| 46 |
{
|
| 47 |
"cell_type": "code",
|
|
|
|
| 49 |
"id": "72eea5119410473aa328ad9291626812",
|
| 50 |
"metadata": {},
|
| 51 |
"outputs": [],
|
| 52 |
+
"source": "import importlib\nimport torch\nfrom transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig\nfrom peft import LoraConfig, get_peft_model\n\nMODEL_NAME = \"Qwen/Qwen3.5-4B\"\nMAX_SEQ_LENGTH = 2048\n\nbnb_config = BitsAndBytesConfig(\n load_in_4bit=True,\n bnb_4bit_quant_type=\"nf4\",\n bnb_4bit_use_double_quant=True,\n bnb_4bit_compute_dtype=torch.bfloat16,\n)\n\nattn_impl = \"flash_attention_2\" if importlib.util.find_spec(\"flash_attn\") else \"sdpa\"\n\nmodel = AutoModelForCausalLM.from_pretrained(\n MODEL_NAME,\n quantization_config=bnb_config,\n torch_dtype=torch.bfloat16,\n device_map=\"auto\",\n attn_implementation=attn_impl,\n)\n\ntokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)\nif tokenizer.pad_token is None:\n tokenizer.pad_token = tokenizer.eos_token\n\nlora_config = LoraConfig(\n r=32,\n lora_alpha=32,\n target_modules=[\"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\", \"gate_proj\", \"up_proj\", \"down_proj\"],\n lora_dropout=0.0,\n task_type=\"CAUSAL_LM\",\n)\nmodel = get_peft_model(model, lora_config)\nmodel.gradient_checkpointing_enable()\nmodel.print_trainable_parameters()\nprint(f\"Model loaded: {MODEL_NAME} (attn: {attn_impl})\")"
|
| 53 |
},
|
| 54 |
{
|
| 55 |
"cell_type": "markdown",
|
|
|
|
| 358 |
"id": "3ed186c9a28b402fb0bc4494df01f08d",
|
| 359 |
"metadata": {},
|
| 360 |
"outputs": [],
|
| 361 |
+
"source": "import random\n\nmodel.eval()\n\n\ndef reward_term_summary(step_or_obs: object) -> str:\n breakdown_obj = getattr(step_or_obs, \"reward_breakdown\")\n breakdown = (\n breakdown_obj.model_dump() if hasattr(breakdown_obj, \"model_dump\") else breakdown_obj\n )\n terms = []\n for key, value in breakdown.items():\n if key in {\n \"intent\",\n \"total\",\n \"evaluation_failed\",\n \"recovered_from_failure\",\n \"reference_constraints_satisfied\",\n \"reference_score\",\n \"reference_feasibility\",\n \"reference_max_elongation\",\n \"initial_reference_score\",\n \"terminal_score_ratio\",\n }:\n continue\n if isinstance(value, (int, float)) and float(value) != 0.0:\n terms.append(f\"{key}={float(value):+.3f}\")\n return \", \".join(terms) if terms else \"none\"\n\n\ndef run_episode_with_model(seed_idx: int) -> tuple[float, list[str]]:\n \"\"\"Run one episode using the trained model.\"\"\"\n env = StellaratorEnvironment()\n obs = env.reset(seed=seed_idx)\n prompt = build_prompt(obs)\n inputs = tokenizer(prompt, return_tensors=\"pt\").to(model.device)\n with torch.no_grad():\n outputs = model.generate(\n **inputs,\n max_new_tokens=MAX_COMPLETION_LENGTH,\n temperature=0.7,\n do_sample=True,\n )\n completion = tokenizer.decode(\n outputs[0][inputs[\"input_ids\"].shape[1] :], skip_special_tokens=True\n )\n actions = parse_action_plan(completion)\n episode = run_episode_with_actions(actions, seed_idx=seed_idx)\n trace = [\n (\n f\"{step.action_label} → reward={step.reward:.3f} \"\n f\"score={step.p1_score:.4f} feasible={step.constraints_satisfied} \"\n f\"terms={reward_term_summary(step)}\"\n )\n for step in episode.steps\n ]\n return episode.total_reward, trace\n\n\ndef run_random_episode(seed_idx: int) -> float:\n \"\"\"Run one episode with random actions for comparison.\"\"\"\n actions = [StellaratorAction(**random.choice(RUN_ACTION_SPECS)) for _ in range(BUDGET)]\n return run_episode_with_actions(actions, seed_idx=seed_idx).total_reward\n\n\n# Evaluate\nprint(\"=\" * 60)\nprint(\"TRAINED MODEL EPISODES\")\nprint(\"=\" * 60)\ntrained_rewards = []\nfor seed in range(len(RESET_SEEDS)):\n reward, trace = run_episode_with_model(seed)\n trained_rewards.append(reward)\n print(f\"\\nSeed {seed} — Total reward: {reward:.3f}\")\n for line in trace:\n print(f\" {line}\")\n\nprint(f\"\\nMean trained reward: {sum(trained_rewards) / len(trained_rewards):.3f}\")\n\nprint(\"\\n\" + \"=\" * 60)\nprint(\"RANDOM BASELINE (10 episodes per seed)\")\nprint(\"=\" * 60)\nrandom_rewards = []\nfor seed in range(len(RESET_SEEDS)):\n seed_rewards = [run_random_episode(seed) for _ in range(10)]\n random_rewards.extend(seed_rewards)\n print(\n f\"Seed {seed} — Mean: {sum(seed_rewards) / len(seed_rewards):.3f}, Best: {max(seed_rewards):.3f}\"\n )\n\nprint(f\"\\nMean random reward: {sum(random_rewards) / len(random_rewards):.3f}\")\nprint(f\"Mean trained reward: {sum(trained_rewards) / len(trained_rewards):.3f}\")"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
},
|
| 363 |
{
|
| 364 |
"cell_type": "markdown",
|
|
|
|
| 372 |
"id": "379cbbc1e968416e875cc15c1202d7eb",
|
| 373 |
"metadata": {},
|
| 374 |
"outputs": [],
|
| 375 |
+
"source": "import requests\n\nfrom fusion_lab.client import FusionLabClient\n\nHF_SPACE_URL = \"https://creativeengineer-fusion-design-lab.hf.space\"\n\n# Check health\nhealth = requests.get(f\"{HF_SPACE_URL}/health\").json()\nprint(f\"HF Space status: {health['status']}\")\n\n# Get task description\ntask = requests.get(f\"{HF_SPACE_URL}/task\").json()\nprint(f\"\\nTask: {task['description']}\")\nprint(f\"Constraints: {task['constraints']}\")\nprint(f\"Budget: {task['budget']}\")\n\nwith FusionLabClient(base_url=HF_SPACE_URL) as env:\n reset_result = env.reset(seed=42)\n remote_obs = reset_result.observation\n print(f\"\\nRemote reset — max_elongation: {remote_obs.max_elongation:.4f}\")\n print(f\" aspect_ratio: {remote_obs.aspect_ratio:.4f}\")\n print(f\" constraints_satisfied: {remote_obs.constraints_satisfied}\")\n print(f\" budget_remaining: {remote_obs.budget_remaining}\")\n\n # Generate an action plan from the trained model\n prompt = build_prompt(remote_obs)\n inputs = tokenizer(prompt, return_tensors=\"pt\").to(model.device)\n with torch.no_grad():\n outputs = model.generate(\n **inputs, max_new_tokens=MAX_COMPLETION_LENGTH, temperature=0.7, do_sample=True\n )\n completion = tokenizer.decode(\n outputs[0][inputs[\"input_ids\"].shape[1] :], skip_special_tokens=True\n )\n actions = parse_action_plan(completion)\n\n print(f\"\\nTrained model generated {len(actions)} actions for remote env:\")\n for i, action in enumerate(actions[:BUDGET], start=1):\n if action.intent == \"submit\":\n continue\n result = env.step(action)\n step_obs = result.observation\n reward = float(result.reward) if result.reward is not None else 0.0\n print(\n f\" Step {i}: {action.intent} {action.parameter or ''} \"\n f\"{action.direction or ''} {action.magnitude or ''} \"\n f\"→ reward={reward:.3f}, score={step_obs.p1_score:.4f}, terms={reward_term_summary(step_obs)}\"\n )\n if result.done:\n print(f\" Episode done. Final score: {step_obs.p1_score:.4f}\")\n break\nprint(\"\\nEnvironment is live and accessible for training and evaluation.\")"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
}
|
| 377 |
],
|
| 378 |
"metadata": {
|