publish_hf

Publish a marola-sea GGUF to a Hugging Face model repo (MIP-0025 §5.1, MIP-0033 §5.3).

Uploads one or more .gguf files plus a generated model card and a CHECKSUMS file (sha256 per file, so a Modelfile can pin an exact blob rather than a mutable filename — the same discipline MIP-0008's image gate applies to marola-local). Needs pip install huggingface_hub and a prior huggingface-cli login (or HF_TOKEN in the environment) — neither is run here, both are the human's own one-time setup per AGENTS.md's "never hardcode a key" rule.

This is the export chain's last step only. Get the .gguf file(s) first:

  1. just finetune-train preset=<preset> (LoRA adapter, finetune/out//adapter/)
  2. merge + convert with llama.cpp's convert_hf_to_gguf.py (base) or use ollama create + Modelfile.adapter for local-only use without merging (see finetune/README.md)
  3. this script, pointed at the resulting .gguf file(s)

Deliberately does not shell out to ollama push (MIP-0025 §5.1(2)'s optional second channel) — that needs a separate ollama signin and a private key this script has no business touching.

  1#!/usr/bin/env python3
  2"""Publish a marola-sea GGUF to a Hugging Face model repo (MIP-0025 §5.1, MIP-0033 §5.3).
  3
  4Uploads one or more `.gguf` files plus a generated model card and a `CHECKSUMS` file (sha256 per
  5file, so a Modelfile can pin an exact blob rather than a mutable filename — the same discipline
  6MIP-0008's image gate applies to `marola-local`). Needs `pip install huggingface_hub` and a prior
  7`huggingface-cli login` (or `HF_TOKEN` in the environment) — neither is run here, both are the
  8human's own one-time setup per `AGENTS.md`'s "never hardcode a key" rule.
  9
 10This is the export chain's *last* step only. Get the `.gguf` file(s) first:
 11  1. `just finetune-train preset=<preset>` (LoRA adapter, finetune/out/<preset>/adapter/)
 12  2. merge + convert with llama.cpp's `convert_hf_to_gguf.py` (base) or use `ollama create` +
 13     `Modelfile.adapter` for local-only use without merging (see finetune/README.md)
 14  3. this script, pointed at the resulting .gguf file(s)
 15
 16Deliberately does not shell out to `ollama push` (MIP-0025 §5.1(2)'s optional second channel) —
 17that needs a separate `ollama signin` and a private key this script has no business touching.
 18"""
 19
 20from __future__ import annotations
 21
 22import argparse
 23import hashlib
 24import sys
 25from pathlib import Path
 26
 27
 28def sha256_of(path: Path) -> str:
 29    h = hashlib.sha256()
 30    with path.open("rb") as f:
 31        for chunk in iter(lambda: f.read(1 << 20), b""):
 32            h.update(chunk)
 33    return h.hexdigest()
 34
 35
 36def checksums_file(gguf_paths: list[Path]) -> str:
 37    """`sha256sum`-compatible output — one line per file, sorted by name for a stable diff."""
 38    lines = [f"{sha256_of(p)}  {p.name}" for p in sorted(gguf_paths, key=lambda p: p.name)]
 39    return "\n".join(lines) + "\n"
 40
 41
 42def model_card(
 43    repo_id: str,
 44    base_model: str,
 45    base_license: str,
 46    gguf_paths: list[Path],
 47    eval_note: str,
 48) -> str:
 49    """Check by hand against MIP-0025 §5.1(2)'s required sections before a real publish."""
 50    files = "\n".join(f"- `{p.name}` ({p.stat().st_size / 1e6:.0f} MB)" for p in gguf_paths)
 51    return f"""---
 52base_model: {base_model}
 53license: {base_license}
 54tags:
 55- gguf
 56- marola
 57- ocean
 58pipeline_tag: text-generation
 59---
 60
 61# {repo_id.split("/")[-1]}
 62
 63A GGUF release of a marola-sea checkpoint — a small model tuned on marola's own
 64question/answer shape (open-water swim conditions, safety, sea life), served locally through
 65[Ollama](https://ollama.com) alongside marola's own RAG corpus and deterministic scoring
 66(`Recommender`/`Swimability`, never the model itself).
 67
 68**Honest framing (MIP-0025 §6, §8):** tuning changes tone and format reliability, not factual
 69grounding. This model does not replace marola's RAG corpus (`knowledge/`, cited answers only) or
 70its `Reviewer` pass, and it is **not a standalone safety authority** — treat any first-aid or
 71hazard answer as a starting point, not a substitute for a lifeguard or emergency services (marola's
 72own answers carry this caveat automatically via the MIP-0022 safety footer; this raw checkpoint,
 73used outside marola, does not).
 74
 75## Files
 76
 77{files}
 78
 79`CHECKSUMS` (sha256) ships alongside these files — pin a specific hash in your own `Modelfile`
 80rather than a bare filename, so re-quantizing upstream can't silently change what you run.
 81
 82## Use with Ollama
 83
 84```bash
 85ollama run hf.co/{repo_id}
 86# or a specific quant tag, e.g.:
 87ollama run hf.co/{repo_id}:Q4_K_M
 88```
 89
 90## Base model and training
 91
 92Fine-tuned from [{base_model}](https://huggingface.co/{base_model}) via LoRA (`finetune/train_lora.py`
 93in [h0ffmann/marola](https://github.com/h0ffmann/marola)) on a small, hand-built dataset derived
 94from marola's own DSPy-compiled demos, sea-lore entries, and knowledge-corpus Q&A
 95(`finetune/build_dataset.py`) — a few dozen examples, enough to teach format and tone, not facts.
 96
 97{eval_note}
 98
 99## Licence
100
101Base model licence: {base_license}. See the base model's own repo for the full licence text.
102"""
103
104
105def main(argv: list[str] | None = None) -> int:
106    parser = argparse.ArgumentParser(description=__doc__)
107    parser.add_argument("--repo", required=True, help="e.g. yourname/marola-sea-tiny-GGUF")
108    parser.add_argument(
109        "--gguf", nargs="+", required=True, type=Path, help="one or more .gguf files"
110    )
111    parser.add_argument(
112        "--base-model", required=True, help="e.g. HuggingFaceTB/SmolLM2-360M-Instruct"
113    )
114    parser.add_argument(
115        "--base-license",
116        default="apache-2.0",
117        help="the BASE model's licence id (not a generic default — check it per MIP-0025 §5.1(3): "
118        "a Llama-derived model has a different name requirement entirely, see finetune/README.md)",
119    )
120    parser.add_argument(
121        "--eval-note",
122        default="No `just benchmark` numbers recorded yet for this checkpoint — see "
123        "docs/benchmarks/ in the source repo before trusting this over a plain base model.",
124    )
125    parser.add_argument("--private", action="store_true", help="create the repo private")
126    parser.add_argument(
127        "--dry-run",
128        action="store_true",
129        help="write CHECKSUMS + the model card locally, upload nothing",
130    )
131    args = parser.parse_args(argv)
132
133    missing = [p for p in args.gguf if not p.is_file()]
134    if missing:
135        print(f"error: not found: {', '.join(str(p) for p in missing)}", file=sys.stderr)
136        return 1
137
138    checksums = checksums_file(args.gguf)
139    card = model_card(args.repo, args.base_model, args.base_license, args.gguf, args.eval_note)
140
141    if args.dry_run:
142        out_dir = args.gguf[0].parent
143        (out_dir / "CHECKSUMS").write_text(checksums)
144        (out_dir / "README.md").write_text(card)
145        print(
146            f"dry run: wrote {out_dir / 'CHECKSUMS'} and {out_dir / 'README.md'}, uploaded nothing"
147        )
148        return 0
149
150    try:
151        from huggingface_hub import HfApi
152    except ImportError:
153        print("error: pip install huggingface_hub (see finetune/requirements.txt)", file=sys.stderr)
154        return 1
155
156    api = HfApi()
157    api.create_repo(args.repo, repo_type="model", private=args.private, exist_ok=True)
158    api.upload_file(
159        path_or_fileobj=checksums.encode("utf-8"), path_in_repo="CHECKSUMS", repo_id=args.repo
160    )
161    api.upload_file(
162        path_or_fileobj=card.encode("utf-8"), path_in_repo="README.md", repo_id=args.repo
163    )
164    for p in args.gguf:
165        api.upload_file(path_or_fileobj=str(p), path_in_repo=p.name, repo_id=args.repo)
166        print(f"uploaded {p.name}")
167
168    print(f"done: https://huggingface.co/{args.repo}")
169    print(f"try it: ollama run hf.co/{args.repo}")
170    return 0
171
172
173if __name__ == "__main__":
174    raise SystemExit(main())
def sha256_of(path: pathlib.Path) -> str:
29def sha256_of(path: Path) -> str:
30    h = hashlib.sha256()
31    with path.open("rb") as f:
32        for chunk in iter(lambda: f.read(1 << 20), b""):
33            h.update(chunk)
34    return h.hexdigest()
def checksums_file(gguf_paths: list[pathlib.Path]) -> str:
37def checksums_file(gguf_paths: list[Path]) -> str:
38    """`sha256sum`-compatible output — one line per file, sorted by name for a stable diff."""
39    lines = [f"{sha256_of(p)}  {p.name}" for p in sorted(gguf_paths, key=lambda p: p.name)]
40    return "\n".join(lines) + "\n"

sha256sum-compatible output — one line per file, sorted by name for a stable diff.

def model_card( repo_id: str, base_model: str, base_license: str, gguf_paths: list[pathlib.Path], eval_note: str) -> str:
 43def model_card(
 44    repo_id: str,
 45    base_model: str,
 46    base_license: str,
 47    gguf_paths: list[Path],
 48    eval_note: str,
 49) -> str:
 50    """Check by hand against MIP-0025 §5.1(2)'s required sections before a real publish."""
 51    files = "\n".join(f"- `{p.name}` ({p.stat().st_size / 1e6:.0f} MB)" for p in gguf_paths)
 52    return f"""---
 53base_model: {base_model}
 54license: {base_license}
 55tags:
 56- gguf
 57- marola
 58- ocean
 59pipeline_tag: text-generation
 60---
 61
 62# {repo_id.split("/")[-1]}
 63
 64A GGUF release of a marola-sea checkpoint — a small model tuned on marola's own
 65question/answer shape (open-water swim conditions, safety, sea life), served locally through
 66[Ollama](https://ollama.com) alongside marola's own RAG corpus and deterministic scoring
 67(`Recommender`/`Swimability`, never the model itself).
 68
 69**Honest framing (MIP-0025 §6, §8):** tuning changes tone and format reliability, not factual
 70grounding. This model does not replace marola's RAG corpus (`knowledge/`, cited answers only) or
 71its `Reviewer` pass, and it is **not a standalone safety authority** — treat any first-aid or
 72hazard answer as a starting point, not a substitute for a lifeguard or emergency services (marola's
 73own answers carry this caveat automatically via the MIP-0022 safety footer; this raw checkpoint,
 74used outside marola, does not).
 75
 76## Files
 77
 78{files}
 79
 80`CHECKSUMS` (sha256) ships alongside these files — pin a specific hash in your own `Modelfile`
 81rather than a bare filename, so re-quantizing upstream can't silently change what you run.
 82
 83## Use with Ollama
 84
 85```bash
 86ollama run hf.co/{repo_id}
 87# or a specific quant tag, e.g.:
 88ollama run hf.co/{repo_id}:Q4_K_M
 89```
 90
 91## Base model and training
 92
 93Fine-tuned from [{base_model}](https://huggingface.co/{base_model}) via LoRA (`finetune/train_lora.py`
 94in [h0ffmann/marola](https://github.com/h0ffmann/marola)) on a small, hand-built dataset derived
 95from marola's own DSPy-compiled demos, sea-lore entries, and knowledge-corpus Q&A
 96(`finetune/build_dataset.py`) — a few dozen examples, enough to teach format and tone, not facts.
 97
 98{eval_note}
 99
100## Licence
101
102Base model licence: {base_license}. See the base model's own repo for the full licence text.
103"""

Check by hand against MIP-0025 §5.1(2)'s required sections before a real publish.

def main(argv: list[str] | None = None) -> int:
106def main(argv: list[str] | None = None) -> int:
107    parser = argparse.ArgumentParser(description=__doc__)
108    parser.add_argument("--repo", required=True, help="e.g. yourname/marola-sea-tiny-GGUF")
109    parser.add_argument(
110        "--gguf", nargs="+", required=True, type=Path, help="one or more .gguf files"
111    )
112    parser.add_argument(
113        "--base-model", required=True, help="e.g. HuggingFaceTB/SmolLM2-360M-Instruct"
114    )
115    parser.add_argument(
116        "--base-license",
117        default="apache-2.0",
118        help="the BASE model's licence id (not a generic default — check it per MIP-0025 §5.1(3): "
119        "a Llama-derived model has a different name requirement entirely, see finetune/README.md)",
120    )
121    parser.add_argument(
122        "--eval-note",
123        default="No `just benchmark` numbers recorded yet for this checkpoint — see "
124        "docs/benchmarks/ in the source repo before trusting this over a plain base model.",
125    )
126    parser.add_argument("--private", action="store_true", help="create the repo private")
127    parser.add_argument(
128        "--dry-run",
129        action="store_true",
130        help="write CHECKSUMS + the model card locally, upload nothing",
131    )
132    args = parser.parse_args(argv)
133
134    missing = [p for p in args.gguf if not p.is_file()]
135    if missing:
136        print(f"error: not found: {', '.join(str(p) for p in missing)}", file=sys.stderr)
137        return 1
138
139    checksums = checksums_file(args.gguf)
140    card = model_card(args.repo, args.base_model, args.base_license, args.gguf, args.eval_note)
141
142    if args.dry_run:
143        out_dir = args.gguf[0].parent
144        (out_dir / "CHECKSUMS").write_text(checksums)
145        (out_dir / "README.md").write_text(card)
146        print(
147            f"dry run: wrote {out_dir / 'CHECKSUMS'} and {out_dir / 'README.md'}, uploaded nothing"
148        )
149        return 0
150
151    try:
152        from huggingface_hub import HfApi
153    except ImportError:
154        print("error: pip install huggingface_hub (see finetune/requirements.txt)", file=sys.stderr)
155        return 1
156
157    api = HfApi()
158    api.create_repo(args.repo, repo_type="model", private=args.private, exist_ok=True)
159    api.upload_file(
160        path_or_fileobj=checksums.encode("utf-8"), path_in_repo="CHECKSUMS", repo_id=args.repo
161    )
162    api.upload_file(
163        path_or_fileobj=card.encode("utf-8"), path_in_repo="README.md", repo_id=args.repo
164    )
165    for p in args.gguf:
166        api.upload_file(path_or_fileobj=str(p), path_in_repo=p.name, repo_id=args.repo)
167        print(f"uploaded {p.name}")
168
169    print(f"done: https://huggingface.co/{args.repo}")
170    print(f"try it: ollama run hf.co/{args.repo}")
171    return 0