File size: 21,951 Bytes
730c5bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
"""Serve a trained model through bankml β€” the verified CPU engine.

`push_to_bankml` is the bankml twin of `ollama_push.push_to_ollama`: merge the LoRA (or take an
already-merged directory) β†’ write a Modelfile that bankml can reproduce β†’ `bankml create`, so the
tag answers on `bankml serve --native` (OpenAI `/v1` + Ollama `/api`, a receipt on every answer).

bankml (https://github.com/cryptoAGI/bankml) is reached **only** as a CLI subprocess here and as
HTTP in `operator/backends/bankml.py`; none of its code is vendored (clean-room policy).

What bankml takes, and what this module therefore refuses *before* running anything (it never
drops an instruction silently β€” a dropped PARAMETER would serve a model that answers differently
from the one described):

- **Taken:** `FROM` (the merged safetensors directory, which `bankml create` converts to GGUF F16
  byte-identically to llama.cpp b11192, or a GGUF `bankml convert` already pinned), `SYSTEM`,
  `MESSAGE`, `LICENSE`, `REQUIRES`, `PARAMETER` temperature / top_k / top_p / min_p / seed /
  num_ctx / num_predict, and `stop`; from bankml 0.3.6 (its O2) also the penalties β€”
  repeat_penalty, repeat_last_n, presence_penalty, frequency_penalty β€” which it applies token
  for token as llama-server b11192 does. A small generation (SmolLM2 / mindx-genN) needs
  `repeat_penalty` to answer in words rather than runs of `,`.
- **Refused:** `ADAPTER` (merge first β€” this module does it for you given `adapter_dir`), a
  `TEMPLATE` other than the base's own, the penalties on a bankml older than 0.3.6,
  mirostat*, typical_p, resource options (num_gpu, num_thread, num_batch,
  num_keep, draft_num_predict), and any architecture other than Llama (SmolLM2 = mindx-genN):
  bankml converts Llama safetensors only; Qwen3 it serves only as a pinned Q1_0/Q2_0_g64 GGUF.

The merged directory is converted through a link named after the tag: llama.cpp's converter
names a model after the directory it reads (`merged` β†’ general.name "Merged"), and only the
tag's name gives the GGUF byte-identical to the one mindX pins (gen39 β†’ 6b64c748…).

`bankml create` / `bankml convert` arrive in bankml 0.3.5. An older binary is detected from
`bankml --help` (the verbs it lists) and reported as `status="bankml_too_old"`, never raised.

Every public function returns a result; nothing here raises past `BankmlPushResult`.
"""

from __future__ import annotations

import json
import os
import re
import shutil
import subprocess
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal

from mindxtrain.deploy.modelfile import ModelfileSpec, render_modelfile

BANKML_URL = "https://github.com/cryptoAGI/bankml"

# PARAMETERs bankml reproduces token-for-token (plus `stop`, carried on ModelfileSpec.stop).
BANKML_PARAMS: frozenset[str] = frozenset(
    {"temperature", "top_k", "top_p", "min_p", "seed", "num_ctx", "num_predict"},
)

_PENALTY = frozenset({"repeat_penalty", "presence_penalty", "frequency_penalty", "repeat_last_n"})
PENALTIES_SINCE = (0, 3, 6)  # bankml O2: penalties token-identical to llama-server b11192
_MIROSTAT = frozenset({"mirostat", "mirostat_tau", "mirostat_eta"})
_RESOURCE = frozenset({"num_gpu", "num_thread", "num_batch", "num_keep", "draft_num_predict"})

# Architectures `bankml convert` / `bankml create FROM <dir>` accept (config.json `architectures`).
BANKML_CONVERT_ARCHS: frozenset[str] = frozenset({"LlamaForCausalLM"})

_NAME = re.compile(r"^[a-z0-9_][a-z0-9._-]{0,127}$")
_SHA = r"[0-9a-f]{64}"

PushStatus = Literal[
    "created", "refused", "bankml_missing", "bankml_too_old", "merge_failed", "failed", "error",
]


def version_tuple(version: str) -> tuple[int, ...]:
    """'0.3.6' β†’ (0, 3, 6); '' or unparseable β†’ () (treated as older than any release)."""
    m = re.match(r"(\d+)\.(\d+)\.(\d+)", version or "")
    return tuple(int(g) for g in m.groups()) if m else ()


def penalties_supported(version: str) -> bool:
    return version_tuple(version) >= PENALTIES_SINCE


def _param_refusal(name: str) -> str:
    if name in _PENALTY:
        return (f"PARAMETER {name}: penalties are reproduced from bankml "
                f"{'.'.join(map(str, PENALTIES_SINCE))} (O2); this bankml predates it β€” upgrade, "
                "or leave it out")
    if name in _MIROSTAT:
        return f"PARAMETER {name}: mirostat is not reproduced by bankml; leave it out"
    if name == "typical_p":
        return "PARAMETER typical_p: locally-typical sampling is not reproduced by bankml"
    if name in _RESOURCE:
        return (f"PARAMETER {name}: a resource option is not part of a bankml model "
                "(bankml's answer does not depend on threads, batch or GPU layers)")
    if name == "stop":
        return "PARAMETER stop: pass stop sequences as ModelfileSpec.stop, not in parameters"
    return f"PARAMETER {name}: not a parameter bankml reproduces"


# ---- capability probe -------------------------------------------------------


@dataclass(frozen=True)
class BankmlCapabilities:
    """What the local `bankml` binary can do. `reason` is set when it cannot do what was asked."""

    binary: str | None
    version: str = ""
    has_create: bool = False
    has_convert: bool = False
    reason: str = ""

    @property
    def found(self) -> bool:
        return self.binary is not None


def bankml_capabilities(bankml_bin: str | None = None, *, timeout_s: float = 30.0) -> BankmlCapabilities:
    """Locate `bankml` and read its version and verbs (`bankml version`, `bankml --help`).

    The verbs listed in the usage text are the truth β€” an unreleased build may still say 0.3.4
    and already carry `create`. Never raises.
    """
    binary = bankml_bin or shutil.which("bankml")
    if not binary:
        return BankmlCapabilities(
            binary=None,
            reason=(f"`bankml` not found on PATH; install it from {BANKML_URL} "
                    "(or pass --bankml-bin)"),
        )
    try:
        ver = subprocess.run([binary, "version"], capture_output=True, text=True,
                             timeout=timeout_s, check=False)
        usage = subprocess.run([binary, "--help"], capture_output=True, text=True,
                               timeout=timeout_s, check=False)
    except (OSError, subprocess.SubprocessError) as exc:
        return BankmlCapabilities(binary=binary, reason=f"cannot run {binary}: {exc}")
    m = re.search(r"bankml\s+(\d+\.\d+\.\d+\S*)", ver.stdout + ver.stderr)
    text = usage.stdout + usage.stderr
    return BankmlCapabilities(
        binary=binary,
        version=m.group(1) if m else "",
        has_create="bankml create" in text,
        has_convert="bankml convert" in text,
    )


# ---- the Modelfile subset ----------------------------------------------------


@dataclass(frozen=True)
class BankmlSanitizeResult:
    """`ok` β†’ `spec` is exactly what bankml will reproduce; else `refusals` say why, one per line."""

    spec: ModelfileSpec
    refusals: tuple[str, ...] = ()

    @property
    def ok(self) -> bool:
        return not self.refusals


def bankml_sanitize(
    spec: ModelfileSpec, *, base_template: str | None = None, penalties: bool = False,
) -> BankmlSanitizeResult:
    """Check a ModelfileSpec against the subset `bankml create` reproduces.

    Refuses (and names) every instruction bankml would not honour; it never drops one, because a
    silently dropped PARAMETER serves a model that answers differently from the spec. A
    `TEMPLATE` passes only when it equals `base_template` (the base GGUF's own chat template).
    `penalties=True` admits the penalties (bankml 0.3.6+; see `penalties_supported`).
    """
    refusals: list[str] = []
    if spec.adapter:
        refusals.append("ADAPTER: bankml does not merge a LoRA at create time; merge it first "
                        "(push_to_bankml does, given adapter_dir) and FROM the merged directory")
    if spec.template and spec.template != base_template:
        refusals.append("TEMPLATE: bankml renders the base model's own chat template "
                        "(byte-identical to llama.cpp); a different template is not reproduced β€” "
                        "leave TEMPLATE out")
    allowed = BANKML_PARAMS | _PENALTY if penalties else BANKML_PARAMS
    for name in sorted(spec.parameters):
        if name not in allowed:
            refusals.append(_param_refusal(name))
    for label, value in (("SYSTEM", spec.system), ("TEMPLATE", spec.template),
                         ("LICENSE", spec.license)):
        if '"""' in value:
            refusals.append(f'{label}: contains """, which a Modelfile cannot quote')
    for stop in spec.stop:
        if '"' in stop or "\n" in stop:
            refusals.append(f"stop {stop!r}: a quote or newline cannot be written as PARAMETER stop")
    return BankmlSanitizeResult(spec=spec, refusals=tuple(refusals))


def check_merged_arch(merged_dir: Path) -> str | None:
    """None when `merged_dir/config.json` names an architecture bankml converts (or has none to
    read β€” then bankml decides); else the refusal reason."""
    try:
        cfg = json.loads((merged_dir / "config.json").read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return None
    archs = cfg.get("architectures") or []
    if not archs or any(a in BANKML_CONVERT_ARCHS for a in archs):
        return None
    return (f"architecture {', '.join(map(str, archs))}: bankml converts Llama-architecture "
            "safetensors only (SmolLM2 / mindx-genN); Qwen3 it serves only as a pinned "
            "Q1_0/Q2_0_g64 GGUF β€” use `serve --to ollama` or `--to vllm` for this model")


# Families mindXtrain trains that bankml cannot convert (checked by name before a merge is spent).
_UNCONVERTIBLE_FAMILIES = ("qwen", "mistral", "phi", "gemma", "glm", "deepseek", "instella")


def base_family_refusal(base_model: str) -> str | None:
    """A cheap, name-based pre-check (before merging): None unless `base_model` names a family
    bankml cannot convert. The merged `config.json` is still checked afterwards."""
    low = base_model.lower()
    if "smollm" in low or "llama" in low:
        return None
    hit = next((f for f in _UNCONVERTIBLE_FAMILIES if f in low), None)
    if hit is None:
        return None
    return (f"base {base_model}: the {hit} family is not a Llama-architecture model bankml can "
            "convert (bankml converts SmolLM2 / mindx-genN; Qwen3 only as a pinned "
            "Q1_0/Q2_0_g64 GGUF) β€” use `serve --to ollama` or `--to vllm`")


def bankml_registry_dir(registry_dir: Path | None = None) -> Path:
    """bankml's registry (pins + derived models): `--registry`, else $BANKML_FORKS, else
    ~/.local/share/bankml/forks β€” the same resolution `bankml create` uses."""
    if registry_dir is not None:
        return Path(registry_dir).expanduser()
    env = os.environ.get("BANKML_FORKS")
    if env:
        return Path(env).expanduser()
    return Path.home() / ".local" / "share" / "bankml" / "forks"


# ---- push --------------------------------------------------------------------


@dataclass(frozen=True)
class BankmlPushResult:
    """Outcome of `push_to_bankml`. `status == "created"` is the only success."""

    status: PushStatus
    tag: str
    reason: str = ""
    refusals: tuple[str, ...] = ()
    bankml_version: str = ""
    merged_dir: Path | None = None
    gguf: Path | None = None
    modelfile: Path | None = None
    model_sha256: str = ""
    digest: str = ""
    output: str = ""
    mindx_fallback_swap: dict[str, str] | None = None
    log: tuple[str, ...] = field(default=())

    @property
    def ok(self) -> bool:
        return self.status == "created"


def _run(cmd: list[str], timeout_s: float) -> tuple[int, str, str]:
    proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, check=False)
    return proc.returncode, proc.stdout or "", proc.stderr or ""


def push_to_bankml(
    base_model: str,
    tag: str,
    *,
    adapter_dir: Path | None = None,
    merged_dir: Path | None = None,
    system: str | None = None,
    params: dict[str, float | int | str] | None = None,
    stop: list[str] | None = None,
    bankml_bin: str | None = None,
    convert: bool = False,
    work_dir: Path | None = None,
    registry_dir: Path | None = None,
    register_with_mindx: bool = False,
    mindx_base_url: str | None = None,
    sink: Callable[[str], None] | None = None,
    timeout_s: float = 1800.0,
) -> BankmlPushResult:
    """Merge (if given an adapter) β†’ Modelfile (bankml subset) β†’ `bankml create <tag>`.

    Give exactly one of `adapter_dir` (a PEFT LoRA over `base_model`; merging needs
    `uv sync --extra ml`) or `merged_dir` (an already-merged HF safetensors directory).

    `convert=False` (default) writes `FROM <merged dir>` and lets `bankml create` convert and pin
    it. `convert=True` runs `bankml convert` first (GGUF + FORK.json into the registry) and
    writes `FROM <gguf>` β€” the explicit, inspectable two-step.

    The model sha256 recorded is the one bankml prints for the base it pinned and verified.
    With `register_with_mindx`, mindX's fallback model is swapped to `{provider: "bankml",
    model: tag}` (best-effort; a failure is logged, the push still stands).
    """
    lines: list[str] = []

    def emit(line: str) -> None:
        lines.append(line)
        if sink:
            sink(line)

    def done(status: PushStatus, **kw: object) -> BankmlPushResult:
        return BankmlPushResult(status=status, tag=tag, log=tuple(lines), **kw)  # type: ignore[arg-type]

    name = tag.strip().lower().removesuffix(":latest")
    if not _NAME.match(name) or name.endswith(".gguf"):
        return done("refused", reason=(f"tag {tag!r}: bankml names use letters, digits, '.', '_' "
                                       "and '-' (at most 128, no ':tag', not ending in .gguf)"))
    if (adapter_dir is None) == (merged_dir is None):
        return done("error", reason="give exactly one of adapter_dir or merged_dir")

    # 1. the subset β€” checked before anything expensive runs
    spec = ModelfileSpec(from_model="<merged>", system=system or "", parameters=dict(params or {}),
                         stop=list(stop or []))
    checked = bankml_sanitize(spec, penalties=True)  # penalties are gated on the version below
    if not checked.ok:
        for r in checked.refusals:
            emit(f"[push-bankml] refuse: {r}")
        return done("refused", reason="the Modelfile asks for what bankml does not reproduce",
                    refusals=checked.refusals)

    # 2. the binary, and whether it has the verbs
    caps = bankml_capabilities(bankml_bin)
    if not caps.found:
        return done("bankml_missing", reason=caps.reason)
    if caps.reason:
        return done("error", reason=caps.reason, bankml_version=caps.version)
    missing = [v for v, have in (("create", caps.has_create), ("convert", caps.has_convert))
               if (v == "create" or convert) and not have]
    if missing:
        return done("bankml_too_old", bankml_version=caps.version, reason=(
            f"bankml {caps.version or '(unknown version)'} has no `{'`/`'.join(missing)}` verb "
            f"(they arrive in bankml 0.3.5); upgrade from {BANKML_URL}"))
    asked = sorted(_PENALTY & set(spec.parameters))
    if asked and not penalties_supported(caps.version):
        refusals = tuple(_param_refusal(n) for n in asked)
        for r in refusals:
            emit(f"[push-bankml] refuse: {r}")
        return done("refused", bankml_version=caps.version, refusals=refusals,
                    reason=f"bankml {caps.version or '(unknown version)'} does not reproduce penalties")
    emit(f"[push-bankml] bankml {caps.version} at {caps.binary}")

    work = Path(work_dir) if work_dir else (
        (adapter_dir or merged_dir).parent / "bankml_push")  # type: ignore[union-attr]

    # 3. merge, when given an adapter
    if adapter_dir is not None:
        try:
            from mindxtrain.deploy.ollama_push import merge_lora_adapter

            merged = merge_lora_adapter(base_model, Path(adapter_dir), work / "merged", sink=emit)
        except ImportError as exc:
            return done("merge_failed", bankml_version=caps.version, reason=(
                f"{exc} β€” merging a LoRA needs `uv sync --extra ml`"))
        except Exception as exc:  # a merge failure is a result, not a crash
            return done("merge_failed", bankml_version=caps.version,
                        reason=f"{type(exc).__name__}: {exc}")
    else:
        merged = Path(merged_dir)  # type: ignore[arg-type]
        if not merged.is_dir():
            return done("error", bankml_version=caps.version,
                        reason=f"merged_dir {merged} is not a directory")

    arch_refusal = check_merged_arch(merged)
    if arch_refusal:
        emit(f"[push-bankml] refuse: {arch_refusal}")
        return done("refused", bankml_version=caps.version, merged_dir=merged,
                    reason=arch_refusal, refusals=(arch_refusal,))

    registry = bankml_registry_dir(registry_dir)
    reg_args = ["--registry", str(registry)] if registry_dir is not None else []
    gguf: Path | None = None
    model_sha = ""
    try:
        # 4. optional explicit conversion β€” through a link named after the tag, so the GGUF's
        #    general.name (and so its bytes and sha256) is the one mindX pins
        source = str(_named_source(merged, work / name, name))
        if convert:
            registry.mkdir(parents=True, exist_ok=True)
            gguf = registry / f"{name}-base-F16.gguf"
            cmd = [str(caps.binary), "convert", source, "-o", str(gguf),
                   "--fork", str(registry / f"{gguf.name}.FORK.json"), "--source", source]
            emit(f"[push-bankml] $ {' '.join(cmd)}")
            code, out, err = _run(cmd, timeout_s)
            if err.strip():
                emit(err.rstrip())
            if code != 0:
                status: PushStatus = "refused" if code == 2 else "failed"
                return done(status, bankml_version=caps.version, merged_dir=merged,
                            reason=_last_line(err) or f"bankml convert exited {code}",
                            output=(out + err)[-2000:])
            m = re.search(rf"^({_SHA})\s", out, re.MULTILINE)
            model_sha = m.group(1) if m else ""

        # 5. the Modelfile and `bankml create`
        spec = spec.model_copy(update={"from_model": str(gguf) if gguf else source})
        modelfile = work / name / "Modelfile"
        modelfile.parent.mkdir(parents=True, exist_ok=True)
        modelfile.write_text(render_modelfile(spec), encoding="utf-8")
        cmd = [str(caps.binary), "create", name, "-f", str(modelfile), *reg_args]
        emit(f"[push-bankml] $ {' '.join(cmd)}")
        code, out, err = _run(cmd, timeout_s)
    except (OSError, subprocess.SubprocessError) as exc:
        return done("error", bankml_version=caps.version, merged_dir=merged, gguf=gguf,
                    reason=f"{type(exc).__name__}: {exc}")
    for chunk in (out, err):
        if chunk.strip():
            emit(chunk.rstrip())
    if code != 0:
        return done("refused" if code == 2 else "failed", bankml_version=caps.version,
                    merged_dir=merged, gguf=gguf, modelfile=modelfile,
                    reason=_last_line(err) or f"bankml create exited {code}",
                    output=(out + err)[-2000:])

    both = out + "\n" + err
    over = re.search(rf"over (\S+) \(sha256 ({_SHA})\)", both)
    digest = re.search(rf"digest sha256:({_SHA})", both)
    if over:
        model_sha = over.group(2)
        if gguf is None:
            gguf = registry / over.group(1)
    elif not model_sha and gguf is not None:
        code_s, out_s, _ = _run([str(caps.binary), "sha256", str(gguf)], 600.0)
        m = re.match(rf"({_SHA})\s", out_s)
        model_sha = m.group(1) if (code_s == 0 and m) else ""

    swap: dict[str, str] | None = None
    if register_with_mindx:
        try:
            from mindxtrain.deploy.api_client import swap_mindx_fallback_model

            swap = swap_mindx_fallback_model(provider="bankml", model=name, api_url=mindx_base_url)
            emit(f"[push-bankml] mindX swap: {swap.get('previous', '?')} -> "
                 f"{swap.get('current', '?')}")
        except Exception as exc:  # best-effort, as push_to_ollama
            emit(f"[push-bankml] mindX registration failed (push still ok): {exc}")
            swap = None

    emit(f"[push-bankml] created {name} over sha256 {model_sha or '(not reported)'}")
    return done("created", bankml_version=caps.version, merged_dir=merged, gguf=gguf,
                modelfile=modelfile, model_sha256=model_sha,
                digest=digest.group(1) if digest else "", output=both.strip()[-2000:],
                mindx_fallback_swap=swap)


def _named_source(merged: Path, at: Path, name: str) -> Path:
    """A link `at/<name>` β†’ `merged`, unless `merged` is already called `name`."""
    target = merged.resolve()
    if target.name == name:
        return target
    at.mkdir(parents=True, exist_ok=True)
    link = at / name
    if link.is_symlink():
        link.unlink()
    elif link.exists():
        return link  # a real directory by that name: the caller put it there
    link.symlink_to(target, target_is_directory=True)
    return link


def _last_line(text: str) -> str:
    rows = [r.strip() for r in text.strip().splitlines() if r.strip()]
    return rows[-1] if rows else ""


__all__ = [
    "BANKML_CONVERT_ARCHS",
    "BANKML_PARAMS",
    "PENALTIES_SINCE",
    "BankmlCapabilities",
    "BankmlPushResult",
    "BankmlSanitizeResult",
    "bankml_capabilities",
    "bankml_registry_dir",
    "bankml_sanitize",
    "base_family_refusal",
    "check_merged_arch",
    "penalties_supported",
    "push_to_bankml",
    "version_tuple",
]