File size: 6,215 Bytes
e51b495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Rebuild a merge from its parents' control vectors: apply a mergekit recipe to the vectors, layer by layer,
and bake the result into one GGUF.

A linear-family merge on stock moves the model by sum_i w_i(L) * delta_i in expectation, so the merge's vector
is the same weighted sum of the parents' vectors at each layer:

    direction.L = sum_i w_i(L) * parent_i.direction.L

w_i(L) is resolved as mergekit resolves it (mergekit/config.py evaluate_setting and mergekit/plan.py): a list
is a gradient interpolated at t = L / 59, conditional values take the first matching filter, then each
method's normalize default and lambda. The parent files are released GGUFs, which already carry their fitted
strength, so the baked file loads at 1.0 like any of them.

    python -m ecce_vector.bake recipe.yaml merge.gguf \\
        --parent Org/Parent-A=vectors/parent-a.gguf --parent Org/Parent-B=vectors/parent-b.gguf

Per-layer weights are what a single llama.cpp --control-vector-scaled weight per file cannot express; a
recipe with one constant weight per parent is just a launch-time blend and needs no baking.

Handled: linear, task_arithmetic, della_linear, dare_linear, and ties / dare_ties / della / breadcrumbs(_ties)
without their sign-election step (a weighted sum cannot reproduce it: a warning, and untested). Refused: other
methods (slerp, model_stock, ...), a base other than stock gemma-4-31B-it, and a recipe that weights a layer's
projections differently (one vector per layer cannot carry that).
"""
from __future__ import annotations

import argparse

import numpy as np
import yaml

from .io import N_LAYERS, read_gguf, write_gguf

STOCK = "gemma-4-31b-it"
LINEAR = {"linear", "task_arithmetic", "ties", "dare_ties", "dare_linear", "della", "della_linear", "breadcrumbs",
          "breadcrumbs_ties"}
# mergekit/merge_methods/registry.py: each method's default for `normalize` (linear.py defaults it to true)
NORMALIZE_DEFAULT = {"linear": True, "task_arithmetic": False, "ties": True, "dare_ties": False, "dare_linear": False,
                     "breadcrumbs": False, "breadcrumbs_ties": False, "della": True, "della_linear": False}
SIGN_ELECTION = {"ties", "dare_ties", "breadcrumbs_ties", "della"}
# one representative tensor per projection family (v_proj is absent on the full-attention layers)
TENSORS = ("self_attn.q_proj.weight", "self_attn.k_proj.weight", "self_attn.o_proj.weight", "mlp.gate_proj.weight",
           "mlp.up_proj.weight", "mlp.down_proj.weight")
PREFIX = "model.language_model.layers."


def evaluate(name: str, setting, t: float):
    """mergekit's evaluate_setting, numeric settings only."""
    if isinstance(setting, (int, float)):
        return float(setting)
    if isinstance(setting, list):
        if all(isinstance(e, (int, float)) for e in setting):
            scaled = t * (len(setting) - 1)
            i0 = int(scaled)
            i1 = min(len(setting) - 1, i0 + 1)
            return (1 - (scaled - i0)) * setting[i0] + (scaled - i0) * setting[i1]
        for cond in setting:
            f = cond.get("filter")
            if f is None or f == "*" or f in name:
                return evaluate(name, cond["value"], t)
        return None
    raise ValueError(f"unsupported setting {setting!r}")


def layer_weights(cfg: dict) -> tuple[dict[str, list[float]], list[tuple[str, int]]]:
    """Per parent, its weight at each layer; and the (parent, layer) pairs whose projections disagree."""
    method = cfg["merge_method"]
    if method not in LINEAR:
        raise SystemExit(f"{method}: not a linear-family merge; a weighted sum of vectors cannot reproduce it")
    params = cfg.get("parameters") or {}
    lam = float(params.get("lambda", 1.0))
    normalize = params.get("normalize", NORMALIZE_DEFAULT[method])
    out, mixed = {}, []
    for m in cfg["models"]:
        if m["model"] == cfg.get("base_model"):
            continue            # the base contributes no delta and is not in the normalizing sum
        weights = []
        for L in range(N_LAYERS):
            vals = [evaluate(f"{PREFIX}{L}.{tn}", (m.get("parameters") or {}).get("weight", 1.0), L / (N_LAYERS - 1))
                    for tn in TENSORS]
            if len(set(round(v, 9) for v in vals)) > 1:
                mixed.append((m["model"], L))
            weights.append(vals[-1])
        out[m["model"]] = weights
    if normalize:
        for L in range(N_LAYERS):
            s = sum(out[k][L] for k in out)
            for k in out:
                out[k][L] = out[k][L] / s if s else 0.0
    return {k: [lam * w for w in v] for k, v in out.items()}, mixed


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("recipe", help="the merge's mergekit YAML")
    ap.add_argument("out", help="output .gguf")
    ap.add_argument("--parent", action="append", required=True,
                    help="MODEL=path.gguf, MODEL exactly as the recipe names it")
    ap.add_argument("--name", help="general.name metadata")
    a = ap.parse_args(argv)
    cfg = yaml.safe_load(open(a.recipe))
    base = str(cfg.get("base_model", "")).rstrip("/").split("/")[-1].lower()
    if base != STOCK:
        raise SystemExit(f"base_model {cfg.get('base_model')!r}: parent vectors are deltas from stock gemma-4-31B-it")
    if cfg["merge_method"] in SIGN_ELECTION:
        print(f"warning: {cfg['merge_method']} elects signs per element; the weighted sum ignores that (untested)")
    w, mixed = layer_weights(cfg)
    if mixed:
        raise SystemExit(f"projections weighted differently within layers {sorted({L for _, L in mixed})}")
    files = dict(p.split("=", 1) for p in a.parent)
    if set(files) != set(w):
        raise SystemExit(f"recipe models {sorted(w)} vs --parent {sorted(files)}")
    V = np.zeros_like(read_gguf(next(iter(files.values()))))
    for m, f in files.items():
        V += np.asarray(w[m], np.float32)[:, None] * read_gguf(f)
    write_gguf(a.out, V, name=a.name)
    for m in w:
        print(f"{m:50s} weight at L1 {w[m][1]:.3f}  L30 {w[m][30]:.3f}  L59 {w[m][59]:.3f}")
    print(f"wrote {a.out}: {cfg['merge_method']}, {len(w)} parents; load it at 1.0")


if __name__ == "__main__":
    main()