Skip to main content

harness_lock_core/
localize.rs

1//! `hlock localize`: the best all-local equivalent of a lock (SPEC §7.8.7).
2//!
3//! Takes any lock (pinned, resolved, tuned) and replaces what isn't local:
4//! a proprietary harness, or one that can't use a local model, becomes a
5//! local-capable one; LLM roles and the decision model on API providers move
6//! to a local runtime, picked from the runtime's model catalog and the weights
7//! already on disk, filtered to what fits this machine's memory. Portable
8//! components carry over. With eval packs it evaluates the candidates through
9//! the resolver; otherwise it ranks them and marks the result unevaluated.
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use serde_json::{json, Map, Value};
16
17use crate::capture::{drop_nulls, locate_model, render_config, VendorFile, VENDOR_DIR};
18use crate::error::{Error, Result};
19use crate::launch::mcp_entry;
20use crate::lockfile::{ConfigFile, Evidence, Harness, Lockfile, Runtime, LLM};
21use crate::manifest::{HarnessCandidate, LLMCandidate, Manifest};
22use crate::profiles::{probe_harness, probe_runtime, run_capture, CatalogModel, Ctx, HarnessProfile, Registry, RuntimeProfile};
23use crate::sync::set_pointer;
24
25/// Local-capable harnesses `localize` tries, most preferred first.
26pub const DEFAULT_HARNESSES: &[&str] = &["opencode", "pi", "codex", "hermes", "goose"];
27/// Local runtimes, most preferred first.
28pub const DEFAULT_RUNTIMES: &[&str] = &["ollama", "lmstudio", "llama-cpp", "mlx-lm", "vllm", "sglang", "localai"];
29
30#[derive(Debug, Clone, Default)]
31pub struct LocalizeOptions {
32    pub harness: Option<String>,
33    pub runtime: Option<String>,
34    /// Exact model ids to consider (default: the runtime's catalog and weights on disk).
35    pub models: Vec<String>,
36    /// Memory budget for model weights, overriding the machine probe.
37    pub memory_gb: Option<f64>,
38    /// A manifest whose eval packs evaluate the candidates.
39    pub manifest: Option<Manifest>,
40    /// Drop remote MCP servers instead of flagging them.
41    pub strict: bool,
42    /// Keep self-hosted (network) runtimes instead of replacing them.
43    pub allow_network: bool,
44    /// Where trial artifacts go.
45    pub workdir: PathBuf,
46}
47
48#[derive(Debug, Clone)]
49pub struct LocalizeOutcome {
50    pub lock: Lockfile,
51    pub vendor: Vec<VendorFile>,
52    pub report: Value,
53}
54
55/// Total memory, the share usable for model weights, and how it was measured.
56pub fn machine_memory(ctx: &Ctx) -> Option<(f64, f64, String)> {
57    let t = Duration::from_secs(5);
58    let gb = |b: f64| (b / 1073741824.0 * 10.0).round() / 10.0; // GiB, as memory is sold
59    if let Some(smi) = ctx.which("nvidia-smi") {
60        let args = ["--query-gpu=memory.total", "--format=csv,noheader,nounits"].map(String::from);
61        if let Some(out) = run_capture(&smi, &args, ctx, t) {
62            let mib: f64 = out.lines().filter_map(|l| l.trim().parse::<f64>().ok()).sum();
63            if mib > 0.0 {
64                return Some((gb(mib * 1048576.0), gb(mib * 1048576.0 * 0.9), "NVIDIA GPU memory".into()));
65            }
66        }
67    }
68    if cfg!(target_os = "macos") {
69        let sysctl = ctx.which("sysctl").unwrap_or_else(|| PathBuf::from("/usr/sbin/sysctl"));
70        let bytes: f64 = run_capture(&sysctl, &["-n".into(), "hw.memsize".into()], ctx, t)?.lines().next()?.trim().parse().ok()?;
71        let unified = cfg!(target_arch = "aarch64");
72        let share = if unified { 0.75 } else { 0.5 };
73        return Some((gb(bytes), gb(bytes * share), if unified { "unified memory (75%)" } else { "RAM (50%)" }.into()));
74    }
75    let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
76    let kb: f64 = meminfo.lines().find(|l| l.starts_with("MemTotal:"))?.split_whitespace().nth(1)?.parse().ok()?;
77    Some((gb(kb * 1024.0), gb(kb * 1024.0 * 0.5), "RAM (50%)".into()))
78}
79
80/// Parameter count (billions) from a model id like `qwen3:8b` or `Qwen3-30B-A3B`.
81fn params_from_id(id: &str) -> Option<f64> {
82    let lower = id.to_ascii_lowercase();
83    let b = lower.as_bytes();
84    let mut best = None;
85    for (i, &c) in b.iter().enumerate() {
86        if c != b'b' || i == 0 || !b[i - 1].is_ascii_digit() {
87            continue;
88        }
89        if b.get(i + 1).is_some_and(|x| x.is_ascii_alphanumeric()) {
90            continue;
91        }
92        let mut j = i;
93        while j > 0 && (b[j - 1].is_ascii_digit() || b[j - 1] == b'.') {
94            j -= 1;
95        }
96        if j > 0 && b[j - 1].is_ascii_alphabetic() && b[j - 1] != b'-' && b[j - 1] != b':' && b[j - 1] != b'_' {
97            // e.g. "a3b" (active params) — skip
98            if b[j - 1] == b'a' {
99                continue;
100            }
101        }
102        if let Ok(v) = lower[j..i].parse::<f64>() {
103            best = Some(best.map_or(v, |x: f64| x.max(v)));
104        }
105    }
106    best
107}
108
109fn tier_for(params: Option<f64>) -> u8 {
110    match params {
111        Some(p) if p >= 25.0 => 3,
112        Some(p) if p >= 12.0 => 2,
113        _ => 1,
114    }
115}
116
117/// A model candidate with what's known about it here.
118#[derive(Debug, Clone, serde::Serialize)]
119#[serde(rename_all = "kebab-case")]
120struct Candidate {
121    id: String,
122    tier: u8,
123    params_b: Option<f64>,
124    min_memory_gb: Option<f64>,
125    present: bool,
126    tools: bool,
127    roles: Vec<String>,
128    from: &'static str,
129}
130
131/// Models already in an Ollama store, with their sizes.
132fn ollama_models_on_disk(rt: &RuntimeProfile, ctx: &Ctx) -> Vec<(String, u64)> {
133    let Some(dir) = rt.models_dir(ctx) else { return vec![] };
134    let lib = dir.join("manifests/registry.ollama.ai/library");
135    let mut out = Vec::new();
136    for name in std::fs::read_dir(&lib).into_iter().flatten().flatten() {
137        for tag in std::fs::read_dir(name.path()).into_iter().flatten().flatten() {
138            let id = format!("{}:{}", name.file_name().to_string_lossy(), tag.file_name().to_string_lossy());
139            let m = locate_model(rt, &id, ctx);
140            out.push((id, m.size_bytes.unwrap_or(0)));
141        }
142    }
143    out.sort();
144    out
145}
146
147fn candidates(rt: &RuntimeProfile, opts: &LocalizeOptions, usable_gb: Option<f64>, ctx: &Ctx) -> Vec<Candidate> {
148    let fits = |need: Option<f64>| match (need, usable_gb) {
149        (Some(n), Some(u)) => n <= u,
150        _ => true,
151    };
152    let mut out: Vec<Candidate> = Vec::new();
153    if !opts.models.is_empty() {
154        for id in &opts.models {
155            let cat = rt.catalog.iter().find(|c| &c.id == id);
156            let params = cat.and_then(|c| c.params_b).or_else(|| params_from_id(id));
157            out.push(Candidate {
158                id: id.clone(),
159                tier: cat.map(|c| c.tier).unwrap_or_else(|| tier_for(params)),
160                params_b: params,
161                min_memory_gb: cat.and_then(|c| c.min_memory_gb),
162                present: locate_model(rt, id, ctx).present,
163                tools: cat.map(|c| c.tools).unwrap_or(true),
164                roles: cat.map(|c| c.roles.clone()).unwrap_or_default(),
165                from: "--models",
166            });
167        }
168        return out;
169    }
170    for c in rt.catalog.iter().filter(|c| fits(c.min_memory_gb)) {
171        let CatalogModel { id, params_b, min_memory_gb, tier, tools, roles } = c.clone();
172        let present = locate_model(rt, &id, ctx).present;
173        out.push(Candidate { id, tier, params_b, min_memory_gb, present, tools, roles, from: "catalog" });
174    }
175    if rt.models.layout == "ollama" {
176        for (id, size) in ollama_models_on_disk(rt, ctx) {
177            if out.iter().any(|c| c.id == id) {
178                continue;
179            }
180            let need = (size > 0).then(|| size as f64 * 1.25 / 1e9);
181            if !fits(need) {
182                continue;
183            }
184            let params = params_from_id(&id);
185            out.push(Candidate { id, tier: tier_for(params), params_b: params, min_memory_gb: need, present: true, tools: true, roles: vec![], from: "on disk" });
186        }
187    }
188    out
189}
190
191/// Best first: higher tier, already on disk, larger.
192fn rank_default(c: &[Candidate]) -> Vec<Candidate> {
193    let mut v: Vec<Candidate> = c.iter().filter(|x| x.tools && (x.roles.is_empty() || x.roles.iter().any(|r| r == "default"))).cloned().collect();
194    if v.is_empty() {
195        v = c.to_vec();
196    }
197    v.sort_by(|a, b| {
198        b.tier.cmp(&a.tier).then(b.present.cmp(&a.present)).then(b.params_b.unwrap_or(0.0).partial_cmp(&a.params_b.unwrap_or(0.0)).unwrap_or(std::cmp::Ordering::Equal))
199    });
200    v
201}
202
203/// Smallest capable model for the small and decision roles.
204fn pick_small(c: &[Candidate]) -> Option<Candidate> {
205    let mut v: Vec<Candidate> = c.iter().filter(|x| x.tools && x.tier == 1).cloned().collect();
206    v.sort_by(|a, b| {
207        let ra = a.roles.iter().any(|r| r == "small" || r == "decision");
208        let rb = b.roles.iter().any(|r| r == "small" || r == "decision");
209        rb.cmp(&ra).then(b.present.cmp(&a.present)).then(a.params_b.unwrap_or(99.0).partial_cmp(&b.params_b.unwrap_or(99.0)).unwrap_or(std::cmp::Ordering::Equal))
210    });
211    v.into_iter().next()
212}
213
214fn is_local_harness(p: &HarnessProfile) -> bool {
215    p.kind == "custom" || (p.open_source == Some(true) && p.local_models == Some(true))
216}
217
218pub(crate) fn subst_value(v: &Value, vars: &[(&str, &str)]) -> Value {
219    let s = |x: &str| vars.iter().fold(x.to_string(), |acc, (k, val)| acc.replace(&format!("{{{k}}}"), val));
220    match v {
221        Value::String(x) => Value::String(s(x)),
222        Value::Array(a) => Value::Array(a.iter().map(|x| subst_value(x, vars)).collect()),
223        Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (s(k), subst_value(x, vars))).collect()),
224        other => other.clone(),
225    }
226}
227
228/// A local runtime and the model to serve on it.
229#[derive(Debug, Clone, serde::Serialize)]
230pub struct LocalChoice {
231    pub runtime: String,
232    pub runtime_name: String,
233    pub installed: bool,
234    pub base_url: String,
235    pub host: String,
236    pub model: String,
237    /// The weights are already on disk.
238    pub present: bool,
239    /// The command that downloads the weights, when they aren't.
240    pub pull: Option<String>,
241    pub usable_gb: Option<f64>,
242}
243
244/// Pick a local runtime (`runtime`, else the most preferred installed one, else Ollama)
245/// and the best model for it that fits this machine (`model` to name one).
246pub fn choose_local(reg: &Registry, ctx: &Ctx, runtime: Option<&str>, model: Option<&str>, memory_gb: Option<f64>) -> Result<LocalChoice> {
247    let rt = match runtime {
248        Some(r) => reg.runtime(r).ok_or_else(|| Error::Profile(format!("unknown runtime {r:?} (see hlock runtime list)")))?,
249        None => DEFAULT_RUNTIMES
250            .iter()
251            .filter_map(|r| reg.runtime(r))
252            .find(|r| probe_runtime(r, ctx).installed)
253            .or_else(|| reg.runtime("ollama"))
254            .ok_or_else(|| Error::Profile("no runtime profiles".into()))?,
255    };
256    let usable = memory_gb.or_else(|| machine_memory(ctx).map(|m| m.1));
257    let opts = LocalizeOptions { models: model.map(|m| vec![m.to_string()]).unwrap_or_default(), ..Default::default() };
258    let ranked = rank_default(&candidates(rt, &opts, usable, ctx));
259    let top = ranked.first().ok_or_else(|| {
260        Error::Profile(format!("no {} model fits {} GB; pass --model", rt.name, usable.map(|u| u.to_string()).unwrap_or_else(|| "?".into())))
261    })?;
262    // a catalog model (known to call tools) already on disk, else the smallest of the best tier (the least to download)
263    let best = ranked.iter().find(|c| c.present && c.tier >= 2 && c.from == "catalog").unwrap_or_else(|| {
264        ranked
265            .iter()
266            .filter(|c| c.tier == top.tier)
267            .min_by(|a, b| a.min_memory_gb.or(a.params_b).unwrap_or(f64::MAX).partial_cmp(&b.min_memory_gb.or(b.params_b).unwrap_or(f64::MAX)).unwrap_or(std::cmp::Ordering::Equal))
268            .unwrap_or(top)
269    });
270    let port = rt.ports.first().copied().unwrap_or(8080);
271    Ok(LocalChoice {
272        runtime: rt.id.clone(),
273        runtime_name: rt.name.clone(),
274        installed: probe_runtime(rt, ctx).installed,
275        base_url: format!("http://localhost:{port}/v1"),
276        host: format!("http://localhost:{port}"),
277        model: best.id.clone(),
278        present: best.present,
279        pull: (!best.present).then(|| rt.pull.as_ref().map(|p| p.replace("{model}", &best.id))).flatten(),
280        usable_gb: usable,
281    })
282}
283
284/// Produce a localized lock. Nothing is written; `vendor` holds files to place next to `out`.
285pub fn localize(source: &Lockfile, source_path: &Path, reg: &Registry, ctx: &Ctx, opts: &LocalizeOptions) -> Result<LocalizeOutcome> {
286    let mut notes: Vec<String> = Vec::new();
287    let mut replaced: Vec<String> = Vec::new();
288    let mem = machine_memory(ctx);
289    let usable = opts.memory_gb.or(mem.as_ref().map(|m| m.1));
290
291    // ---- harness ----
292    let src_profile = reg.harness(&source.harness.name);
293    let (target, why) = if let Some(h) = &opts.harness {
294        let p = reg.require_harness(h)?;
295        if !(p.kind == "custom" || p.local_models == Some(true)) {
296            return Err(Error::Profile(format!("{} can't use a local model endpoint", p.name)));
297        }
298        if p.open_source == Some(false) {
299            notes.push(format!("{} is proprietary; it runs locally but isn't open source", p.name));
300        }
301        (p, "--harness".to_string())
302    } else if let Some(p) = src_profile.filter(|p| is_local_harness(p)) {
303        (p, "already local-capable".to_string())
304    } else {
305        let options: Vec<&HarnessProfile> = DEFAULT_HARNESSES.iter().filter_map(|h| reg.harness(h)).filter(|p| is_local_harness(p)).collect();
306        let installed = options.iter().find(|p| probe_harness(p, ctx).installed).copied();
307        match installed {
308            Some(p) => (p, "the most preferred local-capable harness installed".to_string()),
309            None => {
310                let p = options.first().copied().ok_or_else(|| Error::Profile("no local-capable harness profile".into()))?;
311                notes.push(format!("{} isn't installed; `hlock sync --install` installs it", p.name));
312                (p, "the default local-capable harness".to_string())
313            }
314        }
315    };
316    let harness_changed = src_profile.map(|p| p.id != target.id).unwrap_or(true);
317    if harness_changed {
318        replaced.push(format!("harness {} → {}", source.harness.name, target.id));
319    }
320
321    // ---- runtime ----
322    let runtime = match &opts.runtime {
323        Some(r) => reg.runtime(r).ok_or_else(|| Error::Profile(format!("unknown runtime {r:?} (see hlock runtime list)")))?,
324        None => DEFAULT_RUNTIMES
325            .iter()
326            .filter_map(|r| reg.runtime(r))
327            .find(|r| probe_runtime(r, ctx).installed)
328            .or_else(|| reg.runtime("ollama"))
329            .ok_or_else(|| Error::Profile("no runtime profiles".into()))?,
330    };
331    let rprobe = probe_runtime(runtime, ctx);
332    if !rprobe.installed {
333        notes.push(format!("{} isn't installed; `hlock sync` will show how to install it", runtime.name));
334    }
335    let port = runtime.ports.first().copied().unwrap_or(8080);
336    let base_url = format!("http://localhost:{port}/v1");
337
338    // ---- models ----
339    let cands = candidates(runtime, opts, usable, ctx);
340    let ranked = rank_default(&cands);
341    if ranked.is_empty() {
342        return Err(Error::Profile(format!(
343            "no {} model fits {} GB; pass --models or --memory-gb",
344            runtime.name,
345            usable.map(|u| u.to_string()).unwrap_or_else(|| "?".into())
346        )));
347    }
348    let small = pick_small(&cands);
349
350    // which source roles need replacing
351    let is_local_llm = |l: &LLM| -> bool {
352        match l.runtime.as_deref().and_then(|r| source.runtime.iter().find(|x| x.name == r)) {
353            Some(rt) => rt.location == "local" || (opts.allow_network && rt.location == "network"),
354            None => l.provider == "reference",
355        }
356    };
357
358    // ---- evaluate or rank ----
359    let mut default_pick = ranked[0].clone();
360    let mut evaluated = false;
361    let mut evidence = Evidence::default();
362    let mut eval_harness = target.id.clone();
363    let tasks = opts.manifest.as_ref().filter(|m| !m.eval_packs().is_empty());
364    if let Some(m) = tasks {
365        let mut man = m.clone();
366        man.target.clear();
367        let top: Vec<String> = ranked.iter().take(4).map(|c| format!("{}:{}", runtime.id, c.id)).collect();
368        man.candidates.llm = BTreeMap::from([("planner".to_string(), LLMCandidate { any_of: top })]);
369        man.candidates.harness = vec![HarnessCandidate { name: target.id.clone(), ..Default::default() }];
370        let mut resolver = crate::Resolver::new(man, &opts.workdir);
371        let lock = resolver.resolve_lock()?;
372        if let Some(win) = lock.llm.first() {
373            if let Some(c) = ranked.iter().find(|c| c.id == win.model) {
374                default_pick = c.clone();
375            }
376        }
377        evaluated = true;
378        evidence = lock.evidence.clone();
379        eval_harness = lock.harness.adapter.clone().unwrap_or_else(|| target.id.clone());
380        if eval_harness != target.id {
381            notes.push(format!("trials ran on the {eval_harness} adapter, so the evidence is a stand-in"));
382        }
383    } else {
384        notes.push("no eval packs to evaluate with; candidates were ranked by fit, tier, and what's on disk".into());
385    }
386
387    // ---- build the lock ----
388    let mut lock = source.clone();
389    lock.kind = Some("localized".into());
390    lock.base_lock = Some(source_path.to_string_lossy().into_owned());
391    lock.stack.insert("selection".into(), json!("localize"));
392    lock.stack.insert("harness".into(), json!(target.id));
393    lock.provenance.backend = "localize".into();
394    let hp = probe_harness(target, ctx);
395    if harness_changed {
396        lock.harness = Harness { name: target.id.clone(), version: hp.version.clone(), source: hp.source.clone(), pattern: source.harness.pattern.clone(), ..Default::default() };
397    }
398
399    let mut used_models: BTreeSet<String> = BTreeSet::new();
400    let mut new_llms = Vec::new();
401    for l in &source.llm {
402        if is_local_llm(l) {
403            new_llms.push(l.clone());
404            continue;
405        }
406        let pick = if l.role == "small" { small.clone().unwrap_or_else(|| default_pick.clone()) } else { default_pick.clone() };
407        replaced.push(format!("llm {}: {}:{} → {}:{}", l.role, l.provider, l.model, runtime.id, pick.id));
408        used_models.insert(pick.id.clone());
409        new_llms.push(LLM {
410            role: l.role.clone(),
411            provider: runtime.id.clone(),
412            model: pick.id.clone(),
413            resolved_from: Some(format!("localize:{}:{}", l.provider, l.model)),
414            params: l.params.clone(),
415            endpoint: Some(base_url.clone()),
416            runtime: Some(runtime.id.clone()),
417            ..Default::default()
418        });
419    }
420    if new_llms.is_empty() {
421        used_models.insert(default_pick.id.clone());
422        new_llms.push(LLM { role: "default".into(), provider: runtime.id.clone(), model: default_pick.id.clone(), endpoint: Some(base_url.clone()), runtime: Some(runtime.id.clone()), ..Default::default() });
423    }
424    lock.llm = new_llms;
425
426    // decision model on an API provider → the small local model
427    if source.decision.kind.starts_with("llm") {
428        let api = source.decision.model.as_deref().map(|m| !m.starts_with(&runtime.id)).unwrap_or(true);
429        if api {
430            if let Some(s) = &small {
431                replaced.push(format!("decision {} → llm:{}:{}", source.decision.model.as_deref().unwrap_or(&source.decision.kind), runtime.id, s.id));
432                lock.decision.kind = "llm".into();
433                lock.decision.model = Some(format!("{}:{}", runtime.id, s.id));
434                used_models.insert(s.id.clone());
435            }
436        }
437    }
438
439    // runtimes: keep local ones still in use, add the chosen one
440    let still_used: BTreeSet<String> = lock.llm.iter().filter_map(|l| l.runtime.clone()).collect();
441    lock.runtime.retain(|r| still_used.contains(&r.name) && r.name != runtime.id);
442    if !used_models.is_empty() {
443        lock.runtime.push(Runtime {
444            name: runtime.id.clone(),
445            location: "local".into(),
446            version: rprobe.version.clone(),
447            source: rprobe.source.clone(),
448            endpoint: Some(base_url.clone()),
449            model: used_models.iter().map(|m| locate_model(runtime, m, ctx)).collect(),
450        });
451    }
452
453    // MCP: remote servers are network dependencies
454    let remote: Vec<String> = lock.tool.iter().filter(|t| t.kind == "mcp" && t.url.is_some()).map(|t| t.name.clone()).collect();
455    if !remote.is_empty() {
456        if opts.strict {
457            lock.tool.retain(|t| !remote.contains(&t.name));
458            replaced.push(format!("dropped remote MCP servers: {}", remote.join(", ")));
459        } else {
460            notes.push(format!("remote MCP servers still need the network: {} (--strict drops them)", remote.join(", ")));
461        }
462    }
463
464    // harness-specific parts that don't carry over
465    let mut vendor = Vec::new();
466    if harness_changed {
467        let plugins: Vec<String> = lock.native.iter().filter(|n| n.kind != "package").map(|n| n.name.clone()).collect();
468        if !plugins.is_empty() {
469            let shown: Vec<&str> = plugins.iter().take(4).map(String::as_str).collect();
470            let more = if plugins.len() > 4 { format!(" and {} more", plugins.len() - 4) } else { String::new() };
471            notes.push(format!("{}'s {} plugins don't carry over: {}{more}", source.harness.name, plugins.len(), shown.join(", ")));
472        }
473        lock.native.retain(|n| n.kind == "package");
474        lock.config.clear();
475        lock.skill.retain(|s| !matches!(s.origin.as_str(), "harness-builtin" | "bundled"));
476        if let Some(tpl) = target.instructions.iter().find(|i| i.starts_with("{root}/")) {
477            for i in lock.instructions.iter_mut().filter(|i| i.scope == "user") {
478                i.name = tpl.clone();
479            }
480        }
481    }
482
483    // configure the local endpoint (and, for a new harness, its MCP servers)
484    let default_model = lock.llm.iter().find(|l| l.role == "default").or_else(|| lock.llm.first()).map(|l| l.model.clone()).unwrap_or_default();
485    let host = format!("http://localhost:{port}");
486    let vars = [("provider", runtime.id.as_str()), ("model", default_model.as_str()), ("base_url", base_url.as_str()), ("host", host.as_str())];
487    let mut per_file: BTreeMap<String, Map<String, Value>> = BTreeMap::new();
488    if target.kind == "cli" && !target.local_provider.is_empty() {
489        for lp in &target.local_provider {
490            let entry = per_file.entry(lp.file.clone()).or_default();
491            for (ptr, v) in &lp.set {
492                let ptr = subst_value(&json!(ptr), &vars).as_str().unwrap_or_default().to_string();
493                entry.insert(ptr, subst_value(v, &vars));
494            }
495        }
496    } else if target.kind == "cli" {
497        notes.push(format!("the {} profile doesn't say how to configure a local model; point it at {base_url} yourself", target.name));
498    }
499    if harness_changed {
500        if let Some((file, ptr)) = target.mcp.write.as_deref().and_then(|w| w.split_once('#')) {
501            let shape = target.mcp.shape.as_deref().unwrap_or("standard");
502            let servers: Map<String, Value> = lock.tool.iter().filter(|t| t.kind == "mcp").map(|t| (t.name.clone(), mcp_entry(t, shape))).collect();
503            if !servers.is_empty() {
504                per_file.entry(file.to_string()).or_default().insert(ptr.to_string(), Value::Object(servers));
505            }
506        }
507    }
508    let vroot = format!("{VENDOR_DIR}/{}/localize", target.id);
509    for (file, sets) in per_file {
510        let Some(spec) = target.files.get(&file) else {
511            notes.push(format!("the {} profile has no file {file:?}", target.id));
512            continue;
513        };
514        let body = Value::Object(sets.clone());
515        let rel = format!("{vroot}/{file}.pointers.json");
516        vendor.push(VendorFile { rel: rel.clone(), bytes: render_config(&body, "json").map_err(Error::Profile)?.into_bytes(), executable: false });
517        let mut check = json!({});
518        for (p, v) in &sets {
519            set_pointer(&mut check, p, v.clone());
520        }
521        lock.config.push(ConfigFile {
522            id: file.clone(),
523            path: spec.path.clone(),
524            format: spec.format.clone(),
525            restore: "pointers".into(),
526            pointers: sets.keys().cloned().collect(),
527            digest: Some(crate::hash::content_hash(&check)),
528            vendored: Some(rel),
529        });
530    }
531
532    lock.evidence = evidence;
533    let report = json!({
534        "source": source_path,
535        "evaluated": evaluated,
536        "evaluated-with": evaluated.then_some(eval_harness),
537        "memory": mem.as_ref().map(|(total, use_, how)| json!({"total-gb": total, "usable-gb": opts.memory_gb.unwrap_or(*use_), "how": how})),
538        "harness": {"from": source.harness.name, "to": target.id, "why": why, "installed": hp.installed},
539        "runtime": {"name": runtime.id, "installed": rprobe.installed, "version": rprobe.version, "endpoint": base_url},
540        "models": {
541            "default": default_pick.id,
542            "small": small.as_ref().map(|s| s.id.clone()),
543            "candidates": ranked.iter().chain(cands.iter().filter(|c| !ranked.iter().any(|r| r.id == c.id))).collect::<Vec<_>>(),
544        },
545        "replaced": replaced,
546        "notes": notes,
547        "baseline": {
548            "success-rate": source.evidence.success_rate.get("mean"),
549            "cost-usd-per-task": source.evidence.cost_usd_per_task.get("mean"),
550            "trials": source.evidence.trials,
551        },
552    });
553    let report = drop_nulls(&report); // TOML has no null
554    lock.localize = Some(report.clone());
555    Ok(LocalizeOutcome { lock, vendor, report })
556}
557
558/// Localize and write the lock to `out`, with its vendored files beside it.
559pub fn localize_to(source_path: &Path, out: &Path, reg: &Registry, ctx: &Ctx, opts: &LocalizeOptions) -> Result<LocalizeOutcome> {
560    let source = Lockfile::load(source_path)?;
561    let mut o = localize(&source, source_path, reg, ctx, opts)?;
562    // record the source relative to the new lock when they share a directory
563    if source_path.parent() == out.parent() {
564        if let Some(name) = source_path.file_name() {
565            o.lock.base_lock = Some(name.to_string_lossy().into_owned());
566        }
567    }
568    let base = out.parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf).unwrap_or_else(|| ctx.cwd.clone());
569    let _ = std::fs::remove_dir_all(base.join(VENDOR_DIR).join(&o.lock.harness.name).join("localize"));
570    for f in &o.vendor {
571        let dest = base.join(&f.rel);
572        if let Some(p) = dest.parent() {
573            std::fs::create_dir_all(p)?;
574        }
575        std::fs::write(&dest, &f.bytes)?;
576    }
577    o.lock.save(out)?;
578    Ok(o)
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn params_parsing() {
587        assert_eq!(params_from_id("qwen3:8b"), Some(8.0));
588        assert_eq!(params_from_id("qwen3-coder:30b"), Some(30.0));
589        assert_eq!(params_from_id("Qwen3-30B-A3B-Instruct"), Some(30.0));
590        assert_eq!(params_from_id("qwen3:1.7b"), Some(1.7));
591        assert_eq!(params_from_id("llama3.2"), None);
592        assert_eq!(tier_for(Some(30.0)), 3);
593        assert_eq!(tier_for(Some(20.0)), 2);
594        assert_eq!(tier_for(None), 1);
595    }
596
597    #[test]
598    fn templates() {
599        let v = json!({"/provider/{provider}": {"options": {"baseURL": "{base_url}"}, "models": {"{model}": {}}}});
600        let out = subst_value(&v, &[("provider", "ollama"), ("model", "qwen3:8b"), ("base_url", "http://localhost:11434/v1")]);
601        assert_eq!(out, json!({"/provider/ollama": {"options": {"baseURL": "http://localhost:11434/v1"}, "models": {"qwen3:8b": {}}}}));
602    }
603
604    #[test]
605    fn ranking() {
606        let c = |id: &str, tier: u8, present: bool, params: f64| Candidate { id: id.into(), tier, params_b: Some(params), min_memory_gb: None, present, tools: true, roles: vec![], from: "catalog" };
607        let cands = vec![c("a", 2, false, 20.0), c("b", 3, false, 30.0), c("c", 3, true, 24.0), c("tiny", 1, false, 1.7), c("small", 1, true, 4.0)];
608        assert_eq!(rank_default(&cands)[0].id, "c"); // top tier, already on disk
609        assert_eq!(pick_small(&cands).unwrap().id, "small"); // on disk beats smaller
610        let r = Registry::builtin();
611        let ollama = r.runtime("ollama").unwrap();
612        assert!(ollama.catalog.iter().any(|m| m.tier == 3) && ollama.catalog.iter().any(|m| m.roles.iter().any(|x| x == "decision")));
613        assert!(r.harness("opencode").unwrap().local_provider.len() == 1 && r.harness("pi").unwrap().local_provider.len() == 2);
614        assert!(is_local_harness(r.harness("opencode").unwrap()) && !is_local_harness(r.harness("claude-code").unwrap()));
615    }
616}