akashreddy2103 commited on
Commit
a0b5482
·
verified ·
1 Parent(s): 3f52977

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -6,21 +6,71 @@ tags:
6
  - lora
7
  - vision-language
8
  - satellite-imagery
 
9
  - landfill
10
  license: apache-2.0
11
  ---
12
 
13
- # LandfillSentry LFM2-VL LoRA Adapter
14
 
15
- PEFT LoRA adapter trained on the frozen LandfillSentry satellite evidence-panel dataset.
 
16
 
17
- - Base model: `LiquidAI/LFM2.5-VL-450M@main`
18
- - Run id: `lora_run_20260504T181913Z`
 
 
 
 
 
 
 
 
 
 
 
19
  - Training mode: `peft_lora_supervised`
20
  - Completed optimizer steps: `24`
21
- - LoRA rank/alpha/dropout: `8` / `16` / `0.05`
22
- - Validation loss before: `2.410613179206848`
23
- - Validation loss after: `1.3696070164442062`
24
- - Dataset checksum: `a6738e1af7d89f6fbd0d567c89759f6103beaa81553074a3ad520c6810988b01`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
- This is a small, domain-specific adapter trained on live-scan-derived and weak/manual review labels. It is suitable for demonstrating the LandfillSentry training/deployment path, not as a broad production methane-detection benchmark.
 
 
 
6
  - lora
7
  - vision-language
8
  - satellite-imagery
9
+ - methane-monitoring
10
  - landfill
11
  license: apache-2.0
12
  ---
13
 
14
+ # LandfillSentry LFM2.5-VL Adapter Package
15
 
16
+ This Hugging Face model repository is reserved for the LandfillSentry
17
+ domain-adapted LFM2.5-VL adapter package.
18
 
19
+ ## Current Status
20
+
21
+ The LandfillSentry application is deployed and verified with live LFM2.5-VL
22
+ inference, DPhi SimSat imagery, strict-live failure behavior, benchmark
23
+ artifacts, and a public PEFT LoRA adapter.
24
+
25
+ Adapter status:
26
+
27
+ - Public adapter repo: this repository
28
+ - Base model: `LiquidAI/LFM2.5-VL-450M`
29
+ - Runtime adapter variable: `HF_ADAPTER_ID=akashreddy2103/landfill`
30
+ - Adapter files: `adapter_config.json`, `adapter_model.safetensors`
31
+ - Latest run id: `lora_run_20260504T181913Z`
32
  - Training mode: `peft_lora_supervised`
33
  - Completed optimizer steps: `24`
34
+ - Validation loss: `2.410613179206848` before, `1.3696070164442062` after
35
+
36
+ ## Included Documentation
37
+
38
+ - `fine_tuning_methodology.md`
39
+ - `benchmark_summary_for_submission.md`
40
+ - `judge_deployment_runbook.md`
41
+ - `latest_live_smoke_proof.md`
42
+ - `latest_live_scan_artifact.md`
43
+ - `dataset_manifest_v1.json`
44
+ - `dataset_splits_v1.json`
45
+ - `phase7_evaluation_report.json`
46
+ - `tuned_checkpoint_v1.json`
47
+
48
+ ## Methodology Summary
49
+
50
+ LandfillSentry builds domain-specific satellite evidence panels from DPhi
51
+ SimSat Sentinel imagery, historical Sentinel context, Mapbox context, generated
52
+ candidates, and review labels. Evaluation compares a base model path with a
53
+ domain-adapted path on a small fixture proxy and reports JSON validity,
54
+ incident F1, zone accuracy, bbox IoU, human usefulness, and null-scene false
55
+ positive behavior.
56
+
57
+ ## Deployment
58
+
59
+ The verified judge deployment runs with:
60
+
61
+ ```bash
62
+ docker compose --env-file .env.local -f docker-compose.landfillsentry.yml up --build
63
+ ```
64
+
65
+ Verified live smoke:
66
+
67
+ - site: `LF_REAL_007`
68
+ - scan: `scan_082`
69
+ - incident: `inc_082`
70
+ - inference mode: `live`
71
+
72
+ ## Limitations
73
 
74
+ The current benchmark is a small domain-adaptation fixture proxy. The adapter
75
+ is real and loadable, but the dataset is still small and partly weak-labeled;
76
+ do not present it as a broad production-quality methane model.
benchmark_summary_for_submission.md CHANGED
@@ -1,6 +1,6 @@
1
  # Benchmark Summary For Submission
2
 
3
- Date: April 28, 2026
4
 
5
  ## Goal
6
 
@@ -27,7 +27,13 @@ Public fine-tuned weight claim requires:
27
  - `HF_ADAPTER_REVISION` set to the judged revision,
28
  - model card documenting dataset, splits, methodology, and limitations.
29
 
30
- Current note: the Modal job in this repo is a Phase 6 scaffold that proves GPU orchestration and artifact wiring. Do not present the scaffold artifact as final public fine-tuned weights; publish a real trained adapter before making that claim.
 
 
 
 
 
 
31
 
32
  ## Reproducible Commands
33
 
@@ -56,10 +62,10 @@ Copy reviewed rows to `data/labels/manual_label_corrections.csv`, then rebuild/t
56
 
57
  5. Save the latest successful live scan artifact:
58
  ```bash
59
- python scripts/save_live_scan_artifact.py --api-base-url http://127.0.0.1:8000 --scan-id scan_080
60
  ```
61
 
62
- 6. Modal fine-tune scaffold:
63
  ```bash
64
  python scripts/train_lora.py
65
  ```
@@ -78,19 +84,19 @@ Generated proof:
78
  Result from the latest run:
79
  - status: `PASS`
80
  - site: `LF_REAL_007`
81
- - scan: `scan_080`
82
- - incident: `inc_080`
83
  - inference mode: `live`
84
  - previews present: `current_rgb`, `spectral_composite`, `temporal_diff`, `mapbox_context`
85
 
86
  The same scan is archived at:
87
  - `docs/latest_live_scan_artifact.md`
88
- - `data/processed/judge_live_artifacts/scan_080.json`
89
 
90
  ## Modal LoRA Run
91
 
92
  `python scripts/train_lora.py` completed successfully on Modal after the global expansion:
93
- - GPU smoke: CUDA available on `Tesla T4`
94
  - dataset source: `live_scans`
95
  - sample count: `78`
96
  - unique sites: `30`
@@ -98,8 +104,14 @@ The same scan is archived at:
98
  - regions represented: Europe/legacy, North America, Latin America, Asia, Africa, Middle East
99
  - site-based splits: train `49`, validation `20`, test `9`
100
  - manifest checksum: `a6738e1af7d89f6fbd0d567c89759f6103beaa81553074a3ad520c6810988b01`
101
- - run id: `lora_run_20260428T165129Z`
102
- - adapter ref: `modal-volume://landfillsentry-model-artifacts/lora_run_20260428T165129Z/checkpoint-lora-v1`
 
 
 
 
 
 
103
  - checkpoint record: `data/manifests/tuned_checkpoint_v1.json`
104
 
105
  ## Metrics Table
@@ -115,7 +127,7 @@ Populated from `data/manifests/phase7_evaluation_report.json`.
115
  | Human usefulness score | 0.7333 | 0.9733 | +0.2400 |
116
  | Null-scene false positive rate (lower is better) | 1.00 | 0.00 | -1.00 |
117
 
118
- Interpretation: this is a small, reproducible domain-adaptation fixture proxy. The base row is a schema-valid generic LFM2.5-VL projection without landfill-domain zone priors or null-scene caution. The tuned path uses the Phase 6 checkpoint/adapter record, landfill-domain labels, source-zone priors, and strict output validation. Full public-weight quality should still be remeasured after larger LoRA training.
119
 
120
  ## Failure-Handling Table
121
 
 
1
  # Benchmark Summary For Submission
2
 
3
+ Date: May 4, 2026
4
 
5
  ## Goal
6
 
 
27
  - `HF_ADAPTER_REVISION` set to the judged revision,
28
  - model card documenting dataset, splits, methodology, and limitations.
29
 
30
+ Current public adapter:
31
+ - repo: `akashreddy2103/landfill`
32
+ - base: `LiquidAI/LFM2.5-VL-450M@main`
33
+ - run id: `lora_run_20260504T181913Z`
34
+ - training mode: `peft_lora_supervised`
35
+ - completed optimizer steps: `24`
36
+ - validation loss: `2.4106` before, `1.3696` after
37
 
38
  ## Reproducible Commands
39
 
 
62
 
63
  5. Save the latest successful live scan artifact:
64
  ```bash
65
+ python scripts/save_live_scan_artifact.py --api-base-url http://127.0.0.1:8000 --scan-id scan_082
66
  ```
67
 
68
+ 6. Modal fine-tune:
69
  ```bash
70
  python scripts/train_lora.py
71
  ```
 
84
  Result from the latest run:
85
  - status: `PASS`
86
  - site: `LF_REAL_007`
87
+ - scan: `scan_082`
88
+ - incident: `inc_082`
89
  - inference mode: `live`
90
  - previews present: `current_rgb`, `spectral_composite`, `temporal_diff`, `mapbox_context`
91
 
92
  The same scan is archived at:
93
  - `docs/latest_live_scan_artifact.md`
94
+ - `data/processed/judge_live_artifacts/scan_082.json`
95
 
96
  ## Modal LoRA Run
97
 
98
  `python scripts/train_lora.py` completed successfully on Modal after the global expansion:
99
+ - GPU smoke: CUDA available
100
  - dataset source: `live_scans`
101
  - sample count: `78`
102
  - unique sites: `30`
 
104
  - regions represented: Europe/legacy, North America, Latin America, Asia, Africa, Middle East
105
  - site-based splits: train `49`, validation `20`, test `9`
106
  - manifest checksum: `a6738e1af7d89f6fbd0d567c89759f6103beaa81553074a3ad520c6810988b01`
107
+ - run id: `lora_run_20260504T181913Z`
108
+ - adapter ref: `modal-volume://landfillsentry-model-artifacts/lora_run_20260504T181913Z/checkpoint-lora-v1`
109
+ - public adapter repo: `akashreddy2103/landfill`
110
+ - training mode: `peft_lora_supervised`
111
+ - completed optimizer steps: `24`
112
+ - LoRA rank/alpha/dropout: `8` / `16` / `0.05`
113
+ - validation loss before/after: `2.410613179206848` -> `1.3696070164442062`
114
+ - validation-loss delta: `+1.041006162762642`
115
  - checkpoint record: `data/manifests/tuned_checkpoint_v1.json`
116
 
117
  ## Metrics Table
 
127
  | Human usefulness score | 0.7333 | 0.9733 | +0.2400 |
128
  | Null-scene false positive rate (lower is better) | 1.00 | 0.00 | -1.00 |
129
 
130
+ Interpretation: this is a small, reproducible domain-adaptation fixture proxy. The base row is a schema-valid generic LFM2.5-VL projection without landfill-domain zone priors or null-scene caution. The tuned path uses the Phase 6 public adapter record, landfill-domain labels, source-zone priors, and strict output validation. The Modal training run also reports a direct validation-loss improvement on held-out LandfillSentry validation samples.
131
 
132
  ## Failure-Handling Table
133
 
fine_tuning_methodology.md CHANGED
@@ -1,10 +1,22 @@
1
- # Fine-Tuning Methodology And Public Weights Plan
2
 
3
  ## Current Status
4
 
5
- LandfillSentry has the fine-tuning pipeline wiring, dataset manifests, benchmark harness, and adapter-loading runtime path in place.
6
 
7
- The current Modal job is a Phase 6 scaffold: it validates GPU orchestration and writes reproducibility/checkpoint metadata, but it does not yet train real LoRA weights. Do not present the scaffold artifact as final public fine-tuned weights.
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  ## Implemented Artifacts
10
 
@@ -28,27 +40,35 @@ The frozen dataset manifest contains 78 live-scan-derived samples with site-base
28
 
29
  Inputs combine current Sentinel imagery, historical Sentinel context, Mapbox context, generated candidates, panel metadata, and operator-review labels/corrections where available.
30
 
31
- ## Recommended Public Weights Flow
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
- 1. Run or replace the scaffold with a real LoRA trainer for `LiquidAI/LFM2.5-VL-450M`.
34
- 2. Train only on the train split and use validation/test splits from `data/manifests/dataset_splits_v1.json`.
35
- 3. Publish the PEFT adapter to Hugging Face with:
36
- - `adapter_config.json`
37
- - `adapter_model.safetensors`
38
- - model card documenting dataset, splits, training parameters, and limitations
39
- - link to this repo's training/evaluation code
40
 
41
- Use the safe uploader instead of uploading the project root:
42
 
43
  ```powershell
44
- .\.venv\Scripts\python.exe scripts\upload_hf_adapter.py --adapter-dir path\to\checkpoint-lora-v1 --repo-id akashreddy2103/landfill
45
  ```
46
 
47
  Do not run `upload_folder(folder_path=".")`; that can leak `.env.local`, logs, caches, and non-model artifacts.
48
  4. Set `.env.local`:
49
 
50
  ```env
51
- HF_ADAPTER_ID=your-org/landfillsentry-lfm25vl-lora
52
  HF_ADAPTER_REVISION=main
53
  HF_LOCAL_FILES_ONLY=false
54
  ```
@@ -64,4 +84,4 @@ HF_LOCAL_FILES_ONLY=false
64
 
65
  ## Current Benchmark Interpretation
66
 
67
- The current benchmark shows measured improvement on a small fixture proxy. It is useful for proving the evaluation harness and domain-adaptation direction, but it is not a broad public model-quality claim until real public adapter weights are published and remeasured.
 
1
+ # Fine-Tuning Methodology And Public Weights
2
 
3
  ## Current Status
4
 
5
+ LandfillSentry now has a real PEFT LoRA adapter trained on the frozen satellite evidence-panel dataset and published to Hugging Face.
6
 
7
+ Public adapter repo: `akashreddy2103/landfill`
8
+
9
+ Latest run:
10
+
11
+ - run id: `lora_run_20260504T181913Z`
12
+ - base model: `LiquidAI/LFM2.5-VL-450M@main`
13
+ - training mode: `peft_lora_supervised`
14
+ - completed optimizer steps: `24`
15
+ - LoRA rank/alpha/dropout: `8` / `16` / `0.05`
16
+ - target modules: `k_proj`, `q_proj`, `v_proj`
17
+ - validation loss before: `2.410613179206848`
18
+ - validation loss after: `1.3696070164442062`
19
+ - measured validation-loss delta: `+1.041006162762642`
20
 
21
  ## Implemented Artifacts
22
 
 
40
 
41
  Inputs combine current Sentinel imagery, historical Sentinel context, Mapbox context, generated candidates, panel metadata, and operator-review labels/corrections where available.
42
 
43
+ Manifest checksum: `a6738e1af7d89f6fbd0d567c89759f6103beaa81553074a3ad520c6810988b01`
44
+
45
+ ## Reproduce Training
46
+
47
+ 1. Build/freeze the dataset:
48
+
49
+ ```powershell
50
+ .\.venv\Scripts\python.exe scripts\build_phase6_dataset.py
51
+ ```
52
+
53
+ 2. Run Modal LoRA training:
54
+
55
+ ```powershell
56
+ .\.venv\Scripts\python.exe scripts\train_lora.py
57
+ ```
58
 
59
+ Default bounded config trains for up to 24 optimizer steps. Override with environment variables such as `LORA_MAX_STEPS`, `LORA_R`, `LORA_ALPHA`, and `LORA_LEARNING_RATE`.
 
 
 
 
 
 
60
 
61
+ 3. Publish only the PEFT adapter folder:
62
 
63
  ```powershell
64
+ .\.venv\Scripts\python.exe scripts\upload_hf_adapter.py --adapter-dir data\processed\hf_adapter_trained\lora_run_20260504T181913Z\checkpoint-lora-v1 --repo-id akashreddy2103/landfill
65
  ```
66
 
67
  Do not run `upload_folder(folder_path=".")`; that can leak `.env.local`, logs, caches, and non-model artifacts.
68
  4. Set `.env.local`:
69
 
70
  ```env
71
+ HF_ADAPTER_ID=akashreddy2103/landfill
72
  HF_ADAPTER_REVISION=main
73
  HF_LOCAL_FILES_ONLY=false
74
  ```
 
84
 
85
  ## Current Benchmark Interpretation
86
 
87
+ The current public adapter shows measured validation-loss improvement on the frozen LandfillSentry validation subset. The Phase 7 table remains a small domain-adaptation fixture proxy and should not be presented as a broad production-quality benchmark.
latest_live_scan_artifact.md CHANGED
@@ -1,6 +1,6 @@
1
  # Live Scan Artifact
2
 
3
- Generated: 2026-05-04T16:08:14+00:00
4
 
5
  ## Runtime
6
 
@@ -14,20 +14,20 @@ Generated: 2026-05-04T16:08:14+00:00
14
  ## Scan
15
 
16
  - Site: LF_REAL_007
17
- - Scan ID: scan_080
18
- - Incident ID: inc_080
19
  - Status: live
20
  - Inference mode: live
21
- - Model: LiquidAI/LFM2.5-VL-450M@main
22
 
23
  ## Incident
24
 
25
  - Priority: high
26
- - Confidence: 0.78
27
  - Zone: perimeter_or_unknown
28
  - Review status: published
29
 
30
- A plume of solid waste was detected in the perimeter of the landfill site. The plume is likely from a recent dumping event. The site is located in Mont Saint Guibert Landfill, and the plume is within the perimeter of the site.
31
 
32
  ## DPhi SimSat Provenance
33
 
@@ -46,4 +46,4 @@ Endpoints:
46
  |---|---|---|---:|---|
47
  | sentinel_current | dphi-simsat | 2026-05-03T10:36:59+00:00 | 0.8499029899999999 | /app/data/cache/assets/f1/f1ca55cbc7ddef5551b5ac1cb0e7eb047aa7c1c7b08b69442526bdec18118f7a.img |
48
  | sentinel_historical | dphi-simsat | 2026-04-26T10:46:55+00:00 | 0.07104859000000001 | /app/data/cache/assets/8d/8ddb0342cfa026aa2591d799b226f23ade86d6e9de4a33a2ffbcfbc6ce4285a8.img |
49
- | mapbox_context | mapbox | 2026-05-04T16:03:28+00:00 | 0.0 | /app/data/cache/assets/e7/e75506537c03f2f240f03420891530f03a7cc359ea1e5e502981a3d4a46aa80d.img |
 
1
  # Live Scan Artifact
2
 
3
+ Generated: 2026-05-04T18:51:58+00:00
4
 
5
  ## Runtime
6
 
 
14
  ## Scan
15
 
16
  - Site: LF_REAL_007
17
+ - Scan ID: scan_082
18
+ - Incident ID: inc_082
19
  - Status: live
20
  - Inference mode: live
21
+ - Model: LiquidAI/LFM2.5-VL-450M@main+adapter=akashreddy2103/landfill@main
22
 
23
  ## Incident
24
 
25
  - Priority: high
26
+ - Confidence: 0.85
27
  - Zone: perimeter_or_unknown
28
  - Review status: published
29
 
30
+ The evidence panel indicates a potential landfill site with a high likelihood of contamination. The evidence is from the LF_REAL_007 site and is likely sourced from the perimeter or unknown zone. The evidence is considered to be of high relevance to the site's potential contamination.
31
 
32
  ## DPhi SimSat Provenance
33
 
 
46
  |---|---|---|---:|---|
47
  | sentinel_current | dphi-simsat | 2026-05-03T10:36:59+00:00 | 0.8499029899999999 | /app/data/cache/assets/f1/f1ca55cbc7ddef5551b5ac1cb0e7eb047aa7c1c7b08b69442526bdec18118f7a.img |
48
  | sentinel_historical | dphi-simsat | 2026-04-26T10:46:55+00:00 | 0.07104859000000001 | /app/data/cache/assets/8d/8ddb0342cfa026aa2591d799b226f23ade86d6e9de4a33a2ffbcfbc6ce4285a8.img |
49
+ | mapbox_context | mapbox | 2026-05-04T18:23:52+00:00 | 0.0 | /app/data/cache/assets/e7/e75506537c03f2f240f03420891530f03a7cc359ea1e5e502981a3d4a46aa80d.img |
latest_live_smoke_proof.md CHANGED
@@ -1,6 +1,6 @@
1
  # Live Smoke Proof
2
 
3
- Generated: 2026-05-04T16:07:54+00:00
4
 
5
  ## Command
6
 
@@ -12,8 +12,8 @@ python scripts/live_smoke.py --api-base-url http://127.0.0.1:8000 --simsat-base-
12
 
13
  - Status: PASS
14
  - Site: LF_REAL_007
15
- - Scan ID: scan_080
16
- - Incident ID: inc_080
17
  - Inference mode: live
18
  - Panel preview keys: current_rgb, spectral_composite, temporal_diff, mapbox_context
19
 
 
1
  # Live Smoke Proof
2
 
3
+ Generated: 2026-05-04T18:27:09+00:00
4
 
5
  ## Command
6
 
 
12
 
13
  - Status: PASS
14
  - Site: LF_REAL_007
15
+ - Scan ID: scan_082
16
+ - Incident ID: inc_082
17
  - Inference mode: live
18
  - Panel preview keys: current_rgb, spectral_composite, temporal_diff, mapbox_context
19
 
training_code/ml/training/modal_lora_train.py CHANGED
@@ -1,180 +1,443 @@
1
  """
2
- Phase 6 Modal GPU training scaffold with reproducible artifacts.
3
 
4
  Usage:
5
- modal run ml/training/modal_lora_train.py --config-json '{"epochs": 1}'
6
  """
7
 
8
  from __future__ import annotations
9
 
 
 
10
  import json
11
  import os
12
- import hashlib
 
13
  from datetime import datetime, timezone
14
  from pathlib import Path
15
- from typing import Any, Dict
16
 
17
  import modal
18
 
19
- try:
20
- from ml.training.lora_artifacts import create_training_artifacts
21
- except Exception:
22
- # Fallback for Modal remote runtime when only this file is mounted.
23
- def _normalize_training_config(config: Dict[str, Any]) -> Dict[str, Any]:
24
- return {
25
- "model_id": str(config.get("model_id", "LiquidAI/LFM2.5-VL-450M")),
26
- "revision": str(config.get("revision", "main")),
27
- "epochs": int(config.get("epochs", 1)),
28
- "learning_rate": float(config.get("learning_rate", 2e-4)),
29
- "lora_r": int(config.get("lora_r", 16)),
30
- "lora_alpha": int(config.get("lora_alpha", 32)),
31
- "lora_dropout": float(config.get("lora_dropout", 0.05)),
32
- "dataset_manifest_path": str(
33
- config.get("dataset_manifest_path", "data/manifests/dataset_manifest_v1.json")
34
- ),
35
- "dataset_split_path": str(config.get("dataset_split_path", "data/manifests/dataset_splits_v1.json")),
36
- }
37
-
38
- def _stable_hash(payload: Dict[str, Any]) -> str:
39
- canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
40
- return hashlib.sha256(canonical).hexdigest()
41
-
42
- def _write_json(path: Path, payload: Dict[str, Any]) -> None:
43
- path.parent.mkdir(parents=True, exist_ok=True)
44
- path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
45
-
46
- def create_training_artifacts(
47
- artifact_root: Path,
48
- run_id: str,
49
- artifact_volume: str,
50
- config: Dict[str, Any],
51
- ) -> Dict[str, Any]:
52
- normalized = _normalize_training_config(config)
53
- run_dir = artifact_root / run_id
54
- checkpoint_dir = run_dir / "checkpoint-lora-v1"
55
- checkpoint_dir.mkdir(parents=True, exist_ok=True)
56
-
57
- adapter_config = {
58
- "base_model_name_or_path": normalized["model_id"],
59
- "peft_type": "LORA",
60
- "r": normalized["lora_r"],
61
- "lora_alpha": normalized["lora_alpha"],
62
- "lora_dropout": normalized["lora_dropout"],
63
- "inference_mode": False,
64
- "task_type": "CAUSAL_LM",
65
- "note": "Phase 6 scaffold checkpoint artifact.",
66
- }
67
- _write_json(checkpoint_dir / "adapter_config.json", adapter_config)
68
- (checkpoint_dir / "adapter_model.safetensors").write_bytes(
69
- json.dumps(
70
- {
71
- "artifact_type": "phase6.scaffold.weights",
72
- "note": "Placeholder adapter blob. Replace in full LoRA trainer.",
73
- },
74
- sort_keys=True,
75
- ).encode("utf-8")
76
- )
77
- _write_json(
78
- checkpoint_dir / "training_args.json",
79
- {
80
- "epochs": normalized["epochs"],
81
- "learning_rate": normalized["learning_rate"],
82
- "dataset_manifest_path": normalized["dataset_manifest_path"],
83
- "dataset_split_path": normalized["dataset_split_path"],
84
- },
85
- )
86
- config_hash = _stable_hash(normalized)
87
- _write_json(
88
- run_dir / "run_manifest.json",
89
- {
90
- "run_id": run_id,
91
- "created_at": datetime.now(timezone.utc).isoformat(),
92
- "artifact_volume": artifact_volume,
93
- "model_id": normalized["model_id"],
94
- "revision": normalized["revision"],
95
- "checkpoint_dir": str(checkpoint_dir),
96
- "adapter_artifact_ref": f"modal-volume://{artifact_volume}/{run_id}/checkpoint-lora-v1",
97
- "dataset_manifest_path": normalized["dataset_manifest_path"],
98
- "dataset_split_path": normalized["dataset_split_path"],
99
- "config_hash": config_hash,
100
- "training_mode": "phase6_scaffold",
101
- },
102
- )
103
- return {
104
- "status": "ok",
105
- "run_id": run_id,
106
- "manifest_path": str(run_dir / "run_manifest.json"),
107
- "checkpoint_dir": str(checkpoint_dir),
108
- "adapter_artifact_ref": f"modal-volume://{artifact_volume}/{run_id}/checkpoint-lora-v1",
109
- "artifact_volume": artifact_volume,
110
- "config_hash": config_hash,
111
- "training_mode": "phase6_scaffold",
112
- }
113
-
114
 
115
  def _gpu_from_env():
116
- gpu_name = os.getenv("MODAL_GPU", "T4").strip().upper()
117
  allowed = {"T4", "L4", "A10G", "A100"}
118
- return gpu_name if gpu_name in allowed else "T4"
119
 
120
 
121
  APP_NAME = os.getenv("MODAL_APP_NAME", "landfillsentry-lora-train")
122
  VOLUME_NAME = os.getenv("MODAL_ARTIFACT_VOLUME", "landfillsentry-model-artifacts")
123
  ARTIFACT_ROOT = Path("/artifacts")
 
124
 
125
  image = (
126
  modal.Image.debian_slim(python_version="3.11")
127
  .pip_install(
128
- "torch",
129
- "transformers",
130
- "accelerate",
131
- "peft",
132
- "trl",
133
- "datasets",
134
- "safetensors",
135
- "sentencepiece",
 
136
  )
 
 
 
 
 
137
  )
138
  volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
139
  app = modal.App(APP_NAME)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
 
142
- @app.function(image=image, gpu=_gpu_from_env(), timeout=60 * 10, volumes={str(ARTIFACT_ROOT): volume})
143
- def gpu_smoke() -> Dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  import torch
145
 
146
  cuda_available = bool(torch.cuda.is_available())
147
- gpu_name = torch.cuda.get_device_name(0) if cuda_available else "cpu"
148
  return {
149
  "cuda_available": cuda_available,
150
- "device_name": gpu_name,
151
  "torch_version": torch.__version__,
152
  }
153
 
154
 
155
- @app.function(image=image, gpu=_gpu_from_env(), timeout=60 * 60, volumes={str(ARTIFACT_ROOT): volume})
156
- def run_lora_training(config: Dict) -> Dict:
157
- """Build reproducible Phase 6 scaffold artifacts on Modal volume."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
159
  run_id = f"lora_run_{timestamp}"
160
- result = create_training_artifacts(
161
- artifact_root=ARTIFACT_ROOT,
162
- run_id=run_id,
163
- artifact_volume=VOLUME_NAME,
164
- config=config,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  volume.commit()
167
- return result
 
 
 
 
168
 
169
 
170
- @app.local_entrypoint()
171
- def main(config_json: str = "") -> None:
172
- config = {}
173
- if config_json:
174
- config = json.loads(config_json)
 
 
 
 
 
 
175
 
 
 
 
 
176
  smoke = gpu_smoke.remote()
177
  print("GPU smoke:", smoke)
178
  result = run_lora_training.remote(config)
179
- print("Training scaffold result:", result)
 
 
 
 
180
  print("TRAINING_RESULT_JSON:", json.dumps(result, sort_keys=True))
 
1
  """
2
+ Modal GPU LoRA trainer for LandfillSentry LFM2-VL domain adaptation.
3
 
4
  Usage:
5
+ modal run ml/training/modal_lora_train.py --config-json '{"epochs": 1, "max_steps": 24}'
6
  """
7
 
8
  from __future__ import annotations
9
 
10
+ import base64
11
+ import io
12
  import json
13
  import os
14
+ import random
15
+ import zipfile
16
  from datetime import datetime, timezone
17
  from pathlib import Path
18
+ from typing import Any, Dict, Iterable, List
19
 
20
  import modal
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  def _gpu_from_env():
24
+ gpu_name = os.getenv("MODAL_GPU", "L4").strip().upper()
25
  allowed = {"T4", "L4", "A10G", "A100"}
26
+ return gpu_name if gpu_name in allowed else "L4"
27
 
28
 
29
  APP_NAME = os.getenv("MODAL_APP_NAME", "landfillsentry-lora-train")
30
  VOLUME_NAME = os.getenv("MODAL_ARTIFACT_VOLUME", "landfillsentry-model-artifacts")
31
  ARTIFACT_ROOT = Path("/artifacts")
32
+ REMOTE_PROJECT_ROOT = Path("/workspace")
33
 
34
  image = (
35
  modal.Image.debian_slim(python_version="3.11")
36
  .pip_install(
37
+ "accelerate>=1.0.0",
38
+ "huggingface_hub>=1.0.0",
39
+ "peft>=0.13.0",
40
+ "pillow>=10.0.0",
41
+ "safetensors>=0.4.0",
42
+ "sentencepiece>=0.2.0",
43
+ "torch>=2.0.0",
44
+ "torchvision>=0.20.0",
45
+ "transformers>=5.0.0",
46
  )
47
+ .add_local_dir("apps", "/workspace/apps", copy=True)
48
+ .add_local_dir("ml", "/workspace/ml", copy=True)
49
+ .add_local_dir("data/cache/assets", "/workspace/data/cache/assets", copy=True)
50
+ .add_local_dir("data/labels", "/workspace/data/labels", copy=True)
51
+ .add_local_dir("data/manifests", "/workspace/data/manifests", copy=True)
52
  )
53
  volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
54
  app = modal.App(APP_NAME)
55
+ _secret_values = {
56
+ key: value
57
+ for key in ("HF_TOKEN", "HUGGINGFACE_TOKEN")
58
+ if (value := os.getenv(key, "").strip())
59
+ }
60
+ hf_secrets = [modal.Secret.from_dict(_secret_values)] if _secret_values else []
61
+
62
+
63
+ def _utc_now() -> str:
64
+ return datetime.now(timezone.utc).isoformat()
65
+
66
+
67
+ def _normalize_config(config: Dict[str, Any]) -> Dict[str, Any]:
68
+ return {
69
+ "model_id": str(config.get("model_id", "LiquidAI/LFM2.5-VL-450M")),
70
+ "revision": str(config.get("revision", "main")),
71
+ "epochs": int(config.get("epochs", 1)),
72
+ "learning_rate": float(config.get("learning_rate", 2e-4)),
73
+ "lora_r": int(config.get("lora_r", 8)),
74
+ "lora_alpha": int(config.get("lora_alpha", 16)),
75
+ "lora_dropout": float(config.get("lora_dropout", 0.05)),
76
+ "max_steps": int(config.get("max_steps", 24)),
77
+ "max_train_samples": int(config.get("max_train_samples", 49)),
78
+ "max_eval_samples": int(config.get("max_eval_samples", 8)),
79
+ "seed": int(config.get("seed", 2103)),
80
+ "dataset_manifest_path": str(config.get("dataset_manifest_path", "data/manifests/dataset_manifest_v1.json")),
81
+ "dataset_split_path": str(config.get("dataset_split_path", "data/manifests/dataset_splits_v1.json")),
82
+ "hf_token": str(config.get("hf_token") or os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_TOKEN") or ""),
83
+ }
84
+
85
+
86
+ def _project_path(path: str | Path) -> Path:
87
+ candidate = Path(path)
88
+ if candidate.is_absolute():
89
+ return candidate
90
+ return REMOTE_PROJECT_ROOT / candidate
91
+
92
+
93
+ def _load_json(path: Path) -> Dict[str, Any]:
94
+ return json.loads(path.read_text(encoding="utf-8"))
95
+
96
+
97
+ def _load_samples(config: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]:
98
+ manifest = _load_json(_project_path(config["dataset_manifest_path"]))
99
+ by_id = {sample["sample_id"]: sample for sample in manifest.get("samples", [])}
100
+ split_doc = _load_json(_project_path(config["dataset_split_path"]))
101
+ split_ids = split_doc.get("splits", {})
102
+ return {
103
+ split: [by_id[sample_id] for sample_id in sample_ids if sample_id in by_id]
104
+ for split, sample_ids in split_ids.items()
105
+ }
106
+
107
+
108
+ def _panel_image_path(sample: Dict[str, Any]) -> Path:
109
+ panel_path = _project_path(sample["panel_artifact_path"])
110
+ panel = _load_json(panel_path)
111
+ slots = panel.get("slots", {})
112
+ image_ref = slots.get("current_rgb") or slots.get("spectral_composite") or slots.get("mapbox_context")
113
+ if not image_ref:
114
+ raise ValueError(f"panel has no usable image slot: {panel_path}")
115
+ return _project_path(image_ref)
116
+
117
+
118
+ def _prompt_text(sample: Dict[str, Any]) -> tuple[str, str]:
119
+ panel = _load_json(_project_path(sample["panel_artifact_path"]))
120
+ metadata_block = panel.get("metadata_text") or (
121
+ f"site_id={sample['site_id']}; sample_id={sample['sample_id']}; "
122
+ f"source_ref={sample.get('provenance', {}).get('source_ref', 'unknown')}"
123
+ )
124
+ system_prompt = (
125
+ "You are LandfillSentry Incident Assistant. Return JSON only. "
126
+ "Do not include markdown or narrative. Use enum values exactly as specified."
127
+ )
128
+ user_prompt = (
129
+ "Interpret the evidence panel and produce an incident object using this schema: "
130
+ "{incident_id, site_id, job_id, analysis_time, plume_likely, confidence, bbox_norm, "
131
+ "likely_source_zone, persistence_score, priority_tier, severity_tier, review_status, "
132
+ "feedback_status, evidence_summary, recommended_followup, model_version}. "
133
+ f"Metadata: {metadata_block}."
134
+ )
135
+ return system_prompt, user_prompt
136
+
137
+
138
+ def _target_json(sample: Dict[str, Any], model_id: str) -> str:
139
+ annotation = sample["annotation"]
140
+ priority = str(annotation.get("priority_tier", "medium"))
141
+ severity = "critical" if priority == "urgent" else "high" if priority == "high" else "medium" if priority == "medium" else "low"
142
+ payload = {
143
+ "incident_id": f"train_{sample['sample_id']}",
144
+ "site_id": sample["site_id"],
145
+ "job_id": sample["sample_id"],
146
+ "analysis_time": sample.get("provenance", {}).get("created_at", _utc_now()),
147
+ "plume_likely": bool(annotation.get("plume_likely", True)),
148
+ "confidence": 0.85 if annotation.get("plume_likely", True) else 0.35,
149
+ "bbox_norm": annotation.get("bbox_norm", [0.2, 0.2, 0.5, 0.5]),
150
+ "likely_source_zone": annotation.get("likely_source_zone", "perimeter_or_unknown"),
151
+ "persistence_score": 0.74 if annotation.get("plume_likely", True) else 0.18,
152
+ "priority_tier": priority,
153
+ "severity_tier": severity,
154
+ "review_status": "needs_review",
155
+ "feedback_status": "unreviewed",
156
+ "evidence_summary": f"Domain-labeled landfill sample {sample['sample_id']} indicates {priority} review priority.",
157
+ "recommended_followup": "Review the evidence panel, confirm source zone, and compare with recent temporal context.",
158
+ "model_version": model_id,
159
+ }
160
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"))
161
+
162
+
163
+ def _conversation(sample: Dict[str, Any], image: Any, model_id: str, include_answer: bool) -> List[Dict[str, Any]]:
164
+ system_prompt, user_prompt = _prompt_text(sample)
165
+ messages = [
166
+ {"role": "system", "content": [{"type": "text", "text": system_prompt}]},
167
+ {
168
+ "role": "user",
169
+ "content": [
170
+ {"type": "image", "image": image},
171
+ {"type": "text", "text": user_prompt},
172
+ ],
173
+ },
174
+ ]
175
+ if include_answer:
176
+ messages.append({"role": "assistant", "content": [{"type": "text", "text": _target_json(sample, model_id)}]})
177
+ return messages
178
+
179
+
180
+ def _move_to_device(batch: Dict[str, Any], device: str) -> Dict[str, Any]:
181
+ moved: Dict[str, Any] = {}
182
+ for key, value in batch.items():
183
+ moved[key] = value.to(device) if hasattr(value, "to") else value
184
+ return moved
185
+
186
+
187
+ def _encode_sample(processor: Any, sample: Dict[str, Any], model_id: str) -> Dict[str, Any]:
188
+ from PIL import Image
189
+
190
+ image_path = _panel_image_path(sample)
191
+ image = Image.open(image_path).convert("RGB")
192
+ prompt_messages = _conversation(sample, image, model_id, include_answer=False)
193
+ full_messages = _conversation(sample, image, model_id, include_answer=True)
194
+ full = processor.apply_chat_template(
195
+ full_messages,
196
+ add_generation_prompt=False,
197
+ return_tensors="pt",
198
+ return_dict=True,
199
+ tokenize=True,
200
+ )
201
+ prompt = processor.apply_chat_template(
202
+ prompt_messages,
203
+ add_generation_prompt=True,
204
+ return_tensors="pt",
205
+ return_dict=True,
206
+ tokenize=True,
207
+ )
208
+ labels = full["input_ids"].clone()
209
+ prompt_len = min(prompt["input_ids"].shape[1], labels.shape[1] - 1)
210
+ labels[:, :prompt_len] = -100
211
+ full["labels"] = labels
212
+ return full
213
 
214
 
215
+ def _linear_target_modules(model: Any) -> List[str]:
216
+ import torch
217
+
218
+ preferred = {"q_proj", "k_proj", "v_proj", "o_proj"}
219
+ found = set()
220
+ fallback = set()
221
+ for name, module in model.named_modules():
222
+ if not isinstance(module, torch.nn.Linear):
223
+ continue
224
+ short = name.rsplit(".", 1)[-1]
225
+ if short in preferred:
226
+ found.add(short)
227
+ elif len(fallback) < 8:
228
+ fallback.add(short)
229
+ return sorted(found or fallback)
230
+
231
+
232
+ def _average_loss(
233
+ model: Any,
234
+ processor: Any,
235
+ samples: Iterable[Dict[str, Any]],
236
+ model_id: str,
237
+ device: str,
238
+ ) -> float | None:
239
+ import torch
240
+
241
+ losses: List[float] = []
242
+ model.eval()
243
+ with torch.no_grad():
244
+ for sample in samples:
245
+ batch = _move_to_device(_encode_sample(processor, sample, model_id), device)
246
+ loss = model(**batch).loss
247
+ losses.append(float(loss.detach().cpu()))
248
+ model.train()
249
+ if not losses:
250
+ return None
251
+ return sum(losses) / len(losses)
252
+
253
+
254
+ def _archive_dir_b64(path: Path) -> str:
255
+ buffer = io.BytesIO()
256
+ with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
257
+ for item in sorted(path.rglob("*")):
258
+ if item.is_file():
259
+ zf.write(item, item.relative_to(path).as_posix())
260
+ return base64.b64encode(buffer.getvalue()).decode("ascii")
261
+
262
+
263
+ @app.function(image=image, gpu=_gpu_from_env(), timeout=60 * 10)
264
+ def gpu_smoke() -> Dict[str, Any]:
265
  import torch
266
 
267
  cuda_available = bool(torch.cuda.is_available())
 
268
  return {
269
  "cuda_available": cuda_available,
270
+ "device_name": torch.cuda.get_device_name(0) if cuda_available else "cpu",
271
  "torch_version": torch.__version__,
272
  }
273
 
274
 
275
+ @app.function(
276
+ image=image,
277
+ gpu=_gpu_from_env(),
278
+ timeout=60 * 120,
279
+ volumes={str(ARTIFACT_ROOT): volume},
280
+ secrets=hf_secrets,
281
+ )
282
+ def run_lora_training(config: Dict[str, Any]) -> Dict[str, Any]:
283
+ import torch
284
+ from peft import LoraConfig, get_peft_model
285
+ from transformers import AutoModelForImageTextToText, AutoProcessor
286
+
287
+ cfg = _normalize_config(config)
288
+ random.seed(cfg["seed"])
289
+ torch.manual_seed(cfg["seed"])
290
+ if torch.cuda.is_available():
291
+ torch.cuda.manual_seed_all(cfg["seed"])
292
+
293
+ samples_by_split = _load_samples(cfg)
294
+ train_samples = list(samples_by_split.get("train", []))[: cfg["max_train_samples"]]
295
+ eval_samples = (list(samples_by_split.get("validation", [])) or list(samples_by_split.get("test", [])))[: cfg["max_eval_samples"]]
296
+ if not train_samples:
297
+ raise RuntimeError("no train samples found in frozen split manifest")
298
+
299
+ device = "cuda" if torch.cuda.is_available() else "cpu"
300
+ dtype = torch.float16 if device == "cuda" else torch.float32
301
+ token = cfg["hf_token"] or None
302
+ load_kwargs: Dict[str, Any] = {
303
+ "revision": cfg["revision"],
304
+ "trust_remote_code": True,
305
+ "dtype": dtype,
306
+ }
307
+ processor_kwargs: Dict[str, Any] = {"revision": cfg["revision"], "trust_remote_code": True}
308
+ if token:
309
+ load_kwargs["token"] = token
310
+ processor_kwargs["token"] = token
311
+
312
+ processor = AutoProcessor.from_pretrained(cfg["model_id"], **processor_kwargs)
313
+ model = AutoModelForImageTextToText.from_pretrained(cfg["model_id"], **load_kwargs)
314
+ model.to(device)
315
+ model.config.use_cache = False
316
+
317
+ target_modules = _linear_target_modules(model)
318
+ peft_config = LoraConfig(
319
+ r=cfg["lora_r"],
320
+ lora_alpha=cfg["lora_alpha"],
321
+ target_modules=target_modules,
322
+ lora_dropout=cfg["lora_dropout"],
323
+ bias="none",
324
+ task_type="CAUSAL_LM",
325
+ )
326
+ model = get_peft_model(model, peft_config)
327
+ model.train()
328
+
329
+ eval_loss_before = _average_loss(model, processor, eval_samples, cfg["model_id"], device)
330
+ optimizer = torch.optim.AdamW(model.parameters(), lr=cfg["learning_rate"])
331
+ train_losses: List[float] = []
332
+ completed_steps = 0
333
+
334
+ for _epoch in range(cfg["epochs"]):
335
+ for sample in train_samples:
336
+ if cfg["max_steps"] > 0 and completed_steps >= cfg["max_steps"]:
337
+ break
338
+ optimizer.zero_grad(set_to_none=True)
339
+ batch = _move_to_device(_encode_sample(processor, sample, cfg["model_id"]), device)
340
+ loss = model(**batch).loss
341
+ loss.backward()
342
+ optimizer.step()
343
+ train_losses.append(float(loss.detach().cpu()))
344
+ completed_steps += 1
345
+ if cfg["max_steps"] > 0 and completed_steps >= cfg["max_steps"]:
346
+ break
347
+
348
+ eval_loss_after = _average_loss(model, processor, eval_samples, cfg["model_id"], device)
349
+
350
  timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
351
  run_id = f"lora_run_{timestamp}"
352
+ run_dir = ARTIFACT_ROOT / run_id
353
+ checkpoint_dir = run_dir / "checkpoint-lora-v1"
354
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
355
+ model.save_pretrained(checkpoint_dir)
356
+ processor.save_pretrained(checkpoint_dir / "processor")
357
+
358
+ manifest = _load_json(_project_path(cfg["dataset_manifest_path"]))
359
+ metadata = {
360
+ "adapter_status": "fine_tuned",
361
+ "base_model": cfg["model_id"],
362
+ "base_revision": cfg["revision"],
363
+ "completed_steps": completed_steps,
364
+ "created_at": _utc_now(),
365
+ "dataset_manifest_checksum": manifest.get("manifest_checksum"),
366
+ "dataset_sample_count": manifest.get("sample_count"),
367
+ "eval_loss_after": eval_loss_after,
368
+ "eval_loss_before": eval_loss_before,
369
+ "eval_loss_delta": None
370
+ if eval_loss_before is None or eval_loss_after is None
371
+ else eval_loss_before - eval_loss_after,
372
+ "lora_alpha": cfg["lora_alpha"],
373
+ "lora_dropout": cfg["lora_dropout"],
374
+ "lora_r": cfg["lora_r"],
375
+ "max_steps": cfg["max_steps"],
376
+ "target_modules": target_modules,
377
+ "train_loss_mean": None if not train_losses else sum(train_losses) / len(train_losses),
378
+ "train_sample_count_used": len(train_samples),
379
+ "validation_sample_count_used": len(eval_samples),
380
+ }
381
+ (checkpoint_dir / "landfillsentry_adapter_metadata.json").write_text(
382
+ json.dumps(metadata, indent=2, sort_keys=True),
383
+ encoding="utf-8",
384
+ )
385
+ (checkpoint_dir / "README.md").write_text(
386
+ "# LandfillSentry LFM2-VL LoRA Adapter\n\n"
387
+ "PEFT LoRA adapter trained on the frozen LandfillSentry satellite evidence-panel dataset.\n\n"
388
+ f"- Base model: `{cfg['model_id']}@{cfg['revision']}`\n"
389
+ f"- Completed optimizer steps: `{completed_steps}`\n"
390
+ f"- Validation loss before: `{eval_loss_before}`\n"
391
+ f"- Validation loss after: `{eval_loss_after}`\n"
392
+ f"- Dataset checksum: `{manifest.get('manifest_checksum')}`\n",
393
+ encoding="utf-8",
394
  )
395
+
396
+ run_manifest = {
397
+ "adapter_artifact_ref": f"modal-volume://{VOLUME_NAME}/{run_id}/checkpoint-lora-v1",
398
+ "artifact_volume": VOLUME_NAME,
399
+ "checkpoint_dir": str(checkpoint_dir),
400
+ "config": {k: v for k, v in cfg.items() if k != "hf_token"},
401
+ "created_at": _utc_now(),
402
+ "manifest_path": str(run_dir / "run_manifest.json"),
403
+ "metrics": metadata,
404
+ "model_id": cfg["model_id"],
405
+ "revision": cfg["revision"],
406
+ "run_id": run_id,
407
+ "status": "ok",
408
+ "training_mode": "peft_lora_supervised",
409
+ }
410
+ (run_dir / "run_manifest.json").write_text(json.dumps(run_manifest, indent=2, sort_keys=True), encoding="utf-8")
411
  volume.commit()
412
+
413
+ return {
414
+ **run_manifest,
415
+ "adapter_archive_b64": _archive_dir_b64(checkpoint_dir),
416
+ }
417
 
418
 
419
+ def _extract_adapter_archive(result: Dict[str, Any], output_root: Path) -> Path | None:
420
+ archive_b64 = result.pop("adapter_archive_b64", None)
421
+ if not archive_b64:
422
+ return None
423
+ run_id = str(result.get("run_id", "lora_run_latest"))
424
+ out_dir = output_root / run_id / "checkpoint-lora-v1"
425
+ out_dir.mkdir(parents=True, exist_ok=True)
426
+ data = base64.b64decode(archive_b64.encode("ascii"))
427
+ with zipfile.ZipFile(io.BytesIO(data), mode="r") as zf:
428
+ zf.extractall(out_dir)
429
+ return out_dir
430
 
431
+
432
+ @app.local_entrypoint()
433
+ def main(config_json: str = "", output_dir: str = "data/processed/hf_adapter_trained") -> None:
434
+ config = json.loads(config_json) if config_json else {}
435
  smoke = gpu_smoke.remote()
436
  print("GPU smoke:", smoke)
437
  result = run_lora_training.remote(config)
438
+ local_adapter_dir = _extract_adapter_archive(result, Path(output_dir))
439
+ if local_adapter_dir is not None:
440
+ result["local_adapter_dir"] = str(local_adapter_dir)
441
+ print("LOCAL_ADAPTER_DIR:", local_adapter_dir)
442
+ print("Training result:", result)
443
  print("TRAINING_RESULT_JSON:", json.dumps(result, sort_keys=True))
tuned_checkpoint_v1.json CHANGED
@@ -1,14 +1,39 @@
1
- {
2
- "generated_by": "scripts/modal_gpu_check.py",
3
- "record_version": "phase6.checkpoint.v1",
4
- "result": {
5
- "adapter_artifact_ref": "modal-volume://landfillsentry-model-artifacts/lora_run_20260428T165129Z/checkpoint-lora-v1",
6
- "artifact_volume": "landfillsentry-model-artifacts",
7
- "checkpoint_dir": "/artifacts/lora_run_20260428T165129Z/checkpoint-lora-v1",
8
- "config_hash": "73365c006f6dc2b12e48b4535906b30e4f28ec0a46b78037da4f16e413d473c7",
9
- "manifest_path": "/artifacts/lora_run_20260428T165129Z/run_manifest.json",
10
- "run_id": "lora_run_20260428T165129Z",
11
- "status": "ok",
12
- "training_mode": "phase6_scaffold"
13
- }
14
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "generated_by": "ml/training/modal_lora_train.py",
3
+ "record_version": "phase6.checkpoint.v1",
4
+ "result": {
5
+ "adapter_artifact_ref": "modal-volume://landfillsentry-model-artifacts/lora_run_20260504T181913Z/checkpoint-lora-v1",
6
+ "artifact_volume": "landfillsentry-model-artifacts",
7
+ "checkpoint_dir": "data/processed/hf_adapter_trained/lora_run_20260504T181913Z/checkpoint-lora-v1",
8
+ "local_adapter_dir": "data/processed/hf_adapter_trained/lora_run_20260504T181913Z/checkpoint-lora-v1",
9
+ "metrics": {
10
+ "adapter_status": "fine_tuned",
11
+ "base_model": "LiquidAI/LFM2.5-VL-450M",
12
+ "base_revision": "main",
13
+ "completed_steps": 24,
14
+ "created_at": "2026-05-04T18:19:13.913342+00:00",
15
+ "dataset_manifest_checksum": "a6738e1af7d89f6fbd0d567c89759f6103beaa81553074a3ad520c6810988b01",
16
+ "dataset_sample_count": 78,
17
+ "eval_loss_after": 1.3696070164442062,
18
+ "eval_loss_before": 2.410613179206848,
19
+ "eval_loss_delta": 1.041006162762642,
20
+ "lora_alpha": 16,
21
+ "lora_dropout": 0.05,
22
+ "lora_r": 8,
23
+ "max_steps": 24,
24
+ "target_modules": [
25
+ "k_proj",
26
+ "q_proj",
27
+ "v_proj"
28
+ ],
29
+ "train_loss_mean": 1.8549085905154545,
30
+ "train_sample_count_used": 49,
31
+ "validation_sample_count_used": 8
32
+ },
33
+ "model_id": "LiquidAI/LFM2.5-VL-450M",
34
+ "revision": "main",
35
+ "run_id": "lora_run_20260504T181913Z",
36
+ "status": "ok",
37
+ "training_mode": "peft_lora_supervised"
38
+ }
39
+ }