Taimwe commited on
Commit
6a38ac4
·
verified ·
1 Parent(s): 3d3d53f

Eval v2: clean rewrite with PEP 723 deps + inlined helpers

Browse files
Files changed (1) hide show
  1. eval_securecoder.py +51 -88
eval_securecoder.py CHANGED
@@ -3,6 +3,10 @@
3
  # dependencies = [
4
  # "datasets",
5
  # "huggingface_hub",
 
 
 
 
6
  # ]
7
  # ///
8
  """Cheap evaluations for SecureCoder.
@@ -18,8 +22,8 @@ Three things are scored:
18
 
19
  3. Security knowledge - CyberSecurityEval MCQ when available, otherwise skip.
20
 
21
- The script writes results to --out-dir/report.json, prints a summary table, and
22
- uploads the report to --upload-repo if set (default: the adapter repo itself).
23
  """
24
 
25
  from __future__ import annotations
@@ -63,6 +67,7 @@ CODE_PROMPTS: list[str] = [
63
 
64
  TOOL_FN_PAT = re.compile(r"<function=([A-Za-z0-9_\.]+)>", re.S)
65
  TOOL_PARAM_PAT = re.compile(r"<parameter=([A-Za-z0-9_]+)>\s*(.*?)\s*</parameter>", re.S)
 
66
 
67
 
68
  def parse_args() -> argparse.Namespace:
@@ -76,23 +81,9 @@ def parse_args() -> argparse.Namespace:
76
  p.add_argument("--max-new-tokens", type=int, default=384)
77
  p.add_argument("--seed", type=int, default=3407)
78
  return p.parse_args()
 
79
 
80
- def _fetch_first_rows(repo: str, config: str | None, split: str, n: int) -> list[dict]:
81
- from datasets import load_dataset
82
 
83
- kwargs: dict[str, Any] = {"split": split, "streaming": True}
84
- if config:
85
- kwargs["name"] = config
86
- ds = load_dataset(repo, token=os.environ.get("HF_TOKEN"), **kwargs)
87
- out = []
88
- for row in ds:
89
- out.append(dict(row))
90
-
91
-
92
- # --------------------------------------------------------------------------
93
- # Local helpers (inlined so the script runs standalone on HF Jobs without
94
- # needing train_securecoder.py to also be uploaded).
95
- # --------------------------------------------------------------------------
96
  _TYPE_ALIASES = {
97
  "str": "string", "string": "string", "text": "string",
98
  "int": "integer", "integer": "integer", "long": "integer",
@@ -102,7 +93,7 @@ _TYPE_ALIASES = {
102
  }
103
 
104
 
105
- def _normalise_tool_schema_local(tool: Any) -> dict | None:
106
  if not isinstance(tool, dict):
107
  return None
108
  fn = tool.get("function") if isinstance(tool.get("function"), dict) else tool
@@ -114,10 +105,8 @@ def _normalise_tool_schema_local(tool: Any) -> dict | None:
114
  required: list[str] = []
115
  for name, spec in params.items():
116
  if isinstance(spec, dict):
117
- cleaned = {k: v for k, v in spec.items()
118
- if k in ("type", "description", "enum", "default", "title", "items")}
119
- cleaned["type"] = _TYPE_ALIASES.get(
120
- str(cleaned.get("type", "")).lower(), "string")
121
  properties[name] = cleaned
122
  if "default" not in spec:
123
  required.append(name)
@@ -130,15 +119,14 @@ def _normalise_tool_schema_local(tool: Any) -> dict | None:
130
  return {"name": fn["name"], "description": fn.get("description", ""), "parameters": params}
131
 
132
 
133
- def _messages_from_any_local(row: dict, kind: str) -> tuple[list[dict], list[dict]]:
134
  tools: list[dict] = []
135
  raw = row.get("tools")
136
  candidates = [raw] if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
137
  for c in candidates:
138
- n = _normalise_tool_schema_local(c)
139
  if n:
140
  tools.append(n)
141
-
142
  messages: list[dict] = []
143
  if isinstance(row.get("messages"), list):
144
  for turn in row["messages"]:
@@ -149,36 +137,39 @@ def _messages_from_any_local(row: dict, kind: str) -> tuple[list[dict], list[dic
149
  if role in {"user", "assistant", "system"} and content:
150
  messages.append({"role": role, "content": str(content)})
151
  return messages, tools
152
-
153
  if row.get("query") and row.get("answers"):
154
  messages.append({"role": "user", "content": str(row["query"])})
155
  messages.append({"role": "assistant", "content": str(row["answers"])})
156
  return messages, tools
157
-
158
  if row.get("user") and row.get("assistant"):
159
  if row.get("system"):
160
  messages.append({"role": "system", "content": str(row["system"])})
161
  messages.append({"role": "user", "content": str(row["user"])})
162
  messages.append({"role": "assistant", "content": str(row["assistant"])})
163
  return messages, tools
164
-
165
  if row.get("instruction") and (row.get("output") or row.get("response")):
166
  messages.append({"role": "user", "content": str(row["instruction"])})
167
  messages.append({"role": "assistant", "content": str(row.get("output") or row.get("response"))})
168
  return messages, tools
169
-
170
  return [], tools
 
171
 
172
 
173
-
 
 
 
 
 
 
 
 
174
  if len(out) >= n:
175
  break
176
  return out
177
 
178
 
179
  def _build_tool_prompts(rows: list[dict]) -> list[dict]:
180
-
181
-
182
  prompts = []
183
  for row in rows:
184
  if not isinstance(row.get("messages"), list):
@@ -186,19 +177,19 @@ def _build_tool_prompts(rows: list[dict]) -> list[dict]:
186
  tools_raw = row.get("tools")
187
  if not tools_raw:
188
  continue
189
- tools = []
190
  if isinstance(tools_raw, list):
191
  for t in tools_raw:
192
- n = _normalise_tool_schema_local(t)
193
  if n:
194
  tools.append(n)
195
  elif isinstance(tools_raw, dict):
196
- n = _normalise_tool_schema_local(tools_raw)
197
  if n:
198
  tools.append(n)
199
  if not tools:
200
  continue
201
- messages, _ = _messages_from_any_local(row, "auto")
202
  if not messages:
203
  continue
204
  user = next((m["content"] for m in messages if m["role"] == "user"), None)
@@ -245,10 +236,11 @@ def _score_call(call: dict, tools: list[dict]) -> dict:
245
  return {"parse": True, "name_ok": True,
246
  "schema_ok": expected.issubset(given) if expected else True,
247
  "expected_keys": sorted(expected), "given_keys": sorted(given)}
 
 
248
 
249
  def eval_tool_calls(tokenizer, model, args) -> dict:
250
  import torch
251
-
252
  log.info("tool-call eval: streaming candidates from hermes FC ...")
253
  rows = _fetch_first_rows("NousResearch/hermes-function-calling-v1", "func_calling", "train",
254
  args.tool_prompts * 4)
@@ -265,23 +257,16 @@ def eval_tool_calls(tokenizer, model, args) -> dict:
265
  with torch.no_grad():
266
  generated = model.generate(ids, max_new_tokens=args.max_new_tokens, do_sample=False)
267
  reply = tokenizer.decode(generated[0, ids.shape[-1]:], skip_special_tokens=True)
268
- except Exception as exc: # noqa: BLE001
269
  out.append({"prompt": p["prompt"][:60], "error": repr(exc)[:120]})
270
  continue
271
-
272
  calls = _parse_emitted_calls(reply)
273
  scored = [_score_call(c, p["tools"]) for c in calls]
274
- out.append({
275
- "prompt": p["prompt"][:80],
276
- "reply_first_160": reply[:160],
277
- "expected_call": p["expected_call"],
278
- "n_calls": len(calls),
279
- "calls": calls,
280
- "scores": scored,
281
- })
282
  if (i + 1) % 25 == 0:
283
  log.info(" tool-call progress: %d/%d", i + 1, len(prompts))
284
-
285
  n = len(out)
286
  parse_ok = sum(1 for r in out if r.get("scores") and any(s["parse"] for s in r["scores"]))
287
  name_ok = sum(1 for r in out if r.get("scores") and any(s["name_ok"] for s in r["scores"]))
@@ -293,7 +278,6 @@ def eval_tool_calls(tokenizer, model, args) -> dict:
293
 
294
  def eval_code_sanity(tokenizer, model, args) -> dict:
295
  import torch
296
-
297
  out = []
298
  prompts = CODE_PROMPTS[: args.code_prompts]
299
  for i, prompt in enumerate(prompts):
@@ -306,59 +290,51 @@ def eval_code_sanity(tokenizer, model, args) -> dict:
306
  with torch.no_grad():
307
  generated = model.generate(ids, max_new_tokens=384, do_sample=False)
308
  reply = tokenizer.decode(generated[0, ids.shape[-1]:], skip_special_tokens=True)
309
- except Exception as exc: # noqa: BLE001
310
  out.append({"prompt": prompt[:60], "error": repr(exc)[:120]})
311
  continue
312
-
313
  block = None
314
- match = re.search(r"```(?:python)?\s*\n(.*?)```", reply, re.S)
315
- if match:
316
- block = match.group(1)
317
  else:
318
  start = reply.find("def ")
319
  if start >= 0:
320
  block = reply[start:]
321
-
322
  parsed = compiles = None
323
  if block:
324
  try:
325
- ast.parse(block)
326
- parsed = True
327
  except SyntaxError:
328
- parsed = False
329
- block = None
330
  if block:
331
  try:
332
- compile(block, "<eval>", "exec")
333
- compiles = True
334
- except Exception: # noqa: BLE001
335
  compiles = False
336
  out.append({"prompt": prompt[:60], "ast_ok": parsed, "compile_ok": compiles,
337
  "reply_first_160": reply[:160]})
338
  if (i + 1) % 5 == 0:
339
  log.info(" code sanity: %d/%d", i + 1, len(prompts))
340
-
341
  n = len(out)
342
  ast_ok = sum(1 for r in out if r.get("ast_ok"))
343
  compile_ok = sum(1 for r in out if r.get("compile_ok"))
344
  return {"section": "code_sanity", "n_prompts": n,
345
  "ast_rate": ast_ok / max(n, 1), "compile_rate": compile_ok / max(n, 1),
346
  "details": out}
 
 
347
 
348
  def eval_security_mcq(tokenizer, model, n_questions: int = 25) -> dict:
349
  import torch
350
-
351
  try:
352
  rows = _fetch_first_rows("CyberNative/CyberSecurityEval", None, "train", n_questions * 2)
353
- except Exception as exc: # noqa: BLE001
354
  return {"section": "security_mcq", "error": repr(exc)[:200], "skipped": True}
355
-
356
  rows = rows[:n_questions]
357
  if not rows:
358
  return {"section": "security_mcq", "skipped": True, "reason": "no rows"}
359
-
360
- correct = 0
361
- details = []
362
  for r in rows:
363
  question = r.get("question") or r.get("prompt") or r.get("input")
364
  options = r.get("options") or r.get("choices") or r.get("answers")
@@ -371,40 +347,30 @@ def eval_security_mcq(tokenizer, model, n_questions: int = 25) -> dict:
371
  else:
372
  opts = "\n".join(f"{i}. {o}" for i, o in enumerate(options))
373
  key_map = {str(i): options[i]}
374
-
375
  user = f"Question: {question}\n\n{opts}\n\nRespond with the letter of the correct answer only."
376
  text = tokenizer.apply_chat_template(
377
- [{"role": "user", "content": user}], tokenize=False, add_generation_prompt=True,
378
- )
379
  ids = tokenizer(text, return_tensors="pt", add_special_tokens=False).input_ids.to(model.device)
380
  try:
381
  with torch.no_grad():
382
  generated = model.generate(ids, max_new_tokens=8, do_sample=False)
383
  reply = tokenizer.decode(generated[0, ids.shape[-1]:], skip_special_tokens=True).strip()
384
- except Exception: # noqa: BLE001
385
  continue
386
-
387
  first_letter = reply[:1].upper()
388
  predicted = key_map.get(first_letter)
389
  is_correct = predicted == answer
390
  correct += int(is_correct)
391
  details.append({"question": str(question)[:80], "reply": reply[:10], "ok": is_correct})
392
-
393
- return {
394
- "section": "security_mcq",
395
- "n_questions": len(details),
396
- "accuracy": correct / max(len(details), 1),
397
- "details": details,
398
- }
399
 
400
 
401
  def main() -> int:
402
  args = parse_args()
403
  token = os.environ.get("HF_TOKEN")
404
  if not token:
405
- log.error("HF_TOKEN not set")
406
- return 1
407
-
408
  os.makedirs(args.out_dir, exist_ok=True)
409
  import torch
410
  from transformers import AutoTokenizer
@@ -414,8 +380,7 @@ def main() -> int:
414
  random.seed(args.seed)
415
  log.info("loading adapter %s on top of %s ...", args.adapter, args.base)
416
  model, tokenizer = FastLanguageModel.from_pretrained(
417
- model_name=args.base, max_seq_length=2048, dtype=torch.bfloat16, load_in_4bit=True,
418
- )
419
  model = PeftModel.from_pretrained(model, args.adapter, token=token)
420
  log.info("adapter loaded")
421
 
@@ -435,7 +400,6 @@ def main() -> int:
435
  ],
436
  "raw": sections,
437
  }
438
-
439
  out_json = os.path.join(args.out_dir, "report.json")
440
  with open(out_json, "w", encoding="utf-8") as fh:
441
  json.dump(summary, fh, indent=2, default=str)
@@ -460,7 +424,6 @@ def main() -> int:
460
  repo_id=args.upload_repo, repo_type="model",
461
  commit_message="Add evaluation report")
462
  log.info("report pushed to https://huggingface.co/%s", args.upload_repo)
463
-
464
  return 0
465
 
466
 
 
3
  # dependencies = [
4
  # "datasets",
5
  # "huggingface_hub",
6
+ # "torch",
7
+ # "transformers",
8
+ # "peft",
9
+ # "unsloth",
10
  # ]
11
  # ///
12
  """Cheap evaluations for SecureCoder.
 
22
 
23
  3. Security knowledge - CyberSecurityEval MCQ when available, otherwise skip.
24
 
25
+ Writes results to --out-dir/report.json, prints a summary table, and uploads
26
+ the report to --upload-repo if set (default: the adapter repo itself).
27
  """
28
 
29
  from __future__ import annotations
 
67
 
68
  TOOL_FN_PAT = re.compile(r"<function=([A-Za-z0-9_\.]+)>", re.S)
69
  TOOL_PARAM_PAT = re.compile(r"<parameter=([A-Za-z0-9_]+)>\s*(.*?)\s*</parameter>", re.S)
70
+
71
 
72
 
73
  def parse_args() -> argparse.Namespace:
 
81
  p.add_argument("--max-new-tokens", type=int, default=384)
82
  p.add_argument("--seed", type=int, default=3407)
83
  return p.parse_args()
84
+
85
 
 
 
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  _TYPE_ALIASES = {
88
  "str": "string", "string": "string", "text": "string",
89
  "int": "integer", "integer": "integer", "long": "integer",
 
93
  }
94
 
95
 
96
+ def _normalise_tool_schema(tool):
97
  if not isinstance(tool, dict):
98
  return None
99
  fn = tool.get("function") if isinstance(tool.get("function"), dict) else tool
 
105
  required: list[str] = []
106
  for name, spec in params.items():
107
  if isinstance(spec, dict):
108
+ cleaned = {k: v for k, v in spec.items() if k in ("type", "description", "enum", "default", "title", "items")}
109
+ cleaned["type"] = _TYPE_ALIASES.get(str(cleaned.get("type", "")).lower(), "string")
 
 
110
  properties[name] = cleaned
111
  if "default" not in spec:
112
  required.append(name)
 
119
  return {"name": fn["name"], "description": fn.get("description", ""), "parameters": params}
120
 
121
 
122
+ def _messages_from_any(row, kind):
123
  tools: list[dict] = []
124
  raw = row.get("tools")
125
  candidates = [raw] if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
126
  for c in candidates:
127
+ n = _normalise_tool_schema(c)
128
  if n:
129
  tools.append(n)
 
130
  messages: list[dict] = []
131
  if isinstance(row.get("messages"), list):
132
  for turn in row["messages"]:
 
137
  if role in {"user", "assistant", "system"} and content:
138
  messages.append({"role": role, "content": str(content)})
139
  return messages, tools
 
140
  if row.get("query") and row.get("answers"):
141
  messages.append({"role": "user", "content": str(row["query"])})
142
  messages.append({"role": "assistant", "content": str(row["answers"])})
143
  return messages, tools
 
144
  if row.get("user") and row.get("assistant"):
145
  if row.get("system"):
146
  messages.append({"role": "system", "content": str(row["system"])})
147
  messages.append({"role": "user", "content": str(row["user"])})
148
  messages.append({"role": "assistant", "content": str(row["assistant"])})
149
  return messages, tools
 
150
  if row.get("instruction") and (row.get("output") or row.get("response")):
151
  messages.append({"role": "user", "content": str(row["instruction"])})
152
  messages.append({"role": "assistant", "content": str(row.get("output") or row.get("response"))})
153
  return messages, tools
 
154
  return [], tools
155
+
156
 
157
 
158
+ def _fetch_first_rows(repo: str, config: str | None, split: str, n: int) -> list[dict]:
159
+ from datasets import load_dataset
160
+ kwargs: dict[str, Any] = {"split": split, "streaming": True}
161
+ if config:
162
+ kwargs["name"] = config
163
+ ds = load_dataset(repo, token=os.environ.get("HF_TOKEN"), **kwargs)
164
+ out = []
165
+ for row in ds:
166
+ out.append(dict(row))
167
  if len(out) >= n:
168
  break
169
  return out
170
 
171
 
172
  def _build_tool_prompts(rows: list[dict]) -> list[dict]:
 
 
173
  prompts = []
174
  for row in rows:
175
  if not isinstance(row.get("messages"), list):
 
177
  tools_raw = row.get("tools")
178
  if not tools_raw:
179
  continue
180
+ tools: list[dict] = []
181
  if isinstance(tools_raw, list):
182
  for t in tools_raw:
183
+ n = _normalise_tool_schema(t)
184
  if n:
185
  tools.append(n)
186
  elif isinstance(tools_raw, dict):
187
+ n = _normalise_tool_schema(tools_raw)
188
  if n:
189
  tools.append(n)
190
  if not tools:
191
  continue
192
+ messages, _ = _messages_from_any(row, "auto")
193
  if not messages:
194
  continue
195
  user = next((m["content"] for m in messages if m["role"] == "user"), None)
 
236
  return {"parse": True, "name_ok": True,
237
  "schema_ok": expected.issubset(given) if expected else True,
238
  "expected_keys": sorted(expected), "given_keys": sorted(given)}
239
+
240
+
241
 
242
  def eval_tool_calls(tokenizer, model, args) -> dict:
243
  import torch
 
244
  log.info("tool-call eval: streaming candidates from hermes FC ...")
245
  rows = _fetch_first_rows("NousResearch/hermes-function-calling-v1", "func_calling", "train",
246
  args.tool_prompts * 4)
 
257
  with torch.no_grad():
258
  generated = model.generate(ids, max_new_tokens=args.max_new_tokens, do_sample=False)
259
  reply = tokenizer.decode(generated[0, ids.shape[-1]:], skip_special_tokens=True)
260
+ except Exception as exc:
261
  out.append({"prompt": p["prompt"][:60], "error": repr(exc)[:120]})
262
  continue
 
263
  calls = _parse_emitted_calls(reply)
264
  scored = [_score_call(c, p["tools"]) for c in calls]
265
+ out.append({"prompt": p["prompt"][:80], "reply_first_160": reply[:160],
266
+ "expected_call": p["expected_call"], "n_calls": len(calls),
267
+ "calls": calls, "scores": scored})
 
 
 
 
 
268
  if (i + 1) % 25 == 0:
269
  log.info(" tool-call progress: %d/%d", i + 1, len(prompts))
 
270
  n = len(out)
271
  parse_ok = sum(1 for r in out if r.get("scores") and any(s["parse"] for s in r["scores"]))
272
  name_ok = sum(1 for r in out if r.get("scores") and any(s["name_ok"] for s in r["scores"]))
 
278
 
279
  def eval_code_sanity(tokenizer, model, args) -> dict:
280
  import torch
 
281
  out = []
282
  prompts = CODE_PROMPTS[: args.code_prompts]
283
  for i, prompt in enumerate(prompts):
 
290
  with torch.no_grad():
291
  generated = model.generate(ids, max_new_tokens=384, do_sample=False)
292
  reply = tokenizer.decode(generated[0, ids.shape[-1]:], skip_special_tokens=True)
293
+ except Exception as exc:
294
  out.append({"prompt": prompt[:60], "error": repr(exc)[:120]})
295
  continue
 
296
  block = None
297
+ m = re.search(r"```(?:python)?\s*\n(.*?)```", reply, re.S)
298
+ if m:
299
+ block = m.group(1)
300
  else:
301
  start = reply.find("def ")
302
  if start >= 0:
303
  block = reply[start:]
 
304
  parsed = compiles = None
305
  if block:
306
  try:
307
+ ast.parse(block); parsed = True
 
308
  except SyntaxError:
309
+ parsed = False; block = None
 
310
  if block:
311
  try:
312
+ compile(block, "<eval>", "exec"); compiles = True
313
+ except Exception:
 
314
  compiles = False
315
  out.append({"prompt": prompt[:60], "ast_ok": parsed, "compile_ok": compiles,
316
  "reply_first_160": reply[:160]})
317
  if (i + 1) % 5 == 0:
318
  log.info(" code sanity: %d/%d", i + 1, len(prompts))
 
319
  n = len(out)
320
  ast_ok = sum(1 for r in out if r.get("ast_ok"))
321
  compile_ok = sum(1 for r in out if r.get("compile_ok"))
322
  return {"section": "code_sanity", "n_prompts": n,
323
  "ast_rate": ast_ok / max(n, 1), "compile_rate": compile_ok / max(n, 1),
324
  "details": out}
325
+
326
+
327
 
328
  def eval_security_mcq(tokenizer, model, n_questions: int = 25) -> dict:
329
  import torch
 
330
  try:
331
  rows = _fetch_first_rows("CyberNative/CyberSecurityEval", None, "train", n_questions * 2)
332
+ except Exception as exc:
333
  return {"section": "security_mcq", "error": repr(exc)[:200], "skipped": True}
 
334
  rows = rows[:n_questions]
335
  if not rows:
336
  return {"section": "security_mcq", "skipped": True, "reason": "no rows"}
337
+ correct = 0; details = []
 
 
338
  for r in rows:
339
  question = r.get("question") or r.get("prompt") or r.get("input")
340
  options = r.get("options") or r.get("choices") or r.get("answers")
 
347
  else:
348
  opts = "\n".join(f"{i}. {o}" for i, o in enumerate(options))
349
  key_map = {str(i): options[i]}
 
350
  user = f"Question: {question}\n\n{opts}\n\nRespond with the letter of the correct answer only."
351
  text = tokenizer.apply_chat_template(
352
+ [{"role": "user", "content": user}], tokenize=False, add_generation_prompt=True)
 
353
  ids = tokenizer(text, return_tensors="pt", add_special_tokens=False).input_ids.to(model.device)
354
  try:
355
  with torch.no_grad():
356
  generated = model.generate(ids, max_new_tokens=8, do_sample=False)
357
  reply = tokenizer.decode(generated[0, ids.shape[-1]:], skip_special_tokens=True).strip()
358
+ except Exception:
359
  continue
 
360
  first_letter = reply[:1].upper()
361
  predicted = key_map.get(first_letter)
362
  is_correct = predicted == answer
363
  correct += int(is_correct)
364
  details.append({"question": str(question)[:80], "reply": reply[:10], "ok": is_correct})
365
+ return {"section": "security_mcq", "n_questions": len(details),
366
+ "accuracy": correct / max(len(details), 1), "details": details}
 
 
 
 
 
367
 
368
 
369
  def main() -> int:
370
  args = parse_args()
371
  token = os.environ.get("HF_TOKEN")
372
  if not token:
373
+ log.error("HF_TOKEN not set"); return 1
 
 
374
  os.makedirs(args.out_dir, exist_ok=True)
375
  import torch
376
  from transformers import AutoTokenizer
 
380
  random.seed(args.seed)
381
  log.info("loading adapter %s on top of %s ...", args.adapter, args.base)
382
  model, tokenizer = FastLanguageModel.from_pretrained(
383
+ model_name=args.base, max_seq_length=2048, dtype=torch.bfloat16, load_in_4bit=True)
 
384
  model = PeftModel.from_pretrained(model, args.adapter, token=token)
385
  log.info("adapter loaded")
386
 
 
400
  ],
401
  "raw": sections,
402
  }
 
403
  out_json = os.path.join(args.out_dir, "report.json")
404
  with open(out_json, "w", encoding="utf-8") as fh:
405
  json.dump(summary, fh, indent=2, default=str)
 
424
  repo_id=args.upload_repo, repo_type="model",
425
  commit_message="Add evaluation report")
426
  log.info("report pushed to https://huggingface.co/%s", args.upload_repo)
 
427
  return 0
428
 
429