Upgrade demanded local models during reconciliation

Validation
* Validation tier: Tier 3 - opt-in runtime reconciliation can replace a lower-demand local model with a locally present, fresh active-demand target; config admission, planner policy, runtime unload/load sequencing, API target signals, and docs are refreshed on current main after PR #753 landed.
* git fetch --no-tags origin main:refs/remotes/origin/main: PASS, origin/main at c0f8990bb1.
* git rebase origin/main: PASS after resolving one test-module conflict in crates/mesh-llm-host-runtime/src/runtime/mod.rs by preserving both the newly landed mDNS relay-policy tests and this PR's model-target replacement sequencing test.
* git diff --check origin/main...HEAD: PASS, no output.
* git diff --check: PASS, no output.
* git diff --cached --check: PASS, no output.
* cargo fmt --all -- --check: PASS.
* cargo test -p mesh-llm-config --lib -- --test-threads=1: PASS, 10 passed.
* LLAMA_STAGE_BUILD_DIR=/Users/Funtland/Downloads/mesh-llm/.deps/llama-build/build-stage-abi-metal cargo test -p mesh-llm-host-runtime model_target_reconciliation --lib -- --test-threads=1: PASS, 22 passed.
* LLAMA_STAGE_BUILD_DIR=/Users/Funtland/Downloads/mesh-llm/.deps/llama-build/build-stage-abi-metal cargo test -p mesh-llm-host-runtime api::model_targets --lib -- --test-threads=1: PASS, 3 passed.
* LLAMA_STAGE_BUILD_DIR=/Users/Funtland/Downloads/mesh-llm/.deps/llama-build/build-stage-abi-metal cargo test -p mesh-llm-host-runtime mdns_discovery --lib -- --test-threads=1: PASS, 3 passed.
* LLAMA_STAGE_BUILD_DIR=/Users/Funtland/Downloads/mesh-llm/.deps/llama-build/build-stage-abi-metal cargo check -p mesh-llm: PASS.
* LLAMA_STAGE_BUILD_DIR=/Users/Funtland/Downloads/mesh-llm/.deps/llama-build/build-stage-abi-metal /opt/homebrew/bin/cargo-clippy clippy -p mesh-llm-config -p mesh-llm-host-runtime --all-targets -- -D warnings: PASS.
* Ledger: not applicable - not required for selected validation tier/change family.
* Version: not applicable - no release/version sync required for this non-release opt-in runtime reconciliation change.
* Not run: live multi-node demand/reconciliation smoke - no local multi-node runtime endpoint and local GGUF set were available; config, model-target signal, planner, runtime-control, and conflict-adjacent mDNS tests cover the changed branches.
* Not run: full workspace suite locally - mandatory PR CI is the final full-suite proof for the pushed SHA.

Rollback
* git revert HEAD
This commit is contained in:
IvGolovach 2026-05-29 11:49:37 -07:00
parent c0f8990bb1
commit d48861a2ea
7 changed files with 411 additions and 39 deletions

View file

@ -51,7 +51,7 @@ MTP work with llama.cpp ongoing, but should be part of this to accelerate infere
## Demand-based rebalancing
Partially done. Unified demand map via gossip, standby nodes promote to serve. Next: large-VRAM hosts auto-upgrade models when demand warrants it.
Partially done. Unified demand map via gossip, standby nodes promote to serve, and large-VRAM hosts can opt into fresh active-demand upgrades for local artifacts. Next: download-backed upgrades, split-aware upgrades, and replica-count balancing.
## Blackboard ✅

View file

@ -201,11 +201,17 @@ version = 1
[runtime]
reconcile_model_targets = true
reconcile_model_target_demand_upgrades = true
model_target_demand_upgrade_min_requests = 4
model_target_demand_upgrade_max_age_secs = 900
"#,
)
.unwrap();
assert!(config.runtime.reconcile_model_targets);
assert!(config.runtime.reconcile_model_target_demand_upgrades);
assert_eq!(config.runtime.model_target_demand_upgrade_min_requests, 4);
assert_eq!(config.runtime.model_target_demand_upgrade_max_age_secs, 900);
}
#[test]

View file

@ -44,10 +44,40 @@ pub struct GpuConfig {
pub parallel: Option<usize>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub const DEFAULT_MODEL_TARGET_DEMAND_UPGRADE_MIN_REQUESTS: u64 = 2;
pub const DEFAULT_MODEL_TARGET_DEMAND_UPGRADE_MAX_AGE_SECS: u64 = 60 * 60;
fn default_model_target_demand_upgrade_min_requests() -> u64 {
DEFAULT_MODEL_TARGET_DEMAND_UPGRADE_MIN_REQUESTS
}
fn default_model_target_demand_upgrade_max_age_secs() -> u64 {
DEFAULT_MODEL_TARGET_DEMAND_UPGRADE_MAX_AGE_SECS
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct RuntimeConfig {
#[serde(default)]
pub reconcile_model_targets: bool,
#[serde(default)]
pub reconcile_model_target_demand_upgrades: bool,
#[serde(default = "default_model_target_demand_upgrade_min_requests")]
pub model_target_demand_upgrade_min_requests: u64,
#[serde(default = "default_model_target_demand_upgrade_max_age_secs")]
pub model_target_demand_upgrade_max_age_secs: u64,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
reconcile_model_targets: false,
reconcile_model_target_demand_upgrades: false,
model_target_demand_upgrade_min_requests:
DEFAULT_MODEL_TARGET_DEMAND_UPGRADE_MIN_REQUESTS,
model_target_demand_upgrade_max_age_secs:
DEFAULT_MODEL_TARGET_DEMAND_UPGRADE_MAX_AGE_SECS,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]

View file

@ -31,9 +31,10 @@ use self::local::{
stop_split_generation_cleanup, withdraw_advertised_model,
};
use self::model_target_reconciliation::{
ModelTargetReconciliationCandidate, ModelTargetReconciliationCapacityState,
ModelTargetReconciliationInput, ModelTargetReconciliationPolicy,
ModelTargetReconciliationState, plan_model_target_reconciliation,
ModelTargetReconciliationAction, ModelTargetReconciliationCandidate,
ModelTargetReconciliationCapacityState, ModelTargetReconciliationInput,
ModelTargetReconciliationPolicy, ModelTargetReconciliationState,
plan_model_target_reconciliation,
};
use self::proxy::{api_proxy, bootstrap_proxy};
#[cfg(test)]
@ -3496,6 +3497,9 @@ fn model_target_reconciliation_policy(
) -> ModelTargetReconciliationPolicy {
ModelTargetReconciliationPolicy {
enabled: config.runtime.reconcile_model_targets,
demand_upgrades_enabled: config.runtime.reconcile_model_target_demand_upgrades,
demand_upgrade_min_request_count: config.runtime.model_target_demand_upgrade_min_requests,
demand_upgrade_max_age_secs: config.runtime.model_target_demand_upgrade_max_age_secs,
..ModelTargetReconciliationPolicy::default()
}
}
@ -3533,21 +3537,26 @@ async fn reconcile_model_targets_once(ctx: ReconcileModelTargetsContext<'_>) {
.await
.into_iter()
.collect::<BTreeSet<_>>();
if local_interest_model_refs.is_empty() {
let loaded_model_refs = runtime_loaded_model_refs(runtime_models, managed_models);
if local_interest_model_refs.is_empty() && loaded_model_refs.is_empty() {
state.prune_expired(runtime_unix_secs());
return;
}
let target_lookup = console_state.model_target_lookup().await;
let loaded_model_refs = runtime_loaded_model_refs(runtime_models, managed_models);
let local_vram_bytes = node.vram_bytes();
let targets = target_lookup
.targets
.into_iter()
.map(|target| {
let demand_upgrade_target = model_target_reconciliation_demand_upgrade_candidate(
policy,
&loaded_model_refs,
&target,
);
let local_path = if target.wanted
&& target.serving_node_count == 0
&& local_interest_model_refs.contains(&target.model_ref)
&& (local_interest_model_refs.contains(&target.model_ref) || demand_upgrade_target)
&& target.capacity_advice.state
== api::status::ModelTargetCapacityAdviceState::SingleNodeFit
&& model_target_reconciliation_local_fit(&target, local_vram_bytes)
@ -3557,9 +3566,13 @@ async fn reconcile_model_targets_once(ctx: ReconcileModelTargetsContext<'_>) {
None
};
ModelTargetReconciliationCandidate {
rank: target.rank,
model_ref: target.model_ref,
model_name: target.model_name,
wanted: target.wanted,
wanted_reason: target.wanted_reason,
request_count: target.request_count,
last_active_secs_ago: target.last_active_secs_ago,
serving_node_count: target.serving_node_count,
capacity_state: ModelTargetReconciliationCapacityState::from(
target.capacity_advice.state,
@ -3584,43 +3597,84 @@ async fn reconcile_model_targets_once(ctx: ReconcileModelTargetsContext<'_>) {
for action in actions {
let load_spec = action.load_spec.to_string_lossy().to_string();
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
if control_tx
.send(api::RuntimeControlRequest::Load {
spec: load_spec.clone(),
resp: resp_tx,
})
.is_err()
{
state.record_load_failure(&action.model_ref, now_secs, policy);
let _ = emit_event(OutputEvent::Warning {
message: format!(
"Model target reconciliation could not queue '{}'",
action.model_ref
),
context: Some("runtime control channel closed".to_string()),
});
continue;
}
state.mark_load_started(&action.model_ref);
let event_tx = runtime_event_tx.clone();
let model_ref = action.model_ref.clone();
let control_tx = control_tx.clone();
let replace_model_ref = action.replace_model_ref.clone();
tokio::spawn(async move {
let result = match resp_rx.await {
Ok(result) => result.map_err(|err| err.to_string()),
Err(err) => Err(format!("runtime load response channel closed: {err}")),
};
let result =
run_model_target_reconciliation_action(control_tx, load_spec, replace_model_ref)
.await;
let _ = event_tx
.send(RuntimeEvent::ModelTargetReconciliationLoadFinished { model_ref, result });
});
let _ = emit_event(OutputEvent::Info {
message: format!("Model target reconciliation loading '{}'", action.model_ref),
context: Some(format!("path={load_spec}")),
});
emit_model_target_reconciliation_queued(&action);
}
}
async fn run_model_target_reconciliation_action(
control_tx: tokio::sync::mpsc::UnboundedSender<api::RuntimeControlRequest>,
load_spec: String,
replace_model_ref: Option<String>,
) -> std::result::Result<api::RuntimeLoadResponse, String> {
if let Some(replace_model_ref) = replace_model_ref {
run_model_target_reconciliation_unload(control_tx.clone(), replace_model_ref).await?;
}
run_model_target_reconciliation_load(control_tx, load_spec).await
}
async fn run_model_target_reconciliation_unload(
control_tx: tokio::sync::mpsc::UnboundedSender<api::RuntimeControlRequest>,
model_ref: String,
) -> std::result::Result<api::RuntimeUnloadResponse, String> {
let (resp, response) = tokio::sync::oneshot::channel();
control_tx
.send(api::RuntimeControlRequest::Unload {
target: UnloadTarget::Model(model_ref.clone()),
options: UnloadOptions::default(),
resp,
})
.map_err(|_| format!("runtime unload queue closed for replacement target '{model_ref}'"))?;
response
.await
.map_err(|err| format!("runtime unload response channel closed: {err}"))?
.map_err(|err| err.to_string())
}
async fn run_model_target_reconciliation_load(
control_tx: tokio::sync::mpsc::UnboundedSender<api::RuntimeControlRequest>,
load_spec: String,
) -> std::result::Result<api::RuntimeLoadResponse, String> {
let (resp, response) = tokio::sync::oneshot::channel();
control_tx
.send(api::RuntimeControlRequest::Load {
spec: load_spec.clone(),
resp,
})
.map_err(|_| format!("runtime load queue closed for '{load_spec}'"))?;
response
.await
.map_err(|err| format!("runtime load response channel closed: {err}"))?
.map_err(|err| err.to_string())
}
fn emit_model_target_reconciliation_queued(action: &ModelTargetReconciliationAction) {
let context = match action.replace_model_ref.as_deref() {
Some(replace_model_ref) => Some(format!("replace={replace_model_ref}")),
None => Some(format!("path={}", action.load_spec.display())),
};
let verb = if action.replace_model_ref.is_some() {
"upgrading to"
} else {
"loading"
};
let _ = emit_event(OutputEvent::Info {
message: format!("Model target reconciliation {verb} '{}'", action.model_ref),
context,
});
}
fn runtime_loaded_model_refs(
runtime_models: &HashMap<String, RuntimeModelHandleEntry>,
managed_models: &HashMap<String, ManagedModelController>,
@ -3659,6 +3713,20 @@ fn model_target_reconciliation_local_fit(
.is_some_and(|required| local_vram_bytes >= required)
}
fn model_target_reconciliation_demand_upgrade_candidate(
policy: &ModelTargetReconciliationPolicy,
loaded_model_refs: &BTreeSet<String>,
target: &api::status::ModelTargetPayload,
) -> bool {
policy.demand_upgrades_enabled
&& !loaded_model_refs.is_empty()
&& target.wanted_reason == Some("active_demand")
&& target.request_count >= policy.demand_upgrade_min_request_count
&& target
.last_active_secs_ago
.is_some_and(|age| age <= policy.demand_upgrade_max_age_secs)
}
fn runtime_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@ -8707,6 +8775,51 @@ mod tests {
));
}
#[tokio::test]
async fn model_target_reconciliation_replacement_unloads_before_loading() {
let (control_tx, mut control_rx) =
tokio::sync::mpsc::unbounded_channel::<api::RuntimeControlRequest>();
let task = tokio::spawn(run_model_target_reconciliation_action(
control_tx,
"/models/large.gguf".to_string(),
Some("Small".to_string()),
));
match control_rx.recv().await {
Some(api::RuntimeControlRequest::Unload { target, resp, .. }) => {
assert_eq!(target.as_runtime_target(), "Small");
resp.send(Ok(api::RuntimeUnloadResponse {
model: "Small".to_string(),
instance_id: "runtime-1".to_string(),
unloaded: true,
}))
.expect("replacement unload response should be received");
}
_ => panic!("expected unload request before load"),
}
match control_rx.recv().await {
Some(api::RuntimeControlRequest::Load { spec, resp }) => {
assert_eq!(spec, "/models/large.gguf");
resp.send(Ok(api::RuntimeLoadResponse {
model_ref: spec,
model: "Large".to_string(),
instance_id: "runtime-2".to_string(),
backend: Some("skippy".to_string()),
context_length: Some(4096),
}))
.expect("replacement load response should be received");
}
_ => panic!("expected load request after unload"),
}
let result = task
.await
.expect("replacement task should join")
.expect("replacement action should finish");
assert_eq!(result.model, "Large");
assert!(control_rx.try_recv().is_err());
}
fn remote_catalog_layer_entry(
variant_name: &str,
curated_name: &str,

View file

@ -9,6 +9,9 @@ pub(crate) struct ModelTargetReconciliationPolicy {
pub(crate) max_loads_per_tick: usize,
pub(crate) failure_cooldown_secs: u64,
pub(crate) manual_unload_cooldown_secs: u64,
pub(crate) demand_upgrades_enabled: bool,
pub(crate) demand_upgrade_min_request_count: u64,
pub(crate) demand_upgrade_max_age_secs: u64,
}
impl Default for ModelTargetReconciliationPolicy {
@ -18,6 +21,11 @@ impl Default for ModelTargetReconciliationPolicy {
max_loads_per_tick: 1,
failure_cooldown_secs: 5 * 60,
manual_unload_cooldown_secs: 5 * 60,
demand_upgrades_enabled: false,
demand_upgrade_min_request_count:
mesh_llm_config::DEFAULT_MODEL_TARGET_DEMAND_UPGRADE_MIN_REQUESTS,
demand_upgrade_max_age_secs:
mesh_llm_config::DEFAULT_MODEL_TARGET_DEMAND_UPGRADE_MAX_AGE_SECS,
}
}
}
@ -114,9 +122,13 @@ pub(crate) struct ModelTargetReconciliationInput<'a> {
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ModelTargetReconciliationCandidate {
pub(crate) rank: usize,
pub(crate) model_ref: String,
pub(crate) model_name: Option<String>,
pub(crate) wanted: bool,
pub(crate) wanted_reason: Option<&'static str>,
pub(crate) request_count: u64,
pub(crate) last_active_secs_ago: Option<u64>,
pub(crate) serving_node_count: usize,
pub(crate) capacity_state: ModelTargetReconciliationCapacityState,
pub(crate) local_path: Option<PathBuf>,
@ -152,6 +164,7 @@ pub(crate) struct ModelTargetReconciliationAction {
pub(crate) model_ref: String,
pub(crate) model_name: Option<String>,
pub(crate) load_spec: PathBuf,
pub(crate) replace_model_ref: Option<String>,
}
pub(crate) fn plan_model_target_reconciliation(
@ -175,10 +188,13 @@ pub(crate) fn plan_model_target_reconciliation(
let Some(load_spec) = target.local_path.clone() else {
continue;
};
let replace_model_ref =
replacement_target(policy, input.loaded_model_refs, input.targets, target);
let has_local_interest = input.local_interest_model_refs.contains(&target.model_ref);
if !target.wanted
|| target.serving_node_count > 0
|| target.capacity_state != ModelTargetReconciliationCapacityState::SingleNodeFit
|| !input.local_interest_model_refs.contains(&target.model_ref)
|| (!has_local_interest && replace_model_ref.is_none())
|| loaded_target(input.loaded_model_refs, target)
|| state.suppressed(
&target.model_ref,
@ -193,11 +209,58 @@ pub(crate) fn plan_model_target_reconciliation(
model_ref: target.model_ref.clone(),
model_name: target.model_name.clone(),
load_spec,
replace_model_ref,
});
}
actions
}
fn replacement_target(
policy: &ModelTargetReconciliationPolicy,
loaded_model_refs: &BTreeSet<String>,
targets: &[ModelTargetReconciliationCandidate],
target: &ModelTargetReconciliationCandidate,
) -> Option<String> {
if !demand_upgrade_candidate(policy, loaded_model_refs, target) {
return None;
}
loaded_model_refs
.iter()
.find(|loaded| replacement_improves_target_mix(loaded, targets, target))
.cloned()
}
fn demand_upgrade_candidate(
policy: &ModelTargetReconciliationPolicy,
loaded_model_refs: &BTreeSet<String>,
target: &ModelTargetReconciliationCandidate,
) -> bool {
policy.demand_upgrades_enabled
&& !loaded_model_refs.is_empty()
&& target.wanted_reason == Some("active_demand")
&& target.request_count >= policy.demand_upgrade_min_request_count
&& target
.last_active_secs_ago
.is_some_and(|age| age <= policy.demand_upgrade_max_age_secs)
}
fn replacement_improves_target_mix(
loaded_model_ref: &str,
targets: &[ModelTargetReconciliationCandidate],
target: &ModelTargetReconciliationCandidate,
) -> bool {
let Some(loaded) = targets
.iter()
.find(|candidate| model_target_matches_loaded(candidate, loaded_model_ref))
else {
return true;
};
if loaded.request_count >= target.request_count {
return false;
}
target.rank < loaded.rank || loaded.request_count == 0
}
fn loaded_target(
loaded_model_refs: &BTreeSet<String>,
target: &ModelTargetReconciliationCandidate,
@ -211,6 +274,17 @@ fn loaded_target(
})
}
fn model_target_matches_loaded(
target: &ModelTargetReconciliationCandidate,
loaded_model_ref: &str,
) -> bool {
model_identity_matches(loaded_model_ref, &target.model_ref)
|| target
.model_name
.as_deref()
.is_some_and(|name| model_identity_matches(loaded_model_ref, name))
}
fn model_identity_matches(left: &str, right: &str) -> bool {
if left == right {
return true;
@ -243,11 +317,24 @@ mod tests {
}
}
fn demand_upgrade_policy() -> ModelTargetReconciliationPolicy {
ModelTargetReconciliationPolicy {
demand_upgrades_enabled: true,
demand_upgrade_min_request_count: 2,
demand_upgrade_max_age_secs: 60 * 60,
..enabled_policy()
}
}
fn target(model_ref: &str) -> ModelTargetReconciliationCandidate {
ModelTargetReconciliationCandidate {
rank: 1,
model_ref: model_ref.to_string(),
model_name: Some("Qwen3-8B-Q4_K_M".to_string()),
wanted: true,
wanted_reason: Some("explicit_interest"),
request_count: 0,
last_active_secs_ago: None,
serving_node_count: 0,
capacity_state: ModelTargetReconciliationCapacityState::SingleNodeFit,
local_path: Some(PathBuf::from("/models/qwen.gguf")),
@ -303,10 +390,140 @@ mod tests {
model_ref: "org/model@main:file.gguf".to_string(),
model_name: Some("Qwen3-8B-Q4_K_M".to_string()),
load_spec: PathBuf::from("/models/qwen.gguf"),
replace_model_ref: None,
}]
);
}
#[test]
fn demand_upgrade_replaces_lower_demand_loaded_model() {
let mut wanted_large = target("org/large@main:file.gguf");
wanted_large.rank = 1;
wanted_large.model_name = Some("Large".to_string());
wanted_large.wanted_reason = Some("active_demand");
wanted_large.request_count = 8;
wanted_large.last_active_secs_ago = Some(30);
wanted_large.local_path = Some(PathBuf::from("/models/large.gguf"));
let mut loaded_small = target("org/small@main:file.gguf");
loaded_small.rank = 2;
loaded_small.model_name = Some("Small".to_string());
loaded_small.wanted = false;
loaded_small.request_count = 1;
loaded_small.serving_node_count = 1;
loaded_small.capacity_state = ModelTargetReconciliationCapacityState::AlreadyServing;
loaded_small.local_path = None;
let targets = vec![wanted_large, loaded_small];
let local_interests = BTreeSet::new();
let loaded = BTreeSet::from(["Small".to_string()]);
let mut state = ModelTargetReconciliationState::default();
let actions = plan_model_target_reconciliation(
&demand_upgrade_policy(),
&mut state,
input(&local_interests, &loaded, &targets),
);
assert_eq!(
actions,
vec![ModelTargetReconciliationAction {
model_ref: "org/large@main:file.gguf".to_string(),
model_name: Some("Large".to_string()),
load_spec: PathBuf::from("/models/large.gguf"),
replace_model_ref: Some("Small".to_string()),
}]
);
}
#[test]
fn demand_upgrade_requires_explicit_policy_opt_in() {
let mut wanted_large = target("org/large@main:file.gguf");
wanted_large.model_name = Some("Large".to_string());
wanted_large.wanted_reason = Some("active_demand");
wanted_large.request_count = 8;
wanted_large.last_active_secs_ago = Some(30);
let loaded = BTreeSet::from(["Small".to_string()]);
let targets = vec![wanted_large];
let local_interests = BTreeSet::new();
let mut state = ModelTargetReconciliationState::default();
let actions = plan_model_target_reconciliation(
&enabled_policy(),
&mut state,
input(&local_interests, &loaded, &targets),
);
assert!(actions.is_empty());
}
#[test]
fn stale_demand_does_not_replace_loaded_model() {
let mut wanted_large = target("org/large@main:file.gguf");
wanted_large.model_name = Some("Large".to_string());
wanted_large.wanted_reason = Some("active_demand");
wanted_large.request_count = 8;
wanted_large.last_active_secs_ago = Some(2 * 60 * 60);
let loaded = BTreeSet::from(["Small".to_string()]);
let targets = vec![wanted_large];
let local_interests = BTreeSet::new();
let mut state = ModelTargetReconciliationState::default();
let actions = plan_model_target_reconciliation(
&demand_upgrade_policy(),
&mut state,
input(&local_interests, &loaded, &targets),
);
assert!(actions.is_empty());
}
#[test]
fn requested_only_target_does_not_replace_loaded_model_without_request_demand() {
let mut requested_only = target("org/requested@main:file.gguf");
requested_only.request_count = 0;
let targets = vec![requested_only];
let local_interests = BTreeSet::new();
let loaded = BTreeSet::from(["Small".to_string()]);
let mut state = ModelTargetReconciliationState::default();
let actions = plan_model_target_reconciliation(
&demand_upgrade_policy(),
&mut state,
input(&local_interests, &loaded, &targets),
);
assert!(actions.is_empty());
}
#[test]
fn demand_upgrade_preserves_loaded_model_with_equal_or_higher_demand() {
let mut wanted_large = target("org/large@main:file.gguf");
wanted_large.rank = 2;
wanted_large.model_name = Some("Large".to_string());
wanted_large.wanted_reason = Some("active_demand");
wanted_large.request_count = 3;
wanted_large.last_active_secs_ago = Some(30);
let mut loaded_hot = target("org/hot@main:file.gguf");
loaded_hot.rank = 1;
loaded_hot.model_name = Some("Hot".to_string());
loaded_hot.wanted = false;
loaded_hot.request_count = 3;
loaded_hot.serving_node_count = 1;
loaded_hot.capacity_state = ModelTargetReconciliationCapacityState::AlreadyServing;
loaded_hot.local_path = None;
let targets = vec![loaded_hot, wanted_large];
let local_interests = BTreeSet::new();
let loaded = BTreeSet::from(["Hot".to_string()]);
let mut state = ModelTargetReconciliationState::default();
let actions = plan_model_target_reconciliation(
&demand_upgrade_policy(),
&mut state,
input(&local_interests, &loaded, &targets),
);
assert!(actions.is_empty());
}
#[test]
fn skips_peer_only_or_requested_targets_without_local_interest() {
let targets = vec![target("org/model@main:file.gguf")];

View file

@ -280,8 +280,14 @@ without the HTML via curl/scripts.
Runtime reconciliation is opt-in. When `[runtime] reconcile_model_targets = true`
is set, the local runtime may load an already-present local GGUF for a locally
registered explicit interest, but only when `/api/model-targets` says the target
is wanted, unserved, and a single-node capacity fit for the current node. It
does not download models, start split serving, or act on peer-only interest.
is wanted, unserved, and a single-node capacity fit for the current node. A
host that also sets `reconcile_model_target_demand_upgrades = true` may replace
a less-demanded local model with a locally present, higher-ranked unserved
target once fresh active request demand crosses
`model_target_demand_upgrade_min_requests`. Stale demand older than
`model_target_demand_upgrade_max_age_secs` is advisory only. Runtime
reconciliation does not download models, start split serving, or act on
requested-only seed interest.
`/api/model-targets` keeps raw inputs and computed hints separate. Each target
reports `signals` from observed mesh state (`explicit_interest_count`,

View file

@ -618,7 +618,7 @@ curl localhost:3131/api/discover # Nostr meshes (current mesh marked by mesh_id)
- `/api/search` returns 200 JSON with canonical model refs for matching results
- `/api/model-interests` stores and returns local explicit-interest entries keyed by canonical model refs
- `/api/model-targets` returns ranked targets with explicit-interest counts, request counts, serving-node counts, `wanted` for targets not currently served, and derived `capacity_advice` without changing ranking or routing behavior
- If `[runtime] reconcile_model_targets = true` is enabled, unserved local explicit interests that are already present on disk and fit the current node may be runtime-loaded automatically. Leave it unset for read-only advisory checks.
- If `[runtime] reconcile_model_targets = true` is enabled, unserved local explicit interests that are already present on disk and fit the current node may be runtime-loaded automatically. If `reconcile_model_target_demand_upgrades = true` is also enabled, an already-serving host may replace a lower-demand local model with a locally present, higher-demand unserved target whose active demand is still within `model_target_demand_upgrade_max_age_secs`. Leave these unset for read-only advisory checks.
- Discover results can be matched to current mesh by `mesh_id`
### 24. HTTP proxy single-request connection contract