File size: 17,125 Bytes
dfb775d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4e0c8c
dfb775d
 
 
 
c4e0c8c
dfb775d
 
 
 
 
 
 
 
 
c4e0c8c
dfb775d
 
 
 
c4e0c8c
dfb775d
 
 
 
 
 
c4e0c8c
dfb775d
 
 
 
 
 
c4e0c8c
dfb775d
 
 
 
 
 
 
c4e0c8c
dfb775d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4e0c8c
dfb775d
 
 
 
 
c4e0c8c
dfb775d
 
 
c4e0c8c
dfb775d
 
 
 
 
 
c4e0c8c
dfb775d
 
 
 
c4e0c8c
dfb775d
 
 
 
 
 
 
 
c4e0c8c
dfb775d
 
 
c4e0c8c
dfb775d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730c5bb
 
 
 
 
 
 
 
 
 
 
c4e0c8c
 
730c5bb
 
dfb775d
 
 
730c5bb
 
 
 
 
dfb775d
 
 
 
 
c4e0c8c
dfb775d
 
 
 
 
 
c4e0c8c
dfb775d
 
 
 
 
 
 
 
 
 
c4e0c8c
dfb775d
 
730c5bb
c4e0c8c
dfb775d
 
 
 
 
 
 
c4e0c8c
dfb775d
 
 
 
 
 
 
 
 
 
 
 
c4e0c8c
dfb775d
 
 
 
 
 
c4e0c8c
 
dfb775d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4e0c8c
dfb775d
 
 
 
 
 
 
 
 
c4e0c8c
 
dfb775d
 
 
 
 
 
 
c4e0c8c
dfb775d
c4e0c8c
dfb775d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4e0c8c
 
 
dfb775d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4e0c8c
dfb775d
 
c4e0c8c
dfb775d
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
"""The Hugging Face extension β€” the Hub as mindXtrain needs it.

Design rules, learned the hard way on a live node (2026-09):

- **Membership is not write scope.** `whoami` lists orgs you belong to; only the token's own
  permissions say where it can write. `account()` reports both, separately.
- **`list_repo_tree` yields RepoFile AND RepoFolder**, both carrying `.path`; only
  `repo_info().siblings` carry `.rfilename`. Reading the wrong one makes a scan report "nothing
  on the Hub" while the repo is full.
- **The Hub checks a Space's ZeroGPU quota before it checks existence**: `create_repo(exist_ok=True,
  space_hardware=…)` on an EXISTING Space answers 402 once the account's slots are used. Check
  existence first.
- **A Space README's `short_description` must be ≀ 60 characters** or the upload is refused.
- **Never park a write-scoped token on a public Space.** Public Spaces read public repos.
"""
from __future__ import annotations

import json
import os
import re
import time
from pathlib import Path
from typing import Any

_HF_ENV = ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACEHUB_API_TOKEN")


def _token(explicit: str | None = None) -> str | None:
    if explicit:
        return explicit
    for k in _HF_ENV:
        v = os.environ.get(k)
        if v:
            return v
    return None


def _api(token: str | None = None):
    """(HfApi, None) or (None, {"ok": False, …}) β€” never raises."""
    try:
        from huggingface_hub import HfApi
    except ImportError:
        return None, {"ok": False, "reason": "huggingface_hub not installed β€” `uv sync --extra hf`"}
    tok = _token(token)
    if not tok:
        return None, {"ok": False, "reason": f"no token β€” set one of {', '.join(_HF_ENV)}"}
    return HfApi(token=tok), None


def tree_paths(api, repo_id: str, **kw) -> list[str]:
    """File paths of a repo tree (folders skipped). See the module docstring: the tree's entries
    carry `.path`, not `.rfilename`."""
    return [f.path for f in api.list_repo_tree(repo_id, **kw) if type(f).__name__ != "RepoFolder"]


# ── who the token is ──────────────────────────────────────────────────────────
def account(token: str | None = None) -> dict[str, Any]:
    """The identity behind the token, its role, the orgs it belongs to, and β€” separately β€” the
    namespaces it can actually write."""
    api, err = _api(token)
    if err:
        return err
    try:
        me = api.whoami()
    except Exception as e:
        return {"ok": False, "reason": f"whoami failed: {type(e).__name__}: {str(e)[:200]}"}
    auth = (me.get("auth") or {}).get("accessToken") or {}
    perms = auth.get("fineGrained") or {}
    writable, creatable = [], []
    for scope in (perms.get("scoped") or []):
        ent = (scope.get("entity") or {})
        name = ent.get("name") or ent.get("type")
        perms_list = scope.get("permissions") or []
        if any(p.startswith("repo.write") or p == "repo.content.write" for p in perms_list):
            writable.append(name)
        if "repo.write" in perms_list:
            creatable.append(name)
    return {"ok": True, "user": me.get("name"), "type": me.get("type"), "is_pro": bool(me.get("isPro")),
            "role": auth.get("role"), "orgs": [o.get("name") for o in (me.get("orgs") or [])],
            "writable_namespaces": writable or None, "creatable_namespaces": creatable or None,
            "note": "membership is not write scope β€” a token can belong to an org and still be unable to write it"}


# ── bases, fetched before the run rather than during it ───────────────────────
def pull_base(model_id: str, *, token: str | None = None, allow_patterns: list[str] | None = None) -> dict[str, Any]:
    """Download a base model to the local cache. Do this BEFORE training: a run that discovers a
    missing base three hours in has wasted three hours."""
    try:
        from huggingface_hub import snapshot_download
    except ImportError:
        return {"ok": False, "reason": "huggingface_hub not installed β€” `uv sync --extra hf`"}
    t0 = time.time()
    try:
        path = snapshot_download(model_id, token=_token(token), allow_patterns=allow_patterns)
    except Exception as e:
        return {"ok": False, "model": model_id, "reason": f"{type(e).__name__}: {str(e)[:200]}"}
    p = Path(path)
    return {"ok": True, "model": model_id, "path": str(p), "seconds": round(time.time() - t0, 1),
            "bytes": sum(f.stat().st_size for f in p.rglob("*") if f.is_file())}


def warm(config: Path | str, *, token: str | None = None) -> dict[str, Any]:
    """Pull whatever a run.yaml says it needs (the base model) so `train` starts cold-free."""
    try:
        import yaml
        cfg = yaml.safe_load(Path(config).read_text())
    except Exception as e:
        return {"ok": False, "reason": f"unreadable config: {type(e).__name__}: {str(e)[:160]}"}
    base = ((cfg or {}).get("model") or {}).get("name")
    if not base:
        return {"ok": False, "reason": "config has no model.name"}
    return dict(pull_base(base, token=token), config=str(config))


# ── a finished run, published ─────────────────────────────────────────────────
def _card(repo_id: str, meta: dict[str, Any]) -> str:
    """A model card written from the run's own numbers. No claim that is not in `meta`."""
    m = meta
    fm = {"license": m.get("license", "apache-2.0"), "library_name": "transformers",
          "pipeline_tag": "text-generation", "tags": ["mindxtrain", "lora", *list(m.get("tags") or [])]}
    if m.get("base"):
        fm["base_model"] = m["base"]
    if m.get("dataset"):
        fm["datasets"] = [m["dataset"]]
    head = "---\n" + "\n".join(
        f"{k}: {json.dumps(v) if isinstance(v, (list, dict)) else v}" for k, v in fm.items()) + "\n---\n\n"
    rows = [("base", m.get("base")), ("method", m.get("method")), ("corpus", m.get("dataset")),
            ("hardware", m.get("hardware")), ("wall", m.get("wall")), ("train loss", m.get("train_loss")),
            ("eval loss", m.get("eval_loss")), ("gate", m.get("gate")), ("framework", "mindXtrain " + str(m.get("framework_version") or ""))]
    table = "\n".join(f"| {k} | {v} |" for k, v in rows if v not in (None, ""))
    return (head + f"# {repo_id.split('/')[-1]}\n\n{m.get('summary', 'A mindXtrain run, published with its evidence.')}\n\n"
            f"| | |\n|---|---|\n{table}\n\n"
            "## Use\n\n```python\nfrom transformers import AutoModelForCausalLM, AutoTokenizer\n"
            f'tok = AutoTokenizer.from_pretrained("{repo_id}")\nm = AutoModelForCausalLM.from_pretrained("{repo_id}")\n```\n\n'
            + (f"## The gate\n\n{m['gate_note']}\n\n" if m.get("gate_note") else "")
            + "## Provenance\n\nTrained by [mindXtrain](https://github.com/professor-codephreak/mindXtrain); "
              "the training log ships beside the weights.\n")


def published_modelfile(persona_system: str | None = None, *,
                        repeat_penalty: float | None = 1.3) -> str:
    """The Modelfile shipped beside a published run. `repeat_penalty=None` leaves the penalty out,
    so engines that refuse penalties (bankml) load the same file."""
    return ("# ollama create <name> -f Modelfile   (from this repo's directory)\nFROM .\n"
            + (f'SYSTEM """{persona_system}"""\n' if persona_system else "")
            + "PARAMETER temperature 0.7\n"
            + (f"PARAMETER repeat_penalty {repeat_penalty}\n" if repeat_penalty is not None else "")
            + 'PARAMETER stop "<|im_end|>"\n')


def publish_generation(run_dir: Path | str, repo_id: str, *, token: str | None = None, private: bool = False,
                       meta: dict[str, Any] | None = None, persona_system: str | None = None,
                       include_merged: bool = True, dry_run: bool = False,
                       repeat_penalty: float | None = 1.3) -> dict[str, Any]:
    """A finished run as a model repo: merged weights at the root (if present), the LoRA delta under
    `adapter/`, `train.log`, a `Modelfile` for Ollama, and a card built from `meta`.

    `dry_run=True` reports exactly what would be uploaded and touches nothing.

    `repeat_penalty` is written into the Modelfile (default 1.3, the imprint gate's decoding);
    `None` leaves it out, so the same Modelfile also loads in engines that refuse penalties
    (bankml reproduces temperature/top-k/top-p/min-p, not penalties)."""
    run = Path(run_dir)
    if not run.is_dir():
        return {"ok": False, "reason": f"no run dir at {run}"}
    ck = run / "checkpoint"
    merged = run / "ollama_push" / "merged"
    staged: dict[str, Path] = {}
    if include_merged and merged.is_dir():
        for f in merged.iterdir():
            if f.is_file():
                staged[f.name] = f
    if ck.is_dir():
        for f in ck.iterdir():
            if (f.is_file() and f.name != "training_args.bin") or f.name == "training_args.bin":
                staged[f"adapter/{f.name}"] = f
    log = run / "train.log"
    if log.is_file():
        staged["train.log"] = log
    if not staged:
        return {"ok": False, "reason": f"{run} holds neither a checkpoint nor merged weights"}
    meta = dict(meta or {})
    if not meta.get("base"):
        try:
            meta["base"] = json.loads((ck / "adapter_config.json").read_text()).get("base_model_name_or_path")
        except Exception:
            pass
    card = _card(repo_id, meta)
    modelfile = published_modelfile(persona_system, repeat_penalty=repeat_penalty)
    plan = {"repo": repo_id, "private": private, "files": [*sorted(staged), "README.md", "Modelfile"],
            "bytes": sum(f.stat().st_size for f in staged.values())}
    if dry_run:
        return {"ok": True, "dry_run": True, "would_upload": plan, "card_preview": card[:400]}
    api, err = _api(token)
    if err:
        return err
    import shutil
    import tempfile
    with tempfile.TemporaryDirectory() as tmp:
        stage = Path(tmp)
        for rel, src in staged.items():
            dst = stage / rel
            dst.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(src, dst)
        (stage / "README.md").write_text(card)
        (stage / "Modelfile").write_text(modelfile)
        try:
            api.create_repo(repo_id, repo_type="model", private=private, exist_ok=True)
            ci = api.upload_folder(folder_path=str(stage), repo_id=repo_id, repo_type="model",
                                   commit_message=meta.get("commit_message") or "mindXtrain: a run, published with its evidence")
        except Exception as e:
            return {"ok": False, "repo": repo_id, "reason": f"{type(e).__name__}: {str(e)[:300]}"}
    return {"ok": True, "repo": repo_id, "url": f"https://huggingface.co/{repo_id}",
            "commit": str(getattr(ci, "oid", ci))[:12], "uploaded": plan}


# ── the corpus ────────────────────────────────────────────────────────────────
def push_dataset(path: Path | str, repo_id: str, *, token: str | None = None, private: bool = False,
                 path_in_repo: str = "", manifest: dict[str, Any] | None = None) -> dict[str, Any]:
    """Push a training corpus (a folder or one JSONL) with an optional manifest beside it."""
    api, err = _api(token)
    if err:
        return err
    src = Path(path)
    if not src.exists():
        return {"ok": False, "reason": f"no corpus at {src}"}
    try:
        api.create_repo(repo_id, repo_type="dataset", private=private, exist_ok=True)
        if src.is_dir():
            ci = api.upload_folder(folder_path=str(src), repo_id=repo_id, repo_type="dataset",
                                   path_in_repo=path_in_repo or None, commit_message="mindXtrain: corpus")
        else:
            ci = api.upload_file(path_or_fileobj=str(src), path_in_repo=f"{path_in_repo}/{src.name}".lstrip("/"),
                                 repo_id=repo_id, repo_type="dataset", commit_message="mindXtrain: corpus")
        if manifest:
            api.upload_file(path_or_fileobj=json.dumps(manifest, indent=1).encode(),
                            path_in_repo=f"{path_in_repo}/MANIFEST.json".lstrip("/"),
                            repo_id=repo_id, repo_type="dataset", commit_message="mindXtrain: corpus manifest")
    except Exception as e:
        return {"ok": False, "repo": repo_id, "reason": f"{type(e).__name__}: {str(e)[:300]}"}
    return {"ok": True, "repo": repo_id, "url": f"https://huggingface.co/datasets/{repo_id}",
            "commit": str(getattr(ci, "oid", ci))[:12]}


# ── what is actually on the Hub ───────────────────────────────────────────────
_GEN = re.compile(r"(?:^|/)gen(\d+)(?:/|$)")


def lineage(repo_id: str, *, repo_type: str = "model", token: str | None = None,
            local_runs: Path | str | None = None) -> dict[str, Any]:
    """Generations present in a repo, and β€” when `local_runs` is given β€” which local runs are not
    published yet. Uses `tree_paths`, so folders never break the scan."""
    api, err = _api(token)
    if err:
        return err
    try:
        paths = tree_paths(api, repo_id, repo_type=repo_type, recursive=True)
    except Exception as e:
        return {"ok": False, "repo": repo_id, "reason": f"{type(e).__name__}: {str(e)[:200]}"}
    gens: dict[int, list[str]] = {}
    for p in paths:
        m = _GEN.search(p)
        if m:
            gens.setdefault(int(m.group(1)), []).append(p)
    out = {"ok": True, "repo": repo_id, "repo_type": repo_type, "files": len(paths),
           "generations": {str(g): {"files": len(v),
                                    "adapter": any(x.endswith("adapter_model.safetensors") for x in v),
                                    "merged": any(x.endswith("model.safetensors") and "adapter" not in x for x in v)}
                           for g, v in sorted(gens.items())}}
    if local_runs:
        root = Path(local_runs)
        local = sorted(int(m.group(1)) for d in root.glob("gen*") if d.is_dir() for m in [_GEN.search(d.name + "/")] if m)
        out["local"] = local
        out["unpublished"] = [g for g in local if g not in gens]
    return out


# ── Spaces ────────────────────────────────────────────────────────────────────
def push_space(folder: Path | str, space_id: str, *, token: str | None = None, private: bool = True,
               hardware: str | None = "zero-a10g", variables: dict[str, str] | None = None,
               secrets: dict[str, str] | None = None) -> dict[str, Any]:
    """Push a Gradio folder as a Space. Existence is checked BEFORE creation (the Hub tests the
    ZeroGPU quota first and answers 402 on a repo that already exists), and a README
    `short_description` longer than 60 characters is refused by the Hub, so it is checked here."""
    api, err = _api(token)
    if err:
        return err
    src = Path(folder)
    if not (src / "app.py").is_file():
        return {"ok": False, "reason": f"{src} has no app.py"}
    readme = src / "README.md"
    if readme.is_file():
        m = re.search(r"^short_description:\s*(.+)$", readme.read_text(), re.M)
        if m and len(m.group(1).strip()) > 60:
            return {"ok": False, "reason": f"short_description is {len(m.group(1).strip())} chars; the Hub allows 60"}
    try:
        exists = api.repo_exists(space_id, repo_type="space")
        if not exists:
            api.create_repo(space_id, repo_type="space", space_sdk="gradio", private=private,
                            **({"space_hardware": hardware} if hardware else {}))
        ci = api.upload_folder(folder_path=str(src), repo_id=space_id, repo_type="space",
                               ignore_patterns=["__pycache__/*", "**/__pycache__/*", "*.pyc"],
                               commit_message="mindXtrain UI")
        for k, v in (variables or {}).items():
            api.add_space_variable(space_id, k, v)
        for k, v in (secrets or {}).items():
            api.add_space_secret(space_id, k, v)
        rt = api.get_space_runtime(space_id)
    except Exception as e:
        return {"ok": False, "space": space_id, "reason": f"{type(e).__name__}: {str(e)[:300]}"}
    return {"ok": True, "space": space_id, "existed": exists, "stage": str(rt.stage),
            "commit": str(getattr(ci, "oid", ci))[:12],
            "url": f"https://huggingface.co/spaces/{space_id}",
            "host": "https://" + space_id.replace("/", "-").replace("_", "-").lower() + ".hf.space",
            "note": "free personal accounts host 2 ZeroGPU Spaces; a free org hosts none (402)"}