MSG msgencrypted-auto commited on
Commit
28543d3
·
1 Parent(s): aac5f23

Feat/last sprintos (#23)

Browse files

* skills

* fix readme

* readme track

* readme track

* experiment

* experiment and finetuning fix

* experiment and finetuning fix

* server finetuning common

* fix

* fix

---------

Co-authored-by: msgencrypted-auto <msgencrypted.auto@gmail.com>

README.md CHANGED
@@ -10,8 +10,21 @@ python_version: "3.12"
10
  pinned: false
11
  license: apache-2.0
12
  tags:
 
 
 
 
 
 
 
 
 
 
 
 
13
  - build-small-hackathon
14
  - backyard-ai
 
15
  - tiny-titan
16
  - best-agent
17
  - best-demo
@@ -19,8 +32,14 @@ tags:
19
  - sharing-is-caring
20
  - off-the-grid
21
  - off-brand
 
 
 
 
22
  ---
23
 
 
 
24
  # Lesson Agent
25
 
26
  **Backyard AI** Gradio Space for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
 
10
  pinned: false
11
  license: apache-2.0
12
  tags:
13
+ - track:backyard
14
+ - track:wood
15
+ - sponsor:openbmb
16
+ - sponsor:openai
17
+ - sponsor:nvidia
18
+ - sponsor:modal
19
+ - achievement:offgrid
20
+ - achievement:welltuned
21
+ - achievement:offbrand
22
+ - achievement:llama
23
+ - achievement:sharing
24
+ - achievement:fieldnotes
25
  - build-small-hackathon
26
  - backyard-ai
27
+ - modal
28
  - tiny-titan
29
  - best-agent
30
  - best-demo
 
32
  - sharing-is-caring
33
  - off-the-grid
34
  - off-brand
35
+ - field-notes
36
+ - well-tuned
37
+ - llama-champion
38
+
39
  ---
40
 
41
+
42
+
43
  # Lesson Agent
44
 
45
  **Backyard AI** Gradio Space for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
modal_math_gate_publish_result.json ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "job": "math-lora",
3
+ "baseline_results": "/vol/finetuned/results/lm_eval/minicpm5-1b__baseline__math/results.json",
4
+ "candidate_results": "/vol/finetuned/results/lm_eval/math-lora__math/results.json",
5
+ "gate": {
6
+ "passed": true,
7
+ "checks": [
8
+ {
9
+ "check": "gsm8k >= 0.05",
10
+ "value": 0.4,
11
+ "ok": true
12
+ },
13
+ {
14
+ "check": "gsm8k improve >= 0.02",
15
+ "value": 0.07,
16
+ "ok": true
17
+ },
18
+ {
19
+ "check": "arc_challenge regress <= 0.03",
20
+ "value": -0.04999999999999999,
21
+ "ok": true
22
+ },
23
+ {
24
+ "check": "hellaswag regress <= 0.03",
25
+ "value": 0.0,
26
+ "ok": true
27
+ },
28
+ {
29
+ "check": "piqa regress <= 0.03",
30
+ "value": 0.020000000000000018,
31
+ "ok": true
32
+ }
33
+ ],
34
+ "task": "gsm8k",
35
+ "candidate_score": 0.4,
36
+ "baseline_score": 0.33
37
+ },
38
+ "publish": {
39
+ "published": true,
40
+ "repo_id": "MSGEncrypted/minicpm5-1b-math-lora",
41
+ "url": "https://huggingface.co/MSGEncrypted/minicpm5-1b-math-lora",
42
+ "uploads": [
43
+ {
44
+ "repo_id": "MSGEncrypted/minicpm5-1b-math-lora",
45
+ "url": "https://huggingface.co/MSGEncrypted/minicpm5-1b-math-lora"
46
+ },
47
+ {
48
+ "repo_id": "build-small-hackathon/minicpm5-1b-math-lora",
49
+ "url": "https://huggingface.co/build-small-hackathon/minicpm5-1b-math-lora"
50
+ }
51
+ ]
52
+ }
53
+ }
modal_science_gate_publish_result.json ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "job": "science-lora",
3
+ "baseline_results": "/vol/finetuned/results/lm_eval/minicpm5-1b__baseline__science/results.json",
4
+ "candidate_results": "/vol/finetuned/results/lm_eval/science-lora__science/results.json",
5
+ "gate": {
6
+ "passed": false,
7
+ "checks": [
8
+ {
9
+ "check": "sciq >= 0.5",
10
+ "value": 0.94,
11
+ "ok": true
12
+ },
13
+ {
14
+ "check": "sciq improve >= 0.02",
15
+ "value": -0.010000000000000009,
16
+ "ok": false
17
+ },
18
+ {
19
+ "check": "arc_challenge regress <= 0.03",
20
+ "value": -0.019999999999999962,
21
+ "ok": true
22
+ }
23
+ ],
24
+ "task": "sciq",
25
+ "candidate_score": 0.94,
26
+ "baseline_score": 0.95
27
+ },
28
+ "publish": {
29
+ "published": false,
30
+ "reason": "gate failed",
31
+ "gate": {
32
+ "passed": false,
33
+ "checks": [
34
+ {
35
+ "check": "sciq >= 0.5",
36
+ "value": 0.94,
37
+ "ok": true
38
+ },
39
+ {
40
+ "check": "sciq improve >= 0.02",
41
+ "value": -0.010000000000000009,
42
+ "ok": false
43
+ },
44
+ {
45
+ "check": "arc_challenge regress <= 0.03",
46
+ "value": -0.019999999999999962,
47
+ "ok": true
48
+ }
49
+ ],
50
+ "task": "sciq",
51
+ "candidate_score": 0.94,
52
+ "baseline_score": 0.95
53
+ }
54
+ }
55
+ }
research/modal/README.md CHANGED
@@ -60,7 +60,7 @@ uv sync --group modal # local orchestration only
60
  ## Run training + benchmarks
61
 
62
  All commands from **repo root**. `finetune_app.py` runs the full **skill-matrix
63
- pipeline**: per-profile baseline lm-eval → finetune each job's QLoRA adapter →
64
  post-train lm-eval vs. that baseline → check `goals` (gate) → publish to the
65
  Hugging Face Hub if the gate passes → pull adapter + results to your laptop.
66
 
@@ -119,7 +119,7 @@ for the `goals`/`publish` schema.
119
  | Flag | Default | Meaning |
120
  | ---- | ------- | ------- |
121
  | `--train` / `--no-train` | train on | Run finetune jobs |
122
- | `--eval-only` | off | Skip train + baselines; eval existing Volume checkpoints |
123
  | `--parallel` | off | `finetune_one.spawn()` per job instead of sequential |
124
  | `--job` | all jobs | Run one job name from `experiments.yaml` |
125
  | `--category` | all categories | Run all jobs with this `category` |
@@ -208,7 +208,7 @@ Task flags (`--job`, `--category`, `--cmd`, `--pipeline`, `--eval-only`, `--publ
208
  | `--category` | — | Run the skill-matrix pipeline for all jobs in a category |
209
  | `--pipeline` | off | Run the skill-matrix pipeline for all jobs |
210
  | `--max-steps` | from YAML | Override training steps |
211
- | `--eval-only` | off | Pipeline eval/gate/publish path only (skip baselines + train) |
212
  | `--publish` / `--no-publish` | publish on | Push to `publish.hub_repo` if the gate passes |
213
  | `--publish-only` | off | Re-check the gate against existing results and publish (requires `--job`) |
214
  | `--pull` / `--no-pull` | pull on | `modal volume get` adapter + results after the pipeline |
@@ -469,6 +469,11 @@ goals:
469
  max_regress: 0.03
470
  ```
471
 
 
 
 
 
 
472
  A job with no `goals` (e.g. `alpaca-lora`) is never gated and never published —
473
  it's local-only (still trained, evaluated, and pulled to your laptop).
474
 
@@ -482,10 +487,13 @@ publish:
482
 
483
  ### What happens on a passing gate
484
 
485
- 1. `run_lm_eval` writes `results/lm_eval/<job>__<profile>/results.json`.
486
- 2. `check_gate` compares it against `results/lm_eval/<preset>__baseline__<profile>/results.json`
487
- using the `goals` above → `{"passed": bool, "checks": [...]}`.
488
- 3. If `passed` and `publish` is set, `publish_adapter`:
 
 
 
489
  - renders a model card (`README.md`) into the adapter directory — base model,
490
  gate checks table, full lm-eval baseline-vs-candidate-vs-delta table,
491
  training stats, and a PEFT load snippet
 
60
  ## Run training + benchmarks
61
 
62
  All commands from **repo root**. `finetune_app.py` runs the full **skill-matrix
63
+ pipeline**: per-profile **base-model** baseline lm-eval (no adapter) → finetune each job's QLoRA adapter →
64
  post-train lm-eval vs. that baseline → check `goals` (gate) → publish to the
65
  Hugging Face Hub if the gate passes → pull adapter + results to your laptop.
66
 
 
119
  | Flag | Default | Meaning |
120
  | ---- | ------- | ------- |
121
  | `--train` / `--no-train` | train on | Run finetune jobs |
122
+ | `--eval-only` | off | Skip train; eval existing Volume checkpoints (still runs missing base-model baselines) |
123
  | `--parallel` | off | `finetune_one.spawn()` per job instead of sequential |
124
  | `--job` | all jobs | Run one job name from `experiments.yaml` |
125
  | `--category` | all categories | Run all jobs with this `category` |
 
208
  | `--category` | — | Run the skill-matrix pipeline for all jobs in a category |
209
  | `--pipeline` | off | Run the skill-matrix pipeline for all jobs |
210
  | `--max-steps` | from YAML | Override training steps |
211
+ | `--eval-only` | off | Pipeline eval/gate/publish only (skip train; still runs missing base-model baselines) |
212
  | `--publish` / `--no-publish` | publish on | Push to `publish.hub_repo` if the gate passes |
213
  | `--publish-only` | off | Re-check the gate against existing results and publish (requires `--job`) |
214
  | `--pull` / `--no-pull` | pull on | `modal volume get` adapter + results after the pipeline |
 
469
  max_regress: 0.03
470
  ```
471
 
472
+ Publishable jobs also run a **general** eval (`defaults.general_eval_profile`, default
473
+ `compare_study`: arc_easy, arc_challenge, hellaswag, piqa, boolq, gsm8k) and must pass
474
+ `defaults.general_goals` regression guards so skill tuning does not wash out general
475
+ capability. The publish gate requires **both** skill `goals` and `general_goals` to pass.
476
+
477
  A job with no `goals` (e.g. `alpaca-lora`) is never gated and never published —
478
  it's local-only (still trained, evaluated, and pulled to your laptop).
479
 
 
487
 
488
  ### What happens on a passing gate
489
 
490
+ 1. `run_lm_eval` writes skill results to `results/lm_eval/<job>__<profile>/results.json`.
491
+ 2. For publishable jobs, a second run writes general results to
492
+ `results/lm_eval/<job>__<general_eval_profile>/results.json`.
493
+ 3. `check_gate` compares skill results against `results/lm_eval/<preset>__baseline__<profile>/results.json`
494
+ and general results against `results/lm_eval/<preset>__baseline__<general_eval_profile>/results.json`
495
+ using `goals` + `general_goals` → `{"passed": bool, "skill": {...}, "general": {...}, "checks": [...]}`.
496
+ 4. If `passed` and `publish` is set, `publish_adapter`:
497
  - renders a model card (`README.md`) into the adapter directory — base model,
498
  gate checks table, full lm-eval baseline-vs-candidate-vs-delta table,
499
  training stats, and a PEFT load snippet
research/modal/_common.py CHANGED
@@ -36,6 +36,8 @@ BASE_MODEL_ID = "openbmb/MiniCPM5-1B"
36
 
37
  BASELINE_EXPERIMENT = "minicpm5-1b__modal-baseline"
38
  BASELINE_RESULTS_JSON = f"{LM_EVAL_OUTPUT}/{BASELINE_EXPERIMENT}/results.json"
 
 
39
 
40
  # Metric keys to prefer when picking a task's "primary" score, in priority
41
  # order. Covers lm-eval-harness multiple-choice (acc), generation (exact_match),
@@ -87,6 +89,9 @@ image = (
87
  COMMON_ENV = {
88
  "TRUST_REMOTE_CODE": "true",
89
  "HF_HOME": HF_CACHE_PATH,
 
 
 
90
  "PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True",
91
  }
92
 
@@ -100,9 +105,24 @@ def repo_env() -> dict[str, str]:
100
  return {**os.environ, **COMMON_ENV}
101
 
102
 
103
- def reload_volumes() -> None:
 
 
 
 
 
 
 
 
 
 
 
104
  finetune_vol.reload()
105
- hf_cache_vol.reload()
 
 
 
 
106
 
107
 
108
  def commit_volumes() -> None:
@@ -385,6 +405,105 @@ def job_gpu(job: dict[str, Any]) -> str:
385
  return job.get("gpu") or DEFAULT_GPU
386
 
387
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  def config_for_profile(profile: str) -> str:
389
  """Map an eval_profiles.yaml profile name to its config path (relative to repo root)."""
390
  with EVAL_PROFILES_PATH.open() as f:
@@ -462,7 +581,7 @@ def evaluate_gate(
462
  """Check a candidate's lm-eval results dict against `goals` (Hub publish gate).
463
 
464
  `goals` schema:
465
- task: <lm-eval task name> # scored via primary_metric(), same as summary.md
466
  min_score: <float, optional> # candidate score must be >= this
467
  min_improve: <float, optional> # candidate - baseline must be >= this
468
  guard_tasks: # optional regression guards
@@ -482,9 +601,11 @@ def evaluate_gate(
482
  checks: list[dict[str, Any]] = []
483
  passed = True
484
 
485
- task = goals["task"]
486
- cand_score = _score(cand_tasks, task)
487
- base_score = _score(base_tasks, task)
 
 
488
 
489
  # Tolerance so a score landing exactly on a threshold (e.g. a clean +0.02
490
  # improvement stored as 0.0199999996) is not rejected by float epsilon.
@@ -576,6 +697,61 @@ def check_gate_files(
576
  return evaluate_gate(candidate=candidate, baseline=baseline, goals=goals)
577
 
578
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
579
  def render_model_card(
580
  *,
581
  job: dict[str, Any],
@@ -624,16 +800,36 @@ def render_model_card(
624
  "",
625
  "## Benchmark gate",
626
  "",
627
- f"- eval profile: `{job.get('eval_profile')}`",
628
  f"- gate: {'**PASSED**' if gate_result.get('passed') else '**FAILED**'}",
629
  "",
630
- "| check | value | result |",
631
- "| --- | ---: | --- |",
632
  ]
633
- for c in gate_result.get("checks", []):
634
- lines.append(f"| {c['check']} | {_fmt(c['value'])} | {'pass' if c['ok'] else 'fail'} |")
635
- if not gate_result.get("checks"):
636
- lines.append("| — | — | — |")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
637
 
638
  lines.extend(
639
  [
 
36
 
37
  BASELINE_EXPERIMENT = "minicpm5-1b__modal-baseline"
38
  BASELINE_RESULTS_JSON = f"{LM_EVAL_OUTPUT}/{BASELINE_EXPERIMENT}/results.json"
39
+ # Shared general-capability profile for publish gates (limit 100; see compare_study).
40
+ GENERAL_EVAL_PROFILE = "compare_study"
41
 
42
  # Metric keys to prefer when picking a task's "primary" score, in priority
43
  # order. Covers lm-eval-harness multiple-choice (acc), generation (exact_match),
 
89
  COMMON_ENV = {
90
  "TRUST_REMOTE_CODE": "true",
91
  "HF_HOME": HF_CACHE_PATH,
92
+ # Keep hf-xet logs off the HF cache Volume mount so volume.reload() is not
93
+ # blocked by open log file handles on warm containers.
94
+ "HF_XET_LOG_DEST": "/tmp/xet-logs/",
95
  "PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True",
96
  }
97
 
 
105
  return {**os.environ, **COMMON_ENV}
106
 
107
 
108
+ def _reload_volume_safe(vol: modal.Volume, *, label: str) -> None:
109
+ """Reload a Volume; skip (with warning) when open files block the operation."""
110
+ try:
111
+ vol.reload()
112
+ except (RuntimeError, modal.exception.ConflictError) as exc:
113
+ if "open files preventing the operation" in str(exc):
114
+ print(f"warning: skipping {label} volume reload ({exc})")
115
+ return
116
+ raise
117
+
118
+
119
+ def reload_finetune_volume() -> None:
120
  finetune_vol.reload()
121
+
122
+
123
+ def reload_volumes() -> None:
124
+ reload_finetune_volume()
125
+ _reload_volume_safe(hf_cache_vol, label="hf-cache")
126
 
127
 
128
  def commit_volumes() -> None:
 
405
  return job.get("gpu") or DEFAULT_GPU
406
 
407
 
408
+ def job_needs_general_gate(job: dict[str, Any]) -> bool:
409
+ """Publishable jobs run a second general eval and must pass `general_goals`."""
410
+ return bool(job.get("goals") and job.get("publish"))
411
+
412
+
413
+ def general_eval_profile(defaults: dict[str, Any]) -> str:
414
+ return defaults.get("general_eval_profile", GENERAL_EVAL_PROFILE)
415
+
416
+
417
+ def general_goals_for_job(
418
+ job: dict[str, Any], defaults: dict[str, Any]
419
+ ) -> dict[str, Any] | None:
420
+ if not job_needs_general_gate(job):
421
+ return None
422
+ goals = job.get("general_goals") or defaults.get("general_goals")
423
+ return goals if goals else None
424
+
425
+
426
+ def baseline_profiles_for_jobs(
427
+ jobs: list[dict[str, Any]], defaults: dict[str, Any]
428
+ ) -> list[str]:
429
+ profiles = {j.get("eval_profile", "compare_study") for j in jobs}
430
+ if any(job_needs_general_gate(j) for j in jobs):
431
+ profiles.add(general_eval_profile(defaults))
432
+ return sorted(profiles)
433
+
434
+
435
+ def baseline_experiment_name(preset: str, profile: str) -> str:
436
+ """Volume path key for the unfine-tuned base model on a given eval profile."""
437
+ return f"{preset}__baseline__{profile}"
438
+
439
+
440
+ def _load_models_registry() -> dict[str, Any]:
441
+ path = REPO_ROOT / "models.yaml"
442
+ if not path.is_file():
443
+ path = Path("/repo") / "models.yaml"
444
+ if not path.is_file():
445
+ return {}
446
+ with path.open() as f:
447
+ return yaml.safe_load(f) or {}
448
+
449
+
450
+ def resolve_base_model_id(job: dict[str, Any], defaults: dict[str, Any]) -> str:
451
+ """Hub/path id of the base model this job fine-tunes — used as the eval baseline."""
452
+ explicit = job.get("model") or (job.get("args") or {}).get("model")
453
+ if explicit:
454
+ return str(explicit)
455
+ preset = job.get("preset", defaults.get("preset", "minicpm5-1b"))
456
+ entry = (_load_models_registry().get("models") or {}).get(preset) or {}
457
+ return entry.get("model_id") or BASE_MODEL_ID
458
+
459
+
460
+ def discover_cached_baselines(
461
+ profile_names: list[str],
462
+ *,
463
+ preset: str,
464
+ eval_tasks: list[str] | None = None,
465
+ eval_limit: int | None = None,
466
+ eval_num_fewshot: int | None = None,
467
+ eval_seed: int | None = None,
468
+ ) -> dict[str, bool]:
469
+ """True per profile when base-model baseline results already exist on the Volume."""
470
+ cached: dict[str, bool] = {}
471
+ for profile in profile_names:
472
+ cached[profile] = baseline_is_cached(
473
+ baseline_experiment_name(preset, profile),
474
+ config_for_profile(profile),
475
+ tasks=eval_tasks,
476
+ limit=eval_limit,
477
+ num_fewshot=eval_num_fewshot,
478
+ seed=eval_seed,
479
+ )
480
+ return cached
481
+
482
+
483
+ def profiles_needing_baseline_run(
484
+ profile_names: list[str],
485
+ cached: dict[str, bool],
486
+ *,
487
+ skip_baseline: bool,
488
+ ) -> list[str]:
489
+ if skip_baseline:
490
+ return []
491
+ return [profile for profile in profile_names if not cached.get(profile)]
492
+
493
+
494
+ def eval_paths(
495
+ *,
496
+ job_name: str,
497
+ preset: str,
498
+ profile: str,
499
+ ) -> tuple[str, str, str]:
500
+ """Return (candidate_results_path, baseline_results_path, experiment_name)."""
501
+ exp_name = f"{job_name}__{profile}"
502
+ candidate = f"{LM_EVAL_OUTPUT}/{exp_name}/results.json"
503
+ baseline = f"{LM_EVAL_OUTPUT}/{baseline_experiment_name(preset, profile)}/results.json"
504
+ return candidate, baseline, exp_name
505
+
506
+
507
  def config_for_profile(profile: str) -> str:
508
  """Map an eval_profiles.yaml profile name to its config path (relative to repo root)."""
509
  with EVAL_PROFILES_PATH.open() as f:
 
581
  """Check a candidate's lm-eval results dict against `goals` (Hub publish gate).
582
 
583
  `goals` schema:
584
+ task: <lm-eval task name, optional when only guard_tasks are set>
585
  min_score: <float, optional> # candidate score must be >= this
586
  min_improve: <float, optional> # candidate - baseline must be >= this
587
  guard_tasks: # optional regression guards
 
601
  checks: list[dict[str, Any]] = []
602
  passed = True
603
 
604
+ task = goals.get("task")
605
+ cand_score = base_score = None
606
+ if task:
607
+ cand_score = _score(cand_tasks, task)
608
+ base_score = _score(base_tasks, task)
609
 
610
  # Tolerance so a score landing exactly on a threshold (e.g. a clean +0.02
611
  # improvement stored as 0.0199999996) is not rejected by float epsilon.
 
697
  return evaluate_gate(candidate=candidate, baseline=baseline, goals=goals)
698
 
699
 
700
+ def check_publish_gate_files(
701
+ *,
702
+ skill_candidate_path: str,
703
+ skill_baseline_path: str | None,
704
+ skill_goals: dict[str, Any],
705
+ general_candidate_path: str | None = None,
706
+ general_baseline_path: str | None = None,
707
+ general_goals: dict[str, Any] | None = None,
708
+ ) -> dict[str, Any]:
709
+ """Gate on skill-specific eval plus optional general-capability eval."""
710
+ skill_gate = check_gate_files(
711
+ candidate_results_path=skill_candidate_path,
712
+ baseline_results_path=skill_baseline_path,
713
+ goals=skill_goals,
714
+ )
715
+ general_gate: dict[str, Any] | None = None
716
+ if general_goals:
717
+ if not general_candidate_path:
718
+ general_gate = {
719
+ "passed": False,
720
+ "checks": [
721
+ {
722
+ "check": "general eval results missing",
723
+ "value": None,
724
+ "ok": False,
725
+ }
726
+ ],
727
+ "reason": "general candidate results path not provided",
728
+ }
729
+ else:
730
+ general_gate = check_gate_files(
731
+ candidate_results_path=general_candidate_path,
732
+ baseline_results_path=general_baseline_path,
733
+ goals=general_goals,
734
+ )
735
+
736
+ passed = skill_gate.get("passed") and (
737
+ general_gate is None or general_gate.get("passed")
738
+ )
739
+ checks = list(skill_gate.get("checks", []))
740
+ if general_gate:
741
+ for check in general_gate.get("checks", []):
742
+ checks.append({**check, "check": f"general: {check['check']}"})
743
+
744
+ return {
745
+ "passed": passed,
746
+ "checks": checks,
747
+ "skill": skill_gate,
748
+ "general": general_gate,
749
+ "task": skill_gate.get("task"),
750
+ "candidate_score": skill_gate.get("candidate_score"),
751
+ "baseline_score": skill_gate.get("baseline_score"),
752
+ }
753
+
754
+
755
  def render_model_card(
756
  *,
757
  job: dict[str, Any],
 
800
  "",
801
  "## Benchmark gate",
802
  "",
803
+ f"- skill eval profile: `{job.get('eval_profile')}`",
804
  f"- gate: {'**PASSED**' if gate_result.get('passed') else '**FAILED**'}",
805
  "",
 
 
806
  ]
807
+
808
+ def _gate_table(section: dict[str, Any] | None, *, prefix: str = "") -> list[str]:
809
+ if not section:
810
+ return []
811
+ out = [
812
+ f"### {prefix}checks".strip(),
813
+ "",
814
+ "| check | value | result |",
815
+ "| --- | ---: | --- |",
816
+ ]
817
+ for c in section.get("checks", []):
818
+ out.append(
819
+ f"| {c['check']} | {_fmt(c['value'])} | {'pass' if c['ok'] else 'fail'} |"
820
+ )
821
+ if not section.get("checks"):
822
+ out.append("| — | — | — |")
823
+ out.append("")
824
+ return out
825
+
826
+ skill_section = gate_result.get("skill") or gate_result
827
+ lines.extend(_gate_table(skill_section, prefix="Skill "))
828
+ if gate_result.get("general"):
829
+ gen_profile = job.get("general_eval_profile") or GENERAL_EVAL_PROFILE
830
+ lines.append(f"- general eval profile: `{gen_profile}`")
831
+ lines.append("")
832
+ lines.extend(_gate_table(gate_result["general"], prefix="General "))
833
 
834
  lines.extend(
835
  [
research/modal/experiments.yaml CHANGED
@@ -5,6 +5,10 @@
5
  # checks the result against `goals`, and — only if the gate passes —
6
  # publishes the adapter to `publish.hub_repo` on the Hugging Face Hub.
7
  #
 
 
 
 
8
  # Smoke limits (max_steps, max_samples, eval `limit` in the profile configs)
9
  # keep hackathon runs affordable; bump them for full runs.
10
  #
@@ -27,6 +31,22 @@ defaults:
27
  max_steps: 100
28
  # Hugging Face namespace for published adapters.
29
  hub_org: MSGEncrypted
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  finetune:
32
  # --- teaching: lesson-planning agent chat data (Well-Tuned primary) ---
@@ -62,14 +82,27 @@ finetune:
62
  private: false
63
 
64
  # --- science: factual + explanatory science tutoring ---
 
 
 
65
  - name: science-lora
66
  category: science
67
- dataset: research/data/science-tutor-chat.jsonl
68
- format: chat
 
 
 
 
 
 
 
69
  args:
 
 
 
70
  early_stopping_patience: 2 # keep best eval_loss checkpoint, not the last
71
  val_split: 0.05
72
- description: Science tutor Q&A chat data (local)
73
  eval_profile: science
74
  goals:
75
  task: sciq
 
5
  # checks the result against `goals`, and — only if the gate passes —
6
  # publishes the adapter to `publish.hub_repo` on the Hugging Face Hub.
7
  #
8
+ # Baselines are always the unfine-tuned base model (`defaults.preset` →
9
+ # models.yaml model_id, e.g. openbmb/MiniCPM5-1B), stored as
10
+ # results/lm_eval/<preset>__baseline__<profile>/ and reused across jobs.
11
+ #
12
  # Smoke limits (max_steps, max_samples, eval `limit` in the profile configs)
13
  # keep hackathon runs affordable; bump them for full runs.
14
  #
 
31
  max_steps: 100
32
  # Hugging Face namespace for published adapters.
33
  hub_org: MSGEncrypted
34
+ # Second eval pass for publish gates: balanced general SLM mix (limit 100).
35
+ general_eval_profile: compare_study
36
+ general_goals:
37
+ guard_tasks:
38
+ - task: arc_easy
39
+ max_regress: 0.03
40
+ - task: arc_challenge
41
+ max_regress: 0.03
42
+ - task: hellaswag
43
+ max_regress: 0.03
44
+ - task: piqa
45
+ max_regress: 0.03
46
+ - task: boolq
47
+ max_regress: 0.03
48
+ - task: gsm8k
49
+ max_regress: 0.03
50
 
51
  finetune:
52
  # --- teaching: lesson-planning agent chat data (Well-Tuned primary) ---
 
82
  private: false
83
 
84
  # --- science: factual + explanatory science tutoring ---
85
+ # 10 local science-tutor chats overfit easily on their own; mix in alpaca
86
+ # replay + NEFTune + bigger LoRA rank (same recipe as teaching/math/language)
87
+ # so sciq recall improves instead of regressing from narrow chat-format drift.
88
  - name: science-lora
89
  category: science
90
+ max_steps: 150
91
+ mix:
92
+ - dataset: research/data/science-tutor-chat.jsonl
93
+ format: chat
94
+ weight: 16 # ~10 samples -> ~160 examples
95
+ - dataset: tatsu-lab/alpaca # general replay: protect sciq/arc_challenge
96
+ format: alpaca
97
+ dataset_split: "train[:600]"
98
+ max_samples: 600
99
  args:
100
+ lora_r: 32
101
+ lora_alpha: 64
102
+ neftune_noise_alpha: 5
103
  early_stopping_patience: 2 # keep best eval_loss checkpoint, not the last
104
  val_split: 0.05
105
+ description: Science tutor Q&A chat + alpaca replay, r=32 + NEFTune
106
  eval_profile: science
107
  goals:
108
  task: sciq
research/modal/finetune_app.py CHANGED
@@ -36,16 +36,21 @@ for _candidate in (Path(__file__).resolve().parent, Path("/repo/research/modal")
36
  sys.path.insert(0, str(_candidate))
37
 
38
  from _common import ( # noqa: E402
39
- BASE_MODEL_ID,
40
  FINETUNE_VOL_PATH,
41
  HF_CACHE_PATH,
42
  LM_EVAL_OUTPUT,
 
43
  build_finetune_cmd,
44
  build_lm_eval_cmd,
45
  check_gate_files,
 
46
  commit_volumes,
47
  config_for_profile,
 
 
48
  finetune_vol,
 
 
49
  hf_cache_vol,
50
  hf_secret,
51
  image,
@@ -53,11 +58,15 @@ from _common import ( # noqa: E402
53
  job_plan_rows,
54
  parse_json_object,
55
  prepare_jobs,
 
 
56
  split_csv,
57
  publish_adapter_files,
58
  pull_artifacts,
 
59
  reload_volumes,
60
  repo_env,
 
61
  )
62
 
63
  APP_NAME = "slm-finetune-benchmark"
@@ -118,7 +127,7 @@ def run_lm_eval(
118
  seed: int | None = None,
119
  ) -> dict[str, Any]:
120
  """Run slm-lm-eval on base model or finetuned checkpoint."""
121
- reload_volumes()
122
 
123
  if adapter_path:
124
  adapter_dir = Path(adapter_path)
@@ -183,9 +192,21 @@ def check_gate(
183
  candidate_results_path: str,
184
  baseline_results_path: str | None,
185
  goals: dict[str, Any],
 
 
 
186
  ) -> dict[str, Any]:
187
- """Check a candidate's lm-eval results against `goals` (Hub publish gate)."""
188
- reload_volumes()
 
 
 
 
 
 
 
 
 
189
  return check_gate_files(
190
  candidate_results_path=candidate_results_path,
191
  baseline_results_path=baseline_results_path,
@@ -207,7 +228,7 @@ def publish_adapter(
207
  baseline_results_path: str | None,
208
  ) -> dict[str, Any]:
209
  """Write a model card and push the adapter to the Hub, but only if the gate passed."""
210
- reload_volumes()
211
  return publish_adapter_files(
212
  job=job,
213
  adapter_dir=adapter_dir,
@@ -284,17 +305,29 @@ def main(
284
  print(json.dumps({"preset": preset, "jobs": plan_rows}, indent=2))
285
  return
286
 
287
- profile_names = sorted({j.get("eval_profile", "compare_study") for j in prepared})
288
 
289
- baselines_ok: dict[str, bool] = {}
290
- if not eval_only and not skip_baseline:
291
- print(f"--- baselines ({', '.join(profile_names)}) ---")
292
- for profile in profile_names:
 
 
 
 
 
 
 
 
 
 
 
 
293
  result = run_lm_eval.remote(
294
- experiment_name=f"{preset}__baseline__{profile}",
295
  config=config_for_profile(profile),
296
  preset=preset,
297
- tasks=split_csv(eval_tasks),
298
  limit=eval_limit,
299
  num_fewshot=eval_num_fewshot,
300
  batch_size=eval_batch_size,
@@ -304,6 +337,9 @@ def main(
304
  )
305
  print(json.dumps(result, indent=2))
306
  baselines_ok[profile] = bool(result.get("ok"))
 
 
 
307
 
308
  train_results: dict[str, dict[str, Any]] = {}
309
  if train and not eval_only:
@@ -325,6 +361,7 @@ def main(
325
 
326
  print("--- post-train lm-eval / gate / publish ---")
327
  summary: list[dict[str, Any]] = []
 
328
  for j in prepared:
329
  job_name = j["name"]
330
  profile = j.get("eval_profile", "compare_study")
@@ -333,17 +370,18 @@ def main(
333
  train_payload["output_dir"] if train_payload else f"{FINETUNE_VOL_PATH}/{job_name}"
334
  )
335
 
336
- baseline_path = f"{LM_EVAL_OUTPUT}/{preset}__baseline__{profile}/results.json"
337
  compare_to = baseline_path if baselines_ok.get(profile) else None
 
338
 
339
  exp_name = f"{job_name}__{profile}"
340
  eval_result = run_lm_eval.remote(
341
  experiment_name=exp_name,
342
  config=config_for_profile(profile),
343
- model_path=BASE_MODEL_ID,
344
  adapter_path=adapter_path,
345
  compare_to=compare_to,
346
- tasks=split_csv(eval_tasks),
347
  limit=eval_limit,
348
  num_fewshot=eval_num_fewshot,
349
  batch_size=eval_batch_size,
@@ -353,20 +391,58 @@ def main(
353
  )
354
  print(json.dumps(eval_result, indent=2))
355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  row: dict[str, Any] = {
357
  "name": job_name,
358
  "category": j.get("category"),
359
  "profile": profile,
 
360
  "plan": next((p for p in plan_rows if p["name"] == job_name), None),
361
  }
362
 
363
  gate_result: dict[str, Any] | None = None
364
  if j.get("goals"):
365
- if eval_result.get("ok"):
 
 
 
 
 
366
  gate_result = check_gate.remote(
367
  candidate_results_path=eval_result["results_json"],
368
  baseline_results_path=baseline_path,
369
  goals=j["goals"],
 
 
 
370
  )
371
  print(json.dumps(gate_result, indent=2))
372
  row["gate_passed"] = bool(gate_result and gate_result.get("passed"))
@@ -388,6 +464,8 @@ def main(
388
 
389
  if pull:
390
  pull_artifacts(job_name, exp_name)
 
 
391
 
392
  _print_summary(summary)
393
 
@@ -404,14 +482,26 @@ def publish_only(job: str):
404
 
405
  preset = defaults.get("preset", "minicpm5-1b")
406
  profile = j.get("eval_profile", "compare_study")
 
 
407
  adapter_path = f"{FINETUNE_VOL_PATH}/{job}"
408
- candidate_results_path = f"{LM_EVAL_OUTPUT}/{job}__{profile}/results.json"
409
- baseline_results_path = f"{LM_EVAL_OUTPUT}/{preset}__baseline__{profile}/results.json"
 
 
 
 
 
 
 
410
 
411
  gate_result = check_gate.remote(
412
  candidate_results_path=candidate_results_path,
413
  baseline_results_path=baseline_results_path,
414
  goals=j["goals"],
 
 
 
415
  )
416
  print(json.dumps(gate_result, indent=2))
417
 
 
36
  sys.path.insert(0, str(_candidate))
37
 
38
  from _common import ( # noqa: E402
 
39
  FINETUNE_VOL_PATH,
40
  HF_CACHE_PATH,
41
  LM_EVAL_OUTPUT,
42
+ baseline_profiles_for_jobs,
43
  build_finetune_cmd,
44
  build_lm_eval_cmd,
45
  check_gate_files,
46
+ check_publish_gate_files,
47
  commit_volumes,
48
  config_for_profile,
49
+ discover_cached_baselines,
50
+ eval_paths,
51
  finetune_vol,
52
+ general_eval_profile,
53
+ general_goals_for_job,
54
  hf_cache_vol,
55
  hf_secret,
56
  image,
 
58
  job_plan_rows,
59
  parse_json_object,
60
  prepare_jobs,
61
+ profiles_needing_baseline_run,
62
+ resolve_base_model_id,
63
  split_csv,
64
  publish_adapter_files,
65
  pull_artifacts,
66
+ reload_finetune_volume,
67
  reload_volumes,
68
  repo_env,
69
+ baseline_experiment_name,
70
  )
71
 
72
  APP_NAME = "slm-finetune-benchmark"
 
127
  seed: int | None = None,
128
  ) -> dict[str, Any]:
129
  """Run slm-lm-eval on base model or finetuned checkpoint."""
130
+ reload_finetune_volume()
131
 
132
  if adapter_path:
133
  adapter_dir = Path(adapter_path)
 
192
  candidate_results_path: str,
193
  baseline_results_path: str | None,
194
  goals: dict[str, Any],
195
+ general_candidate_results_path: str | None = None,
196
+ general_baseline_results_path: str | None = None,
197
+ general_goals: dict[str, Any] | None = None,
198
  ) -> dict[str, Any]:
199
+ """Check skill + general lm-eval results against publish goals."""
200
+ reload_finetune_volume()
201
+ if general_goals:
202
+ return check_publish_gate_files(
203
+ skill_candidate_path=candidate_results_path,
204
+ skill_baseline_path=baseline_results_path,
205
+ skill_goals=goals,
206
+ general_candidate_path=general_candidate_results_path,
207
+ general_baseline_path=general_baseline_results_path,
208
+ general_goals=general_goals,
209
+ )
210
  return check_gate_files(
211
  candidate_results_path=candidate_results_path,
212
  baseline_results_path=baseline_results_path,
 
228
  baseline_results_path: str | None,
229
  ) -> dict[str, Any]:
230
  """Write a model card and push the adapter to the Hub, but only if the gate passed."""
231
+ reload_finetune_volume()
232
  return publish_adapter_files(
233
  job=job,
234
  adapter_dir=adapter_dir,
 
305
  print(json.dumps({"preset": preset, "jobs": plan_rows}, indent=2))
306
  return
307
 
308
+ profile_names = baseline_profiles_for_jobs(prepared, defaults)
309
 
310
+ eval_task_list = split_csv(eval_tasks)
311
+ baselines_ok = discover_cached_baselines(
312
+ profile_names,
313
+ preset=preset,
314
+ eval_tasks=eval_task_list,
315
+ eval_limit=eval_limit,
316
+ eval_num_fewshot=eval_num_fewshot,
317
+ eval_seed=eval_seed,
318
+ )
319
+ missing_baselines = profiles_needing_baseline_run(
320
+ profile_names, baselines_ok, skip_baseline=skip_baseline
321
+ )
322
+ if missing_baselines:
323
+ print(f"--- base-model baselines ({', '.join(missing_baselines)}) ---")
324
+ for profile in missing_baselines:
325
+ exp = baseline_experiment_name(preset, profile)
326
  result = run_lm_eval.remote(
327
+ experiment_name=exp,
328
  config=config_for_profile(profile),
329
  preset=preset,
330
+ tasks=eval_task_list,
331
  limit=eval_limit,
332
  num_fewshot=eval_num_fewshot,
333
  batch_size=eval_batch_size,
 
337
  )
338
  print(json.dumps(result, indent=2))
339
  baselines_ok[profile] = bool(result.get("ok"))
340
+ elif any(baselines_ok.values()):
341
+ cached = [p for p in profile_names if baselines_ok.get(p)]
342
+ print(f"--- base-model baselines: reusing cached ({', '.join(cached)}) ---")
343
 
344
  train_results: dict[str, dict[str, Any]] = {}
345
  if train and not eval_only:
 
361
 
362
  print("--- post-train lm-eval / gate / publish ---")
363
  summary: list[dict[str, Any]] = []
364
+ gen_profile = general_eval_profile(defaults)
365
  for j in prepared:
366
  job_name = j["name"]
367
  profile = j.get("eval_profile", "compare_study")
 
370
  train_payload["output_dir"] if train_payload else f"{FINETUNE_VOL_PATH}/{job_name}"
371
  )
372
 
373
+ baseline_path = f"{LM_EVAL_OUTPUT}/{baseline_experiment_name(preset, profile)}/results.json"
374
  compare_to = baseline_path if baselines_ok.get(profile) else None
375
+ base_model_id = resolve_base_model_id(j, defaults)
376
 
377
  exp_name = f"{job_name}__{profile}"
378
  eval_result = run_lm_eval.remote(
379
  experiment_name=exp_name,
380
  config=config_for_profile(profile),
381
+ model_path=base_model_id,
382
  adapter_path=adapter_path,
383
  compare_to=compare_to,
384
+ tasks=eval_task_list,
385
  limit=eval_limit,
386
  num_fewshot=eval_num_fewshot,
387
  batch_size=eval_batch_size,
 
391
  )
392
  print(json.dumps(eval_result, indent=2))
393
 
394
+ general_goals = general_goals_for_job(j, defaults)
395
+ general_eval_result: dict[str, Any] | None = None
396
+ general_candidate_path: str | None = None
397
+ general_baseline_path: str | None = None
398
+ if general_goals:
399
+ general_baseline_path = (
400
+ f"{LM_EVAL_OUTPUT}/{baseline_experiment_name(preset, gen_profile)}/results.json"
401
+ )
402
+ gen_compare_to = (
403
+ general_baseline_path if baselines_ok.get(gen_profile) else None
404
+ )
405
+ gen_exp_name = f"{job_name}__{gen_profile}"
406
+ general_eval_result = run_lm_eval.remote(
407
+ experiment_name=gen_exp_name,
408
+ config=config_for_profile(gen_profile),
409
+ model_path=base_model_id,
410
+ adapter_path=adapter_path,
411
+ compare_to=gen_compare_to,
412
+ tasks=eval_task_list,
413
+ limit=eval_limit,
414
+ num_fewshot=eval_num_fewshot,
415
+ batch_size=eval_batch_size,
416
+ device=eval_device,
417
+ dtype=eval_dtype,
418
+ seed=eval_seed,
419
+ )
420
+ print(json.dumps(general_eval_result, indent=2))
421
+ general_candidate_path = general_eval_result["results_json"]
422
+
423
  row: dict[str, Any] = {
424
  "name": job_name,
425
  "category": j.get("category"),
426
  "profile": profile,
427
+ "general_profile": gen_profile if general_goals else None,
428
  "plan": next((p for p in plan_rows if p["name"] == job_name), None),
429
  }
430
 
431
  gate_result: dict[str, Any] | None = None
432
  if j.get("goals"):
433
+ skill_ok = bool(eval_result.get("ok"))
434
+ general_ok = (
435
+ not general_goals
436
+ or bool(general_eval_result and general_eval_result.get("ok"))
437
+ )
438
+ if skill_ok and general_ok:
439
  gate_result = check_gate.remote(
440
  candidate_results_path=eval_result["results_json"],
441
  baseline_results_path=baseline_path,
442
  goals=j["goals"],
443
+ general_candidate_results_path=general_candidate_path,
444
+ general_baseline_results_path=general_baseline_path,
445
+ general_goals=general_goals,
446
  )
447
  print(json.dumps(gate_result, indent=2))
448
  row["gate_passed"] = bool(gate_result and gate_result.get("passed"))
 
464
 
465
  if pull:
466
  pull_artifacts(job_name, exp_name)
467
+ if general_goals and general_eval_result:
468
+ pull_artifacts(job_name, f"{job_name}__{gen_profile}", dest="models/finetuned")
469
 
470
  _print_summary(summary)
471
 
 
482
 
483
  preset = defaults.get("preset", "minicpm5-1b")
484
  profile = j.get("eval_profile", "compare_study")
485
+ gen_profile = general_eval_profile(defaults)
486
+ general_goals = general_goals_for_job(j, defaults)
487
  adapter_path = f"{FINETUNE_VOL_PATH}/{job}"
488
+ candidate_results_path, baseline_results_path, _ = eval_paths(
489
+ job_name=job, preset=preset, profile=profile
490
+ )
491
+ general_candidate_path = None
492
+ general_baseline_path = None
493
+ if general_goals:
494
+ general_candidate_path, general_baseline_path, _ = eval_paths(
495
+ job_name=job, preset=preset, profile=gen_profile
496
+ )
497
 
498
  gate_result = check_gate.remote(
499
  candidate_results_path=candidate_results_path,
500
  baseline_results_path=baseline_results_path,
501
  goals=j["goals"],
502
+ general_candidate_results_path=general_candidate_path,
503
+ general_baseline_results_path=general_baseline_path,
504
+ general_goals=general_goals,
505
  )
506
  print(json.dumps(gate_result, indent=2))
507
 
research/modal/server_app.py CHANGED
@@ -44,7 +44,6 @@ for _candidate in (Path(__file__).resolve().parent, Path("/repo/research/modal")
44
  sys.path.insert(0, str(_candidate))
45
 
46
  from _common import ( # noqa: E402
47
- BASE_MODEL_ID,
48
  DEFAULT_GPU,
49
  DEFAULT_KEEPALIVE_HOURS,
50
  DEFAULT_SCALEDOWN_WINDOW,
@@ -52,22 +51,31 @@ from _common import ( # noqa: E402
52
  FINETUNE_VOL_PATH,
53
  HF_CACHE_PATH,
54
  LM_EVAL_OUTPUT,
55
- baseline_is_cached,
 
56
  build_finetune_cmd,
57
  build_lm_eval_cmd,
58
  check_gate_files,
 
59
  commit_volumes,
60
  config_for_profile,
 
 
61
  finetune_vol,
 
 
62
  hf_cache_vol,
63
  hf_secret,
64
  image,
65
  job_plan_rows,
66
  parse_json_object,
67
  prepare_jobs,
 
 
68
  split_csv,
69
  publish_adapter_files,
70
  pull_artifacts,
 
71
  reload_volumes,
72
  repo_env,
73
  )
@@ -177,7 +185,9 @@ class GpuWorker:
177
  """Run slm-lm-eval on base model or finetuned checkpoint."""
178
  # Pick up adapters committed by another container (e.g. a separate
179
  # eval-only invocation) — the warm container's mount may predate them.
180
- reload_volumes()
 
 
181
 
182
  if adapter_path:
183
  adapter_dir = Path(adapter_path)
@@ -241,8 +251,20 @@ class GpuWorker:
241
  candidate_results_path: str,
242
  baseline_results_path: str | None,
243
  goals: dict[str, Any],
 
 
 
244
  ) -> dict[str, Any]:
245
- """Check a candidate's lm-eval results against `goals` (Hub publish gate)."""
 
 
 
 
 
 
 
 
 
246
  return check_gate_files(
247
  candidate_results_path=candidate_results_path,
248
  baseline_results_path=baseline_results_path,
@@ -313,40 +335,38 @@ class GpuWorker:
313
  raise ValueError("No jobs matched the requested filters")
314
 
315
  preset = defaults.get("preset", "minicpm5-1b")
316
- profile_names = sorted({j.get("eval_profile", "compare_study") for j in prepared})
317
  plan = job_plan_rows(prepared)
318
  if plan_only:
319
  return {"preset": preset, "jobs": plan}
320
 
321
- baselines_ok: dict[str, bool] = {}
322
- if not eval_only and not skip_baseline:
323
- for profile in profile_names:
324
- exp = f"{preset}__baseline__{profile}"
325
- cfg_path = config_for_profile(profile)
326
- if baseline_is_cached(
327
- exp,
328
- cfg_path,
329
- tasks=eval_tasks,
330
- limit=eval_limit,
331
- num_fewshot=eval_num_fewshot,
332
- seed=eval_seed,
333
- ):
334
- print(f"baseline {exp}: reusing cached results (config unchanged)")
335
- baselines_ok[profile] = True
336
- continue
337
- result = self.lm_eval.local(
338
- experiment_name=exp,
339
- config=cfg_path,
340
- preset=preset,
341
- tasks=eval_tasks,
342
- limit=eval_limit,
343
- num_fewshot=eval_num_fewshot,
344
- batch_size=eval_batch_size,
345
- device=eval_device,
346
- dtype=eval_dtype,
347
- seed=eval_seed,
348
- )
349
- baselines_ok[profile] = bool(result.get("ok"))
350
 
351
  train_results: dict[str, dict[str, Any]] = {}
352
  if train and not eval_only:
@@ -354,6 +374,7 @@ class GpuWorker:
354
  train_results[j["name"]] = self.finetune.local(j)
355
 
356
  rows: list[dict[str, Any]] = []
 
357
  for j in prepared:
358
  job_name = j["name"]
359
  profile = j.get("eval_profile", "compare_study")
@@ -364,14 +385,15 @@ class GpuWorker:
364
  else f"{FINETUNE_VOL_PATH}/{job_name}"
365
  )
366
 
367
- baseline_path = f"{LM_EVAL_OUTPUT}/{preset}__baseline__{profile}/results.json"
368
  compare_to = baseline_path if baselines_ok.get(profile) else None
 
369
 
370
  exp_name = f"{job_name}__{profile}"
371
  eval_result = self.lm_eval.local(
372
  experiment_name=exp_name,
373
  config=config_for_profile(profile),
374
- model_path=BASE_MODEL_ID,
375
  adapter_path=adapter_path,
376
  compare_to=compare_to,
377
  tasks=eval_tasks,
@@ -383,21 +405,60 @@ class GpuWorker:
383
  seed=eval_seed,
384
  )
385
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  row: dict[str, Any] = {
387
  "name": job_name,
388
  "category": j.get("category"),
389
  "profile": profile,
 
390
  "plan": next((p for p in plan if p["name"] == job_name), None),
391
  "eval": eval_result,
392
  }
 
 
393
 
394
  gate_result: dict[str, Any] | None = None
395
  if j.get("goals"):
396
- if eval_result.get("ok"):
 
 
 
 
 
397
  gate_result = self.check_gate.local(
398
  candidate_results_path=eval_result["results_json"],
399
  baseline_results_path=baseline_path,
400
  goals=j["goals"],
 
 
 
401
  )
402
  row["gate"] = gate_result
403
 
@@ -515,14 +576,26 @@ def main(
515
 
516
  preset = defaults.get("preset", "minicpm5-1b")
517
  profile = j.get("eval_profile", "compare_study")
 
 
518
  adapter_path = f"{FINETUNE_VOL_PATH}/{job}"
519
- candidate_results_path = f"{LM_EVAL_OUTPUT}/{job}__{profile}/results.json"
520
- baseline_results_path = f"{LM_EVAL_OUTPUT}/{preset}__baseline__{profile}/results.json"
 
 
 
 
 
 
 
521
 
522
  gate_result = worker.check_gate.remote(
523
  candidate_results_path=candidate_results_path,
524
  baseline_results_path=baseline_results_path,
525
  goals=j["goals"],
 
 
 
526
  )
527
  print(json.dumps(gate_result, indent=2))
528
 
@@ -570,6 +643,12 @@ def main(
570
  if pull:
571
  for row in result.get("jobs", []):
572
  pull_artifacts(row["name"], f"{row['name']}__{row['profile']}")
 
 
 
 
 
 
573
  return
574
 
575
  if serve:
 
44
  sys.path.insert(0, str(_candidate))
45
 
46
  from _common import ( # noqa: E402
 
47
  DEFAULT_GPU,
48
  DEFAULT_KEEPALIVE_HOURS,
49
  DEFAULT_SCALEDOWN_WINDOW,
 
51
  FINETUNE_VOL_PATH,
52
  HF_CACHE_PATH,
53
  LM_EVAL_OUTPUT,
54
+ baseline_experiment_name,
55
+ baseline_profiles_for_jobs,
56
  build_finetune_cmd,
57
  build_lm_eval_cmd,
58
  check_gate_files,
59
+ check_publish_gate_files,
60
  commit_volumes,
61
  config_for_profile,
62
+ discover_cached_baselines,
63
+ eval_paths,
64
  finetune_vol,
65
+ general_eval_profile,
66
+ general_goals_for_job,
67
  hf_cache_vol,
68
  hf_secret,
69
  image,
70
  job_plan_rows,
71
  parse_json_object,
72
  prepare_jobs,
73
+ profiles_needing_baseline_run,
74
+ resolve_base_model_id,
75
  split_csv,
76
  publish_adapter_files,
77
  pull_artifacts,
78
+ reload_finetune_volume,
79
  reload_volumes,
80
  repo_env,
81
  )
 
185
  """Run slm-lm-eval on base model or finetuned checkpoint."""
186
  # Pick up adapters committed by another container (e.g. a separate
187
  # eval-only invocation) — the warm container's mount may predate them.
188
+ # Only finetune_vol is needed here; reloading hf-cache can fail when
189
+ # hf-xet keeps log files open on the warm container's HF cache mount.
190
+ reload_finetune_volume()
191
 
192
  if adapter_path:
193
  adapter_dir = Path(adapter_path)
 
251
  candidate_results_path: str,
252
  baseline_results_path: str | None,
253
  goals: dict[str, Any],
254
+ general_candidate_results_path: str | None = None,
255
+ general_baseline_results_path: str | None = None,
256
+ general_goals: dict[str, Any] | None = None,
257
  ) -> dict[str, Any]:
258
+ """Check skill + general lm-eval results against publish goals."""
259
+ if general_goals:
260
+ return check_publish_gate_files(
261
+ skill_candidate_path=candidate_results_path,
262
+ skill_baseline_path=baseline_results_path,
263
+ skill_goals=goals,
264
+ general_candidate_path=general_candidate_results_path,
265
+ general_baseline_path=general_baseline_results_path,
266
+ general_goals=general_goals,
267
+ )
268
  return check_gate_files(
269
  candidate_results_path=candidate_results_path,
270
  baseline_results_path=baseline_results_path,
 
335
  raise ValueError("No jobs matched the requested filters")
336
 
337
  preset = defaults.get("preset", "minicpm5-1b")
338
+ profile_names = baseline_profiles_for_jobs(prepared, defaults)
339
  plan = job_plan_rows(prepared)
340
  if plan_only:
341
  return {"preset": preset, "jobs": plan}
342
 
343
+ baselines_ok = discover_cached_baselines(
344
+ profile_names,
345
+ preset=preset,
346
+ eval_tasks=eval_tasks,
347
+ eval_limit=eval_limit,
348
+ eval_num_fewshot=eval_num_fewshot,
349
+ eval_seed=eval_seed,
350
+ )
351
+ missing_baselines = profiles_needing_baseline_run(
352
+ profile_names, baselines_ok, skip_baseline=skip_baseline
353
+ )
354
+ for profile in missing_baselines:
355
+ exp = baseline_experiment_name(preset, profile)
356
+ cfg_path = config_for_profile(profile)
357
+ result = self.lm_eval.local(
358
+ experiment_name=exp,
359
+ config=cfg_path,
360
+ preset=preset,
361
+ tasks=eval_tasks,
362
+ limit=eval_limit,
363
+ num_fewshot=eval_num_fewshot,
364
+ batch_size=eval_batch_size,
365
+ device=eval_device,
366
+ dtype=eval_dtype,
367
+ seed=eval_seed,
368
+ )
369
+ baselines_ok[profile] = bool(result.get("ok"))
 
 
370
 
371
  train_results: dict[str, dict[str, Any]] = {}
372
  if train and not eval_only:
 
374
  train_results[j["name"]] = self.finetune.local(j)
375
 
376
  rows: list[dict[str, Any]] = []
377
+ gen_profile = general_eval_profile(defaults)
378
  for j in prepared:
379
  job_name = j["name"]
380
  profile = j.get("eval_profile", "compare_study")
 
385
  else f"{FINETUNE_VOL_PATH}/{job_name}"
386
  )
387
 
388
+ baseline_path = f"{LM_EVAL_OUTPUT}/{baseline_experiment_name(preset, profile)}/results.json"
389
  compare_to = baseline_path if baselines_ok.get(profile) else None
390
+ base_model_id = resolve_base_model_id(j, defaults)
391
 
392
  exp_name = f"{job_name}__{profile}"
393
  eval_result = self.lm_eval.local(
394
  experiment_name=exp_name,
395
  config=config_for_profile(profile),
396
+ model_path=base_model_id,
397
  adapter_path=adapter_path,
398
  compare_to=compare_to,
399
  tasks=eval_tasks,
 
405
  seed=eval_seed,
406
  )
407
 
408
+ general_goals = general_goals_for_job(j, defaults)
409
+ general_eval_result: dict[str, Any] | None = None
410
+ general_candidate_path: str | None = None
411
+ general_baseline_path: str | None = None
412
+ if general_goals:
413
+ general_baseline_path = (
414
+ f"{LM_EVAL_OUTPUT}/{baseline_experiment_name(preset, gen_profile)}/results.json"
415
+ )
416
+ gen_compare_to = (
417
+ general_baseline_path if baselines_ok.get(gen_profile) else None
418
+ )
419
+ gen_exp_name = f"{job_name}__{gen_profile}"
420
+ general_eval_result = self.lm_eval.local(
421
+ experiment_name=gen_exp_name,
422
+ config=config_for_profile(gen_profile),
423
+ model_path=base_model_id,
424
+ adapter_path=adapter_path,
425
+ compare_to=gen_compare_to,
426
+ tasks=eval_tasks,
427
+ limit=eval_limit,
428
+ num_fewshot=eval_num_fewshot,
429
+ batch_size=eval_batch_size,
430
+ device=eval_device,
431
+ dtype=eval_dtype,
432
+ seed=eval_seed,
433
+ )
434
+ general_candidate_path = general_eval_result["results_json"]
435
+
436
  row: dict[str, Any] = {
437
  "name": job_name,
438
  "category": j.get("category"),
439
  "profile": profile,
440
+ "general_profile": gen_profile if general_goals else None,
441
  "plan": next((p for p in plan if p["name"] == job_name), None),
442
  "eval": eval_result,
443
  }
444
+ if general_eval_result:
445
+ row["general_eval"] = general_eval_result
446
 
447
  gate_result: dict[str, Any] | None = None
448
  if j.get("goals"):
449
+ skill_ok = bool(eval_result.get("ok"))
450
+ general_ok = (
451
+ not general_goals
452
+ or bool(general_eval_result and general_eval_result.get("ok"))
453
+ )
454
+ if skill_ok and general_ok:
455
  gate_result = self.check_gate.local(
456
  candidate_results_path=eval_result["results_json"],
457
  baseline_results_path=baseline_path,
458
  goals=j["goals"],
459
+ general_candidate_results_path=general_candidate_path,
460
+ general_baseline_results_path=general_baseline_path,
461
+ general_goals=general_goals,
462
  )
463
  row["gate"] = gate_result
464
 
 
576
 
577
  preset = defaults.get("preset", "minicpm5-1b")
578
  profile = j.get("eval_profile", "compare_study")
579
+ gen_profile = general_eval_profile(defaults)
580
+ general_goals = general_goals_for_job(j, defaults)
581
  adapter_path = f"{FINETUNE_VOL_PATH}/{job}"
582
+ candidate_results_path, baseline_results_path, _ = eval_paths(
583
+ job_name=job, preset=preset, profile=profile
584
+ )
585
+ general_candidate_path = None
586
+ general_baseline_path = None
587
+ if general_goals:
588
+ general_candidate_path, general_baseline_path, _ = eval_paths(
589
+ job_name=job, preset=preset, profile=gen_profile
590
+ )
591
 
592
  gate_result = worker.check_gate.remote(
593
  candidate_results_path=candidate_results_path,
594
  baseline_results_path=baseline_results_path,
595
  goals=j["goals"],
596
+ general_candidate_results_path=general_candidate_path,
597
+ general_baseline_results_path=general_baseline_path,
598
+ general_goals=general_goals,
599
  )
600
  print(json.dumps(gate_result, indent=2))
601
 
 
643
  if pull:
644
  for row in result.get("jobs", []):
645
  pull_artifacts(row["name"], f"{row['name']}__{row['profile']}")
646
+ if row.get("general_profile"):
647
+ pull_artifacts(
648
+ row["name"],
649
+ f"{row['name']}__{row['general_profile']}",
650
+ dest="models/finetuned",
651
+ )
652
  return
653
 
654
  if serve:
research/modal/tests/test_modal_common.py CHANGED
@@ -1,13 +1,23 @@
1
  from pathlib import Path
2
 
 
3
  import sys
4
 
5
  sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
6
 
7
  from research.modal._common import ( # noqa: E402
 
 
 
8
  build_finetune_cmd,
9
  build_lm_eval_cmd,
 
 
 
 
10
  prepare_jobs,
 
 
11
  split_csv,
12
  )
13
 
@@ -74,3 +84,100 @@ def test_prepare_jobs_filters_and_applies_finetune_overrides():
74
  def test_split_csv_trims_empty_values():
75
  assert split_csv(" math, science ,,code ") == ["math", "science", "code"]
76
  assert split_csv(None) is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from pathlib import Path
2
 
3
+ import json
4
  import sys
5
 
6
  sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
7
 
8
  from research.modal._common import ( # noqa: E402
9
+ COMMON_ENV,
10
+ baseline_experiment_name,
11
+ baseline_profiles_for_jobs,
12
  build_finetune_cmd,
13
  build_lm_eval_cmd,
14
+ check_publish_gate_files,
15
+ discover_cached_baselines,
16
+ evaluate_gate,
17
+ general_goals_for_job,
18
  prepare_jobs,
19
+ profiles_needing_baseline_run,
20
+ resolve_base_model_id,
21
  split_csv,
22
  )
23
 
 
84
  def test_split_csv_trims_empty_values():
85
  assert split_csv(" math, science ,,code ") == ["math", "science", "code"]
86
  assert split_csv(None) is None
87
+
88
+
89
+ def _results(task_scores: dict[str, float]) -> dict:
90
+ return {
91
+ "results": {
92
+ task: {"acc,none": score, "acc_stderr,none": 0.01}
93
+ for task, score in task_scores.items()
94
+ }
95
+ }
96
+
97
+
98
+ def test_evaluate_gate_guard_only_goals():
99
+ candidate = _results({"arc_easy": 0.5, "hellaswag": 0.4})
100
+ baseline = _results({"arc_easy": 0.52, "hellaswag": 0.41})
101
+ goals = {
102
+ "guard_tasks": [
103
+ {"task": "arc_easy", "max_regress": 0.03},
104
+ {"task": "hellaswag", "max_regress": 0.03},
105
+ ]
106
+ }
107
+ gate = evaluate_gate(candidate=candidate, baseline=baseline, goals=goals)
108
+ assert gate["passed"] is True
109
+ assert len(gate["checks"]) == 2
110
+
111
+
112
+ def test_check_publish_gate_requires_both_skill_and_general(tmp_path):
113
+ skill_cand = tmp_path / "skill_cand.json"
114
+ skill_base = tmp_path / "skill_base.json"
115
+ general_cand = tmp_path / "general_cand.json"
116
+ general_base = tmp_path / "general_base.json"
117
+ skill_cand.write_text(
118
+ json.dumps(_results({"gsm8k": 0.4}))
119
+ )
120
+ skill_base.write_text(
121
+ json.dumps(_results({"gsm8k": 0.33}))
122
+ )
123
+ general_cand.write_text(
124
+ json.dumps(_results({"arc_easy": 0.5, "hellaswag": 0.4}))
125
+ )
126
+ general_base.write_text(
127
+ json.dumps(_results({"arc_easy": 0.52, "hellaswag": 0.41}))
128
+ )
129
+
130
+ gate = check_publish_gate_files(
131
+ skill_candidate_path=str(skill_cand),
132
+ skill_baseline_path=str(skill_base),
133
+ skill_goals={"task": "gsm8k", "min_improve": 0.02},
134
+ general_candidate_path=str(general_cand),
135
+ general_baseline_path=str(general_base),
136
+ general_goals={
137
+ "guard_tasks": [{"task": "arc_easy", "max_regress": 0.03}]
138
+ },
139
+ )
140
+ assert gate["passed"] is True
141
+ assert gate["skill"]["passed"] is True
142
+ assert gate["general"]["passed"] is True
143
+ assert any(c["check"].startswith("general:") for c in gate["checks"])
144
+
145
+
146
+ def test_baseline_profiles_include_general_for_publishable_jobs():
147
+ _, jobs = prepare_jobs(job="math-lora")
148
+ defaults = {"general_eval_profile": "compare_study", "general_goals": {"guard_tasks": []}}
149
+ profiles = baseline_profiles_for_jobs(jobs, defaults)
150
+ assert "math" in profiles
151
+ assert "compare_study" in profiles
152
+
153
+
154
+ def test_general_goals_only_for_publishable_jobs():
155
+ _, math_jobs = prepare_jobs(job="math-lora")
156
+ _, local_jobs = prepare_jobs(job="alpaca-lora")
157
+ defaults = {"general_goals": {"guard_tasks": [{"task": "piqa", "max_regress": 0.03}]}}
158
+ assert general_goals_for_job(math_jobs[0], defaults) is not None
159
+ assert general_goals_for_job(local_jobs[0], defaults) is None
160
+
161
+
162
+ def test_resolve_base_model_id_from_preset():
163
+ _, jobs = prepare_jobs(job="math-lora")
164
+ defaults, job = {}, jobs[0]
165
+ assert resolve_base_model_id(job, defaults) == "openbmb/MiniCPM5-1B"
166
+
167
+
168
+ def test_profiles_needing_baseline_run_respects_skip_and_cache():
169
+ cached = {"math": True, "compare_study": False}
170
+ assert profiles_needing_baseline_run(
171
+ ["math", "compare_study"], cached, skip_baseline=False
172
+ ) == ["compare_study"]
173
+ assert profiles_needing_baseline_run(
174
+ ["math", "compare_study"], cached, skip_baseline=True
175
+ ) == []
176
+
177
+
178
+ def test_baseline_experiment_name_uses_preset():
179
+ assert baseline_experiment_name("minicpm5-1b", "math") == "minicpm5-1b__baseline__math"
180
+
181
+
182
+ def test_common_env_redirects_xet_logs_off_hf_cache_volume():
183
+ assert COMMON_ENV["HF_XET_LOG_DEST"] == "/tmp/xet-logs/"