mjolnir-cli 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/.gitignore +6 -0
  2. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/PKG-INFO +2 -4
  3. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/__init__.py +1 -1
  4. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/__main__.py +2 -0
  5. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/accuracy.py +101 -8
  6. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/autoresearch.py +15 -16
  7. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/benchy.py +7 -5
  8. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +18842 -18842
  9. mjolnir_cli-0.2.1/mjolnir_cli/bundled/configs/nvidia/Qwen3.6-35B-A3B/NVFP4_DFLASH/.env +4 -0
  10. mjolnir_cli-0.2.1/mjolnir_cli/bundled/configs/nvidia/Qwen3.6-35B-A3B/NVFP4_DFLASH/config.yaml +42 -0
  11. mjolnir_cli-0.2.1/mjolnir_cli/bundled/configs/nvidia/Qwen3.6-35B-A3B/NVFP4_MJOLNIR/config.yaml +55 -0
  12. mjolnir_cli-0.2.1/mjolnir_cli/bundled/configs/nvidia/Qwen3.6-35B-A3B/NVFP4_MJOLNIR_E2M3/.env +3 -0
  13. mjolnir_cli-0.2.1/mjolnir_cli/bundled/configs/nvidia/Qwen3.6-35B-A3B/NVFP4_MJOLNIR_E2M3/config.yaml +48 -0
  14. mjolnir_cli-0.2.1/mjolnir_cli/bundled/configs/nvidia/Qwen3.6-35B-A3B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +3 -0
  15. mjolnir_cli-0.2.1/mjolnir_cli/bundled/configs/nvidia/Qwen3.6-35B-A3B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +41 -0
  16. mjolnir_cli-0.2.1/mjolnir_cli/bundled/configs/nvidia/Qwen3.6-35B-A3B/NVFP4_MJOLNIR_E2M3_VT64K/.env +2 -0
  17. mjolnir_cli-0.2.1/mjolnir_cli/bundled/configs/nvidia/Qwen3.6-35B-A3B/NVFP4_MJOLNIR_E2M3_VT64K/config.yaml +38 -0
  18. mjolnir_cli-0.2.1/mjolnir_cli/bundled/configs/nvidia/Qwen3.6-35B-A3B/draft_vocab.json +1 -0
  19. mjolnir_cli-0.2.1/mjolnir_cli/bundled/configs/nvidia/Qwen3.6-35B-A3B/kv_scales.json +11425 -0
  20. mjolnir_cli-0.2.0/mjolnir_cli/bundled/scripts/hw/setup-thor.sh → mjolnir_cli-0.2.1/mjolnir_cli/bundled/scripts/hw/setup-thor-steps.sh +2 -391
  21. mjolnir_cli-0.2.1/mjolnir_cli/bundled/scripts/hw/setup-thor.sh +395 -0
  22. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/cachectl.py +2 -3
  23. mjolnir_cli-0.2.1/mjolnir_cli/charts.py +539 -0
  24. mjolnir_cli-0.2.1/mjolnir_cli/charts_base.py +314 -0
  25. mjolnir_cli-0.2.1/mjolnir_cli/charts_e2e.py +420 -0
  26. mjolnir_cli-0.2.1/mjolnir_cli/charts_serving.py +541 -0
  27. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/cli.py +275 -820
  28. mjolnir_cli-0.2.1/mjolnir_cli/cli_bench.py +535 -0
  29. mjolnir_cli-0.2.1/mjolnir_cli/cli_shared.py +42 -0
  30. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/clidoc.py +1 -1
  31. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/config.py +60 -9
  32. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/dockerctl.py +69 -20
  33. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/drivers/fa2_driver.py +16 -16
  34. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/drivers/fi_driver.py +1 -1
  35. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/e2e.py +23 -8
  36. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/gate.py +4 -4
  37. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/history.py +3 -3
  38. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/litellm.py +4 -4
  39. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/perses.py +6 -6
  40. mjolnir_cli-0.2.1/mjolnir_cli/py.typed +0 -0
  41. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/readme.py +133 -451
  42. mjolnir_cli-0.2.1/mjolnir_cli/readme_tables.py +400 -0
  43. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/stack.py +8 -7
  44. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/tasks.py +1 -1
  45. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/theme.py +13 -14
  46. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/verify/cli.py +37 -37
  47. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/verify/docker.py +13 -4
  48. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/verify/kvcache.py +1 -1
  49. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/verify/legs.py +1 -1
  50. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/verify/ncu.py +45 -15
  51. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/verify/profile.py +7 -5
  52. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/verify/report.py +1 -2
  53. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/verify/results.py +3 -3
  54. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/verify/stages.py +16 -9
  55. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/pyproject.toml +1 -3
  56. mjolnir_cli-0.2.0/mjolnir_cli/charts.py +0 -1687
  57. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/LICENSE +0 -0
  58. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/NOTICE +0 -0
  59. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/README.md +0 -0
  60. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/hatch_build.py +0 -0
  61. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +0 -0
  62. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +0 -0
  63. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +0 -0
  64. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +0 -0
  65. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +0 -0
  66. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +0 -0
  67. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +0 -0
  68. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +0 -0
  69. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +0 -0
  70. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +0 -0
  71. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +0 -0
  72. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +0 -0
  73. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +0 -0
  74. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +0 -0
  75. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +0 -0
  76. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +0 -0
  77. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +0 -0
  78. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +0 -0
  79. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +0 -0
  80. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +0 -0
  81. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/docker/litellm/litellm.env.example +0 -0
  82. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +0 -0
  83. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/docker/perses/README.md +0 -0
  84. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +0 -0
  85. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +0 -0
  86. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +0 -0
  87. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +0 -0
  88. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/docker/perses/docker-compose.yaml +0 -0
  89. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/docker/perses/perses.yml +0 -0
  90. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +0 -0
  91. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
  92. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
  93. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
  94. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
  95. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/fonts/OFL.txt +0 -0
  96. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/interactive.py +0 -0
  97. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/legs.py +0 -0
  98. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/picker.py +0 -0
  99. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/thor.py +0 -0
  100. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/verify/__init__.py +0 -0
  101. {mjolnir_cli-0.2.0 → mjolnir_cli-0.2.1}/mjolnir_cli/verify/spec.py +0 -0
@@ -3,6 +3,12 @@ __pycache__/
3
3
  .venv/
4
4
  .pytest_cache/
5
5
  .ruff_cache/
6
+ .env
7
+ # coverage artifacts (the CI test stage writes these; they regenerate)
8
+ .coverage
9
+ .coverage.*
10
+ coverage.xml
11
+ .coverage_html/
6
12
  dist/
7
13
  build/
8
14
  *.egg-info/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mjolnir-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Serve, benchmark and document LLMs on NVIDIA Jetson AGX Thor: YAML model configs, the Mjolnir vLLM image, KV-cache calibration, kernel / serving / accuracy benchmarks and auto-rendered reports.
5
5
  Project-URL: Homepage, https://github.com/thor-arena/mjolnir
6
6
  Project-URL: Documentation, https://github.com/thor-arena/mjolnir/tree/main/docs
@@ -18,13 +18,11 @@ Classifier: Environment :: Console
18
18
  Classifier: Intended Audience :: Developers
19
19
  Classifier: Operating System :: POSIX :: Linux
20
20
  Classifier: Programming Language :: Python :: 3
21
- Classifier: Programming Language :: Python :: 3.10
22
- Classifier: Programming Language :: Python :: 3.11
23
21
  Classifier: Programming Language :: Python :: 3.12
24
22
  Classifier: Programming Language :: Python :: 3.13
25
23
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
26
24
  Classifier: Topic :: System :: Benchmark
27
- Requires-Python: >=3.10
25
+ Requires-Python: >=3.12
28
26
  Requires-Dist: jinja2>=3.1.6
29
27
  Requires-Dist: pyyaml>=6.0.3
30
28
  Requires-Dist: rich>=15.0.0
@@ -5,4 +5,4 @@ kernel against FlashInfer and FlashAttention 2, and render the
5
5
  data-driven README charts — one command surface.
6
6
  """
7
7
 
8
- __version__ = "0.2.0"
8
+ __version__ = "0.2.1"
@@ -1,3 +1,5 @@
1
+ """Run the ``mjolnir`` CLI via ``python -m mjolnir_cli``."""
2
+
1
3
  from mjolnir_cli.cli import app
2
4
 
3
5
  if __name__ == "__main__":
@@ -31,6 +31,8 @@ import time
31
31
  from pathlib import Path
32
32
  from statistics import mean
33
33
 
34
+ from mjolnir_cli.config import by_model, model_of, model_short
35
+
34
36
  FORMAT = "mjolnir.accuracy/1"
35
37
  TOOL_REPO = "https://github.com/SeraphimSerapis/tool-eval-bench"
36
38
  # pinned: a score is only comparable to one produced by the same scenario set
@@ -109,7 +111,7 @@ def summarize_db(db: Path) -> dict:
109
111
  cats: dict[str, dict] = {}
110
112
  scen: dict[str, list[float]] = {}
111
113
  runs = []
112
- for run_id, created, status, run_type, scores_json, config_json in rows:
114
+ for run_id, created, status, run_type, scores_json, _config_json in rows:
113
115
  runs.append({"run_id": run_id, "created_at": created, "status": status, "run_type": run_type})
114
116
  if status != "completed":
115
117
  continue
@@ -160,6 +162,11 @@ def summarize_db(db: Path) -> dict:
160
162
  continue
161
163
  last = dict(items[-1])
162
164
  last.pop("score", None)
165
+ if all("max" in i for i in items): # every trial's points: deltas rescale each trial exactly
166
+ last["earneds"], last["maxes"] = [i["earned"] for i in items], [i["max"] for i in items]
167
+ comps = [i["completion_rate"] for i in items if i.get("completion_rate") is not None]
168
+ if comps:
169
+ last["completion_rate"] = min(comps) # the worst trial: an exclusion in any trial shows
163
170
  suites[name] = {"score": round(mean(scores), 2), "scores": scores, "n": len(scores), **last}
164
171
  return {
165
172
  "suites": suites,
@@ -298,7 +305,7 @@ def latest_legs(docs: list[dict]) -> list[dict]:
298
305
  rows: dict[tuple, dict] = {}
299
306
  for d in docs:
300
307
  for leg in d.get("legs", []):
301
- key = (leg.get("model"), leg.get("image"), leg.get("config"))
308
+ key = (model_of(leg), leg.get("image"), leg.get("config"))
302
309
  row = rows.setdefault(
303
310
  key,
304
311
  {k: leg.get(k) for k in ("id", "model", "image", "config", "label", "kv_cache_dtype", "env")}
@@ -312,16 +319,102 @@ def latest_legs(docs: list[dict]) -> list[dict]:
312
319
  if (leg.get("suites") or {}).keys() & {"tools", "hardmode"}:
313
320
  row["categories"] = leg.get("categories") or row["categories"]
314
321
  row["scenarios"] = leg.get("scenarios") or row["scenarios"]
315
- return [r for r in rows.values() if r["suites"]]
322
+ groups = by_model(r for r in rows.values() if r["suites"])
323
+ if len(groups) > 1: # rows of several models: each model's rows together, labels say which
324
+ for r in (r for g in groups.values() for r in g):
325
+ r["label"] = f"{model_short(r)} · {r['label']}"
326
+ return [r for g in groups.values() for r in g]
316
327
 
317
328
 
318
329
  def baseline(rows: list[dict]) -> dict | None:
319
- """The reference row for deltas: native FlashInfer on the official image
320
- when measured, else the first row."""
321
- for r in rows:
322
- if not _is_mjolnir(str(r.get("image", ""))) and r.get("config") == "NVFP4":
330
+ """The reference row for deltas among ONE model's rows: native FlashInfer
331
+ on the official image when measured, else the first official-image row,
332
+ else the first row. For mixed rows use :func:`baselines`."""
333
+ official = [r for r in rows if not _is_mjolnir(str(r.get("image", "")))]
334
+ for r in official:
335
+ if r.get("config") == "NVFP4":
323
336
  return r
324
- return rows[0] if rows else None
337
+ return (official or rows or [None])[0]
338
+
339
+
340
+ def baselines(rows: list[dict]) -> list[dict | None]:
341
+ """Each row's baseline, in row order: its own model's :func:`baseline`.
342
+ Rows of another model are never a baseline — their scores are not comparable."""
343
+ per_model = {m: baseline(group) for m, group in by_model(rows).items()}
344
+ return [per_model[model_of(r)] for r in rows]
345
+
346
+
347
+ def _maxes(s: dict) -> list:
348
+ """Every trial's max for a suite (older records keep only the last trial's)."""
349
+ return list(s.get("maxes") or ([s["max"]] if s.get("max") else []))
350
+
351
+
352
+ def _on_max(s: dict, m: float) -> float:
353
+ """The suite's score with every trial's points over ``m``: exact from the
354
+ per-trial points when recorded, else from the one run's points, else from
355
+ the mean % (an older averaged record, which keeps only the last max)."""
356
+ if s.get("earneds"):
357
+ return mean(e / m * 100 for e in s["earneds"])
358
+ if s.get("n", 1) == 1 and "earned" in s:
359
+ return s["earned"] / m * 100
360
+ return s["score"] * s["max"] / m
361
+
362
+
363
+ def delta(r: dict, base: dict | None, name: str) -> tuple[float, bool] | None:
364
+ """Points ``r`` scored over ``base`` on suite ``name``, and whether an
365
+ excluded scenario was scored as zero (None when ``r`` is the baseline,
366
+ either lacks the suite, or the two measured different scenario sets).
367
+
368
+ tool-eval-bench drops a scenario it judged the endpoint unable to serve
369
+ (TC-45 when its ``tool_choice=required`` probe fails, a timeout, an error),
370
+ which shrinks that trial's max and lowers its completion rate, so its %
371
+ divides by less. Then every trial of both legs is scored on the largest
372
+ max, the excluded points counting as zero: an exclusion never raises a
373
+ delta. Different maxes at full completion mean different scenario sets
374
+ (another run's arguments), which no rescaling makes comparable."""
375
+ if base is None or r is base:
376
+ return None
377
+ v, b = r.get("suites", {}).get(name) or {}, base.get("suites", {}).get(name) or {}
378
+ if v.get("score") is None or b.get("score") is None:
379
+ return None
380
+ maxes = _maxes(v) + _maxes(b)
381
+ if len(set(maxes)) > 1:
382
+ if all(x.get("completion_rate", 100.0) >= 100.0 for x in (v, b)):
383
+ return None
384
+ m = max(maxes)
385
+ return _on_max(v, m) - _on_max(b, m), True
386
+ return v["score"] - b["score"], False
387
+
388
+
389
+ def fmt_delta(d: tuple[float, bool] | None) -> str:
390
+ """' (+1.2)', ' (-1.4‡)' when an excluded scenario was scored as zero, '' for no delta."""
391
+ return f" ({d[0]:+.1f}{'‡' if d[1] else ''})" if d else ""
392
+
393
+
394
+ def leg_excluded(r: dict, base: dict | None, names: list[str]) -> bool:
395
+ """Whether any of ``r``'s suite deltas scored an excluded scenario as zero."""
396
+ return any((d := delta(r, base, n)) and d[1] for n in names)
397
+
398
+
399
+ def any_excluded(rows: list[dict], bases: list[dict | None], names: list[str]) -> bool:
400
+ return any(leg_excluded(r, b, names) for r, b in zip(rows, bases, strict=True))
401
+
402
+
403
+ EXCLUDED_NOTE = (
404
+ "‡ = tool-eval-bench excluded a scenario on one of the two legs; the delta scores both on the full max, "
405
+ "the excluded points counting as zero"
406
+ )
407
+ EXCLUDED_NOTE_SHORT = "‡ = a scenario was excluded on one side, scored as zero"
408
+ CATEGORY_EXCLUDED_NOTE = (
409
+ "‡ = this leg had a scenario excluded: its category % divides by the smaller max, so one category reads high"
410
+ )
411
+
412
+
413
+ def baseline_caption(bases: list[dict | None], item_sep: str = " · ") -> str:
414
+ """The distinct baseline labels, first-seen order, ' / '-joined ('' if none);
415
+ ``item_sep`` replaces the label's own ' · ' separator."""
416
+ labels = dict.fromkeys(b["label"].replace(" · ", item_sep) for b in bases if b is not None)
417
+ return " / ".join(labels)
325
418
 
326
419
 
327
420
  def suites_in(rows: list[dict]) -> list[str]:
@@ -39,7 +39,6 @@ import json
39
39
  import shutil
40
40
  import subprocess
41
41
  from pathlib import Path
42
- from typing import Optional
43
42
 
44
43
  import typer
45
44
  from rich import box
@@ -119,7 +118,7 @@ MAX_SUSPECT = 2 # more suspect L than this and the measurement itself is reject
119
118
 
120
119
  def _gm(xs) -> float:
121
120
  xs = list(xs)
122
- return results.geomean(xs) if xs else 1.0
121
+ return results.geomean(xs) or 1.0 # geomean is None only when no positive value survives: neutral 1.0
123
122
 
124
123
 
125
124
  def _pct(x: float) -> str:
@@ -158,7 +157,7 @@ def judge(row: dict, best: dict | None, min_gain: float = 0.02, noise: float = N
158
157
  if best is None:
159
158
  return "baseline", "the starting kernel", {}
160
159
  new, old = row.get("per_L") or {}, best.get("per_L") or {}
161
- common = [L for L in new if L in old and old[L]]
160
+ common = [L for L in new if old.get(L)]
162
161
  if not common: # rows written before per-L judging: the plain geomean rule
163
162
  st = decide(score, correct, best.get("score"), min_gain)
164
163
  return st, f"geomean {_pct(score / best['score'])} (no per-L reference)", {}
@@ -172,8 +171,8 @@ def judge(row: dict, best: dict | None, min_gain: float = 0.02, noise: float = N
172
171
  gain = _gm(trusted.values())
173
172
  if gain <= 1 + min_gain:
174
173
  return "discard", f"geomean {_pct(gain)} ≤ +{min_gain * 100:g}%{note}", rel
175
- top = max(trusted, key=trusted.get)
176
- loo = _gm(v for L, v in trusted.items() if L != top)
174
+ top = max(trusted, key=lambda L: trusted[L])
175
+ loo = _gm(v for L, v in trusted.items() if top != L)
177
176
  if loo <= 1 + min_gain / 2:
178
177
  return (
179
178
  "discard",
@@ -207,7 +206,7 @@ def _root() -> Path:
207
206
  # ── commands ────────────────────────────────────────────────────────────────
208
207
 
209
208
 
210
- def _init(root: Path, tag: str, base: str, evaluate_baseline: bool, image: Optional[str]) -> int:
209
+ def _init(root: Path, tag: str, base: str, evaluate_baseline: bool, image: str | None) -> int:
211
210
  branch = f"autoresearch/{tag}"
212
211
  if _git(root, "branch", "--list", branch):
213
212
  console.print(f"[red]{branch} already exists — pick a fresh tag (a run never reuses a branch)[/red]")
@@ -232,7 +231,7 @@ def init(
232
231
  tag: str = typer.Argument(..., help="run tag, e.g. oct11 — the branch is autoresearch/<tag>"),
233
232
  base: str = typer.Option("main", "--base", help="branch to start from"),
234
233
  evaluate_baseline: bool = typer.Option(True, "--baseline/--no-baseline", help="score the starting kernel now"),
235
- image: Optional[str] = typer.Option(None, "--image"),
234
+ image: str | None = typer.Option(None, "--image"),
236
235
  ):
237
236
  """Create the research branch and score the starting kernel (the baseline row)."""
238
237
  raise typer.Exit(_init(_root(), tag, base, evaluate_baseline, image))
@@ -243,9 +242,9 @@ def _evaluate(
243
242
  hypothesis: str,
244
243
  agent: str,
245
244
  sources: str,
246
- image: Optional[str],
245
+ image: str | None,
247
246
  min_gain: float = 0.02,
248
- ls: Optional[str] = None,
247
+ ls: str | None = None,
249
248
  iters: int = 50,
250
249
  gate: bool = True,
251
250
  skip_correctness: bool = False,
@@ -301,7 +300,7 @@ def _evaluate(
301
300
  by = results.series_by_id(doc)
302
301
  if "fi" in by and "mjolnir" in by:
303
302
  sp = results.speedup(by["fi"], by["mjolnir"])
304
- want = set(int(x) for x in (ls or " ".join(map(str, ALL_LS))).replace(",", " ").split())
303
+ want = {int(x) for x in (ls or " ".join(map(str, ALL_LS))).replace(",", " ").split()}
305
304
  if sp and set(sp) >= want:
306
305
  score = results.geomean(sp.values())
307
306
  per_L = {str(k): round(v, 4) for k, v in sp.items()}
@@ -310,7 +309,7 @@ def _evaluate(
310
309
  sweep_run = doc["run_id"]
311
310
  row = {
312
311
  "id": len(rows) + 1,
313
- "ts": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
312
+ "ts": _dt.datetime.now(_dt.UTC).isoformat(timespec="seconds"),
314
313
  "branch": _git(root, "rev-parse", "--abbrev-ref", "HEAD"),
315
314
  "commit": commit,
316
315
  "status": None,
@@ -390,10 +389,10 @@ def evaluate(
390
389
  agent: str = typer.Option("autoresearch", "--agent", help="who proposed/implemented it"),
391
390
  sources: str = typer.Option("", "--sources", help="comma-separated URLs / arXiv ids behind the idea"),
392
391
  min_gain: float = typer.Option(0.02, "--min-gain", help="required geomean gain over the best kept score"),
393
- ls: Optional[str] = typer.Option(None, "--ls", help="L values (default: all 8; a subset is never a keep)"),
392
+ ls: str | None = typer.Option(None, "--ls", help="L values (default: all 8; a subset is never a keep)"),
394
393
  iters: int = typer.Option(50, "--iters"),
395
394
  gate: bool = typer.Option(True, "--gate/--no-gate"),
396
- image: Optional[str] = typer.Option(None, "--image"),
395
+ image: str | None = typer.Option(None, "--image"),
397
396
  ):
398
397
  """Score the committed kernel: quick correctness + FlashInfer vs Mjolnir sweep;
399
398
  append the result and the fixed keep/discard decision to the ledger."""
@@ -464,8 +463,8 @@ def chart():
464
463
 
465
464
  @app.command()
466
465
  def run(
467
- tag: Optional[str] = typer.Option(None, "--tag", help="start a new run first (init <tag>)"),
468
- model: Optional[str] = typer.Option(None, "--model", help="OpenCode model (provider/model) for the orchestrator"),
466
+ tag: str | None = typer.Option(None, "--tag", help="start a new run first (init <tag>)"),
467
+ model: str | None = typer.Option(None, "--model", help="OpenCode model (provider/model) for the orchestrator"),
469
468
  prompt: str = typer.Option(
470
469
  "Read research/program.md and run the loop: setup is done when the ledger has a baseline row. "
471
470
  "Never stop to ask; record every experiment with `mjolnir autoresearch evaluate`.",
@@ -494,7 +493,7 @@ def run(
494
493
 
495
494
  @app.command()
496
495
  def export(
497
- tag: Optional[str] = typer.Option(None, "--tag", help="run tag (default: from the autoresearch/<tag> branch)"),
496
+ tag: str | None = typer.Option(None, "--tag", help="run tag (default: from the autoresearch/<tag> branch)"),
498
497
  ):
499
498
  """Snapshot the ledger into research/runs/<tag>/ (ledger.jsonl, ledger.tsv,
500
499
  SUMMARY.md) — commit that to keep the run's notebook with the repo."""
@@ -115,8 +115,8 @@ def _std(vals: list[float]) -> float:
115
115
  return (sum((v - m) ** 2 for v in vals) / (len(vals) - 1)) ** 0.5
116
116
 
117
117
 
118
- def _p95(vals: list[float]) -> float:
119
- """Linear-interpolated 95th percentile."""
118
+ def _p95(vals: list[float]) -> float | None:
119
+ """Linear-interpolated 95th percentile (``None`` on empty input)."""
120
120
  if not vals:
121
121
  return None
122
122
  s = sorted(vals)
@@ -295,7 +295,7 @@ def run_perf(
295
295
  # pooled like the totals: accepted at each position over all drafts
296
296
  "per_pos": [
297
297
  round(sum(col) / tot["drafts"], 4)
298
- for col in zip(*(x.get("per_pos_accepted") or [] for x in sweep_spec))
298
+ for col in zip(*(x.get("per_pos_accepted") or [] for x in sweep_spec), strict=False)
299
299
  ]
300
300
  if tot["drafts"]
301
301
  else [],
@@ -438,13 +438,15 @@ def _pipe_table(headers: list[str], rows: list[list[str]]) -> str:
438
438
 
439
439
  def data_row(cells: list[str]) -> str:
440
440
  return (
441
- "| " + " | ".join(c.rjust(w) if a == ">" else c.ljust(w) for c, w, a in zip(cells, widths, aligns)) + " |"
441
+ "| "
442
+ + " | ".join(c.rjust(w) if a == ">" else c.ljust(w) for c, w, a in zip(cells, widths, aligns, strict=True))
443
+ + " |"
442
444
  )
443
445
 
444
446
  def sep_cell(a: str, w: int) -> str:
445
447
  return ":" + "-" * (w - 1) if a == "<" else "-" * (w - 1) + ":"
446
448
 
447
- sep = "|" + "|".join(sep_cell(a, w) for w, a in zip(widths, aligns)) + "|"
449
+ sep = "|" + "|".join(sep_cell(a, w) for w, a in zip(widths, aligns, strict=True)) + "|"
448
450
  return "\n".join([data_row(headers), sep, *map(data_row, rows)])
449
451
 
450
452