fix(desktop): keep shared-compute consumers admitted (WIP: multi-workspace publishing) (#2000)

Signed-off-by: Mat Balez <60949391+matbalez@users.noreply.github.com>
Co-authored-by: Mat Balez <60949391+matbalez@users.noreply.github.com>
This commit is contained in:
Michael Neale
2026-07-17 12:52:30 +10:00
committed by GitHub
co-authored by Mat Balez
parent f054df7366
commit 5d77fa5749
9 changed files with 278 additions and 80 deletions
+62 -13
View File
@@ -47,16 +47,52 @@ const RELAY_MESH_RUNTIME_NO_TARGET: &str =
pub type CmdResult<T> = Result<T, String>;
fn advance_mesh_status_cursor(
filter: &mut serde_json::Value,
page: &[nostr::Event],
) -> Result<(u64, String), String> {
let last = page
.last()
.ok_or_else(|| "cannot advance an empty mesh status page".to_string())?;
let cursor = (last.created_at.as_secs(), last.id.to_hex());
filter["until"] = serde_json::json!(cursor.0);
filter["before_id"] = serde_json::json!(cursor.1);
Ok(cursor)
}
async fn query_mesh_discovery_events(state: &AppState) -> Result<Vec<nostr::Event>, String> {
let mut events = relay::query_relay(state, &[mesh_llm::relay_membership_filter()]).await?;
let member_pubkeys = mesh_llm::current_member_pubkeys(&events);
if member_pubkeys.is_empty() {
return Ok(events);
}
let mut status_filter = mesh_llm::mesh_status_filter();
status_filter["authors"] = serde_json::json!(member_pubkeys);
let mut previous_cursor: Option<(u64, String)> = None;
loop {
let page = relay::query_relay(state, &[status_filter.clone()]).await?;
let done = page.len() < mesh_llm::MESH_STATUS_PAGE_SIZE;
if !done {
let cursor = advance_mesh_status_cursor(&mut status_filter, &page)?;
if previous_cursor.as_ref() == Some(&cursor) {
return Err("mesh status pagination did not advance".to_string());
}
previous_cursor = Some(cursor);
}
events.extend(page);
if done {
return Ok(events);
}
}
}
/// Resolve the admission roster by intersecting member-signed mesh status
/// reporters with the current NIP-43 direct-member list. Missing membership or
/// a failed query returns an empty roster, which the runtime normalizes to
/// self-only admission.
pub(crate) async fn resolve_trusted_owner_ids(state: &AppState) -> Vec<String> {
let filters = [
mesh_llm::mesh_status_filter(),
mesh_llm::relay_membership_filter(),
];
match relay::query_relay(state, &filters).await {
match query_mesh_discovery_events(state).await {
Ok(events) => mesh_llm::owner_ids_from_events(&events),
Err(error) => {
eprintln!("buzz-mesh: roster query failed; allowing only this node: {error}");
@@ -272,14 +308,7 @@ pub(crate) async fn resolve_mesh_bootstrap_target(
if model_id.is_empty() {
return Ok(None);
}
let events = relay::query_relay(
state,
&[
mesh_llm::mesh_status_filter(),
mesh_llm::relay_membership_filter(),
],
)
.await?;
let events = query_mesh_discovery_events(state).await?;
Ok(pick_serve_target_for_model(
mesh_llm::availability_from_events(events).serve_targets,
model_id,
@@ -426,6 +455,26 @@ mod tests {
}
}
#[test]
fn mesh_status_cursor_uses_relay_composite_tiebreak() {
let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "status")
.custom_created_at(nostr::Timestamp::from(1_234))
.sign_with_keys(&nostr::Keys::generate())
.expect("sign test status");
let mut filter = mesh_llm::mesh_status_filter();
let cursor = advance_mesh_status_cursor(&mut filter, std::slice::from_ref(&event))
.expect("advance status cursor");
assert_eq!(cursor, (1_234, event.id.to_hex()));
assert_eq!(filter["until"], serde_json::json!(1_234));
assert_eq!(filter["before_id"], serde_json::json!(event.id.to_hex()));
assert_eq!(
filter["limit"],
serde_json::json!(mesh_llm::MESH_STATUS_PAGE_SIZE)
);
}
#[test]
fn pick_serve_target_returns_first_match_for_model() {
let targets = vec![
+33 -6
View File
@@ -184,18 +184,45 @@ pub async fn apply_workspace(
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
let state = restore_app.state::<AppState>();
if state
let restore_pending = state
.managed_agent_restore_pending
.swap(false, Ordering::AcqRel)
.swap(false, Ordering::AcqRel);
// The coordinator starts before React applies the selected workspace, so
// its startup publication may have used the fallback relay and placeholder
// identity. Correct it off the command path so an unavailable relay cannot
// hold the frontend on its loading gate. On initial launch, restore MeshLLM
// first so a slow stopped-status request cannot overwrite a newly restored
// serving status, then restore managed agents after the admission identity
// has been published (or the bounded publication attempt has timed out).
#[cfg(feature = "mesh-llm")]
{
let app = restore_app.clone();
tauri::async_runtime::spawn(async move {
let state = app.state::<AppState>();
#[cfg(feature = "mesh-llm")]
if let Err(error) = crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await
{
eprintln!("buzz-desktop: failed to restore Share Compute: {error}");
if restore_pending {
if let Err(error) =
crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await
{
eprintln!("buzz-desktop: failed to restore Share Compute: {error}");
}
}
crate::mesh_llm::publish_current_status_once(&app, "workspace apply").await;
if restore_pending {
if let Err(error) =
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
{
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
}
}
});
}
#[cfg(not(feature = "mesh-llm"))]
if restore_pending {
let app = restore_app.clone();
tauri::async_runtime::spawn(async move {
let state = app.state::<AppState>();
if let Err(error) =
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
{
+81 -11
View File
@@ -8,10 +8,34 @@
use serde::Serialize;
use mesh_llm_client::models::catalog::{parse_size_gb, MODEL_CATALOG};
use mesh_llm_client::network::nostr::auto_model_pack;
use mesh_llm_node::models::{default_huggingface_cache_dir, scan_installed_models};
use mesh_llm_system::hardware;
use mesh_llm_system::vram::format_rated_capacity;
use mesh_llm_system::vram::{format_rated_capacity, rated_capacity_gb};
/// Buzz-curated tier picks. These are the models we know survive the agent
/// harness on shared compute — deliberately non-reasoning instruction models,
/// so agents stay snappy instead of burning hidden reasoning tokens.
///
/// The large pick is resolved through mesh-llm's remote catalog
/// (huggingface.co/datasets/meshllm/catalog), so it does not need to exist in
/// the compiled `MODEL_CATALOG`; the entry is synthesized below.
const CURATED_LARGE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M";
const CURATED_LARGE_SIZE: &str = "17GB";
const CURATED_LARGE_FILE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf";
const CURATED_LARGE_DESCRIPTION: &str =
"Gemma 4 26B MoE (4B active) — Buzz default for 64GB+ machines";
const CURATED_SMALL: &str = "Gemma-4-E4B-it-Q4_K_M";
/// Rated-capacity boundary between the two curated tiers, in GB (marketing
/// capacity — a "64GB" Mac rates as 64 even though usable AI memory is less).
const CURATED_LARGE_MIN_RATED_GB: u64 = 64;
/// The Buzz-curated recommendation for a machine's rated memory capacity.
fn buzz_recommended_model(rated_gb: Option<u64>) -> &'static str {
match rated_gb {
Some(gb) if gb >= CURATED_LARGE_MIN_RATED_GB => CURATED_LARGE,
_ => CURATED_SMALL,
}
}
/// How a model sits inside this machine's usable AI memory.
/// Mirrors mesh-llm's private `fit_code_for_size_label` thresholds.
@@ -57,6 +81,9 @@ pub struct MeshCatalogEntry {
pub fit: ModelFit,
pub installed: bool,
pub recommended: bool,
/// Buzz-curated pick — known to survive the agent harness. Curated
/// entries render above the fold; everything else is "advanced".
pub curated: bool,
}
#[derive(Debug, Clone, Serialize)]
@@ -123,6 +150,7 @@ fn build_catalog(
fit: fit_code(size_gb, vram_gb),
installed: is_installed(&m.file, &m.name),
recommended: false,
curated: false,
name: m.name.clone(),
size: m.size.clone(),
size_gb,
@@ -131,14 +159,36 @@ fn build_catalog(
})
.collect();
let recommended = auto_model_pack(vram_gb).into_iter().next();
// The compiled MODEL_CATALOG does not know the Buzz large pick; it
// resolves through mesh-llm's remote catalog at download time. Synthesize
// its entry so the picker can offer it.
if !entries.iter().any(|e| e.name == CURATED_LARGE) {
let size_gb = parse_size_gb(CURATED_LARGE_SIZE);
entries.push(MeshCatalogEntry {
fit: fit_code(size_gb, vram_gb),
installed: is_installed(CURATED_LARGE_FILE, CURATED_LARGE),
recommended: false,
curated: false,
name: CURATED_LARGE.to_string(),
size: CURATED_LARGE_SIZE.to_string(),
size_gb,
description: CURATED_LARGE_DESCRIPTION.to_string(),
});
}
let recommended = Some(buzz_recommended_model(rated_capacity_gb(vram_bytes)).to_string());
for entry in &mut entries {
entry.recommended = recommended.as_deref() == Some(entry.name.as_str());
// Both curated tiers are always offered: the recommended one for this
// machine plus the other pick (e.g. the small one as an explicit
// lighter choice on big machines).
entry.curated = entry.name == CURATED_LARGE || entry.name == CURATED_SMALL;
}
entries.sort_by(|a, b| {
b.recommended
.cmp(&a.recommended)
.then(b.curated.cmp(&a.curated))
.then(fit_rank(a.fit).cmp(&fit_rank(b.fit)))
.then(b.size_gb.total_cmp(&a.size_gb))
});
@@ -191,11 +241,11 @@ mod tests {
assert!(catalog.entries[0].recommended);
}
}
// Fit ranks must be non-decreasing after the recommended head.
// Fit ranks must be non-decreasing after the recommended/curated head.
let ranks: Vec<u8> = catalog
.entries
.iter()
.skip_while(|e| e.recommended)
.skip_while(|e| e.recommended || e.curated)
.map(|e| fit_rank(e.fit))
.collect();
assert!(
@@ -205,12 +255,32 @@ mod tests {
}
#[test]
fn recommendation_uses_mesh_llm_auto_selection() {
let catalog = build_catalog(None, 62_000_000_000, 62.0, &[]);
assert_eq!(
catalog.recommended,
auto_model_pack(62.0).into_iter().next()
);
fn recommendation_follows_buzz_curated_tiers() {
// 64GB+ rated machines get the large curated pick.
let large = build_catalog(None, 64_000_000_000, 64.0, &[]);
assert_eq!(large.recommended.as_deref(), Some(CURATED_LARGE));
let big = build_catalog(None, 128_000_000_000, 128.0, &[]);
assert_eq!(big.recommended.as_deref(), Some(CURATED_LARGE));
// Below the boundary: the small curated pick — never a reasoning
// model, never sub-4B guesswork.
let small = build_catalog(None, 32_000_000_000, 32.0, &[]);
assert_eq!(small.recommended.as_deref(), Some(CURATED_SMALL));
let tiny = build_catalog(None, 16_000_000_000, 16.0, &[]);
assert_eq!(tiny.recommended.as_deref(), Some(CURATED_SMALL));
}
#[test]
fn curated_picks_lead_the_catalog() {
let catalog = build_catalog(None, 96_000_000_000, 96.0, &[]);
// Recommended curated entry first, the other curated pick second,
// advanced entries after.
assert_eq!(catalog.entries[0].name, CURATED_LARGE);
assert!(catalog.entries[0].recommended && catalog.entries[0].curated);
assert_eq!(catalog.entries[1].name, CURATED_SMALL);
assert!(catalog.entries[1].curated && !catalog.entries[1].recommended);
assert!(catalog.entries[2..].iter().all(|e| !e.curated));
// The synthesized large pick carries a real size for fit ranking.
assert!(catalog.entries[0].size_gb > 10.0);
}
#[test]
+28 -33
View File
@@ -21,6 +21,7 @@ pub const KIND_BUZZ_MESH_MEMBER_STATUS: u16 = buzz_core_pkg::kind::KIND_BOOKMARK
const STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status";
const ROSTER_POLL_INTERVAL: Duration = Duration::from_secs(60);
const STATUS_PUBLISH_INTERVAL: Duration = Duration::from_secs(45);
const STATUS_PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
pub struct MeshCoordinator {
_status_publisher: tokio::task::JoinHandle<()>,
@@ -38,13 +39,14 @@ pub async fn start_coordinator(app: AppHandle) {
let publisher_app = app.clone();
let status_publisher = tokio::spawn(async move {
// Clear a stale serving status promptly after an app restart. Once the
// node is stopped, the explicit stopped event is sufficient; only a
// running node needs periodic freshness heartbeats.
// Clear a stale serving status promptly after an app restart. Keep
// publishing while stopped too: serving nodes build their admission
// allowlist from fresh member statuses, so consumer-only identities
// must remain fresh even though they advertise no serving targets.
publish_current_status_once(&publisher_app, "startup").await;
loop {
tokio::time::sleep(STATUS_PUBLISH_INTERVAL).await;
publish_running_status_once(&publisher_app).await;
publish_current_status_once(&publisher_app, "heartbeat").await;
}
});
let roster_app = app.clone();
@@ -106,22 +108,31 @@ async fn reconcile_roster(state: &AppState) -> Result<(), String> {
pub(crate) async fn publish_current_status_once(app: &AppHandle, reason: &str) {
let state = app.state::<AppState>();
if let Err(error) = publish_current_status_for_state(&state).await {
eprintln!("buzz-mesh: status report after {reason} failed: {error}");
match tokio::time::timeout(
STATUS_PUBLISH_TIMEOUT,
publish_current_status_for_state(&state),
)
.await
{
Ok(Ok(())) => {}
Ok(Err(error)) => eprintln!("buzz-mesh: status report after {reason} failed: {error}"),
Err(_) => eprintln!("buzz-mesh: status report after {reason} timed out"),
}
}
pub(crate) async fn publish_stopped_status_once(app: &AppHandle, reason: &str) {
let state = app.state::<AppState>();
if let Err(error) = publish_stopped_status_for_state(&state).await {
eprintln!("buzz-mesh: stopped status report after {reason} failed: {error}");
}
}
async fn publish_running_status_once(app: &AppHandle) {
let state = app.state::<AppState>();
if let Err(error) = publish_running_status_for_state(&state).await {
eprintln!("buzz-mesh: periodic status report failed: {error}");
match tokio::time::timeout(
STATUS_PUBLISH_TIMEOUT,
publish_stopped_status_for_state(&state),
)
.await
{
Ok(Ok(())) => {}
Ok(Err(error)) => {
eprintln!("buzz-mesh: stopped status report after {reason} failed: {error}");
}
Err(_) => eprintln!("buzz-mesh: stopped status report after {reason} timed out"),
}
}
@@ -150,23 +161,6 @@ async fn publish_stopped_status_for_state(state: &AppState) -> Result<(), String
publish_status_report(state, payload).await
}
async fn publish_running_status_for_state(state: &AppState) -> Result<(), String> {
let identity = super::ensure_owner_identity()
.map_err(|error| format!("failed to load mesh owner identity: {error}"))?;
let mut payload = {
let runtime = state.mesh_llm_runtime.lock().await;
let Some(runtime) = runtime.as_ref() else {
return Ok(());
};
runtime
.status_report_payload()
.await
.map_err(|error| error.to_string())?
};
bind_payload_to_member(state, &identity, &mut payload)?;
publish_status_report(state, payload).await
}
fn stopped_status_payload(identity: &super::identity::OwnerIdentity) -> serde_json::Value {
serde_json::json!({
"ownerId": identity.owner_id,
@@ -234,10 +228,11 @@ mod tests {
use super::*;
#[test]
fn heartbeat_leaves_room_before_status_expires() {
fn member_heartbeat_leaves_room_before_admission_status_expires() {
assert!(
STATUS_PUBLISH_INTERVAL.as_secs() * 2 < super::super::discovery::STATUS_FRESHNESS_SECS
);
assert!(STATUS_PUBLISH_TIMEOUT < STATUS_PUBLISH_INTERVAL);
}
#[test]
+27 -5
View File
@@ -9,6 +9,7 @@ use super::{dedupe_models, MeshAvailability, MeshModelOption, MeshServeTarget, M
/// than two minutes so crashed/offline devices stop contributing compute or
/// admission identities without requiring a relay-side cleanup job.
pub(super) const STATUS_FRESHNESS_SECS: u64 = 120;
pub(crate) const MESH_STATUS_PAGE_SIZE: usize = 100;
fn status_is_fresh(event: &nostr::Event, now: u64) -> bool {
event
@@ -34,16 +35,22 @@ fn dedupe_targets(targets: Vec<MeshServeTarget>) -> Vec<MeshServeTarget> {
/// contribute an owner id. This removes stale notes from former members and
/// ignores notes from nonmembers. If the relay has no membership snapshot, the
/// roster is empty and MeshLLM admission therefore remains self-only.
///
/// Admission deliberately ignores status freshness: membership is the trust
/// boundary, and a member whose device is offline (stale status) is still a
/// member. Gating admission on freshness caused the allowlist — and therefore
/// the serving node — to churn whenever any member's app went online or
/// offline. Freshness still gates *routing* (see `availability_from_events`):
/// stale nodes are never selected as serve targets. Revocation is unaffected:
/// a member removed from the NIP-43 roster leaves the intersection at the next
/// roster poll regardless of how fresh their last status is.
pub fn owner_ids_from_events(events: &[nostr::Event]) -> Vec<String> {
let Some(members) = latest_membership_list(events) else {
return Vec::new();
};
let now = nostr::Timestamp::now().as_secs();
let mut ids: Vec<String> = events
.iter()
.filter(|event| {
event.kind.as_u16() as u64 == MESH_STATUS_KIND && status_is_fresh(event, now)
})
.filter(|event| event.kind.as_u16() as u64 == MESH_STATUS_KIND)
.filter(|event| {
reporter_pubkey_from_status_event(event)
.is_some_and(|reporter| members.contains(&reporter.to_ascii_lowercase()))
@@ -79,6 +86,13 @@ fn latest_membership_list(events: &[nostr::Event]) -> Option<BTreeSet<String>> {
})
}
pub(crate) fn current_member_pubkeys(events: &[nostr::Event]) -> Vec<String> {
latest_membership_list(events)
.map(BTreeSet::into_iter)
.map(Iterator::collect)
.unwrap_or_default()
}
fn owner_id_from_status_event(event: &nostr::Event) -> Option<String> {
let content = serde_json::from_str::<serde_json::Value>(&event.content).ok()?;
let owner_id = content
@@ -242,11 +256,19 @@ pub fn availability_from_events(events: Vec<nostr::Event>) -> MeshAvailability {
}
}
/// Status filter for admission and availability queries.
///
/// Deliberately has no `since` bound: status events are parameterized
/// replaceable (one per member), and admission must see a member's latest
/// owner binding even when that member has been offline for longer than
/// [`STATUS_FRESHNESS_SECS`]. Freshness is applied *after* the query, and only
/// where it belongs — routing (`availability_from_events`), never admission
/// (`owner_ids_from_events`).
pub fn mesh_status_filter() -> serde_json::Value {
serde_json::json!({
"kinds": [MESH_STATUS_KIND],
"#k": ["buzz-mesh-status"],
"limit": 100
"limit": MESH_STATUS_PAGE_SIZE
})
}
+1
View File
@@ -8,6 +8,7 @@ mod discovery;
pub use discovery::{
availability_from_events, mesh_status_filter, owner_ids_from_events, relay_membership_filter,
};
pub(crate) use discovery::{current_member_pubkeys, MESH_STATUS_PAGE_SIZE};
use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity};
mod catalog;
+29 -4
View File
@@ -388,20 +388,45 @@ fn signed_reporter_target(reporter_secret: &str, model: &str, endpoint: &str) ->
}
#[test]
fn stale_status_is_excluded_from_admission_and_availability() {
fn stale_status_keeps_member_admitted_but_excluded_from_routing() {
let secret = "8".repeat(64);
let member = nostr::Keys::parse(&secret).unwrap().public_key().to_hex();
let stale = signed_reporter_target_at(&secret, "stale-model", &test_endpoint_token(), 1_000);
// No newer event is required to age this status out. Freshness is measured
// against wall clock, so an entirely offline mesh cannot remain live forever.
// Membership is the trust boundary: a current member whose device went
// offline (stale status) must stay admitted, otherwise every app
// open/close in the community churns the allowlist and restarts serving
// nodes. Freshness still gates routing: a stale node is never selected as
// a serve target.
let membership = signed_membership_event_at(std::slice::from_ref(&member), 900);
let events = vec![stale, membership];
assert!(super::owner_ids_from_events(&events).is_empty());
assert_eq!(super::owner_ids_from_events(&events).len(), 1);
let availability = super::availability_from_events(events);
assert!(availability.serve_targets.is_empty());
}
#[test]
fn removed_member_is_dropped_from_admission_despite_fresh_status() {
// Revocation path: freshness must never resurrect trust. A reporter with a
// perfectly fresh status who is absent from the latest NIP-43 roster gets
// no admission entry.
let member_secret = "8".repeat(64);
let outsider_secret = "9".repeat(64);
let member = nostr::Keys::parse(&member_secret)
.unwrap()
.public_key()
.to_hex();
let now = nostr::Timestamp::now().as_secs();
let fresh_outsider =
signed_reporter_target_at(&outsider_secret, "model", &test_endpoint_token(), now);
// Latest roster lists only `member`; the outsider was removed (or never
// admitted).
let membership = signed_membership_event_at(std::slice::from_ref(&member), now);
let events = vec![fresh_outsider, membership];
assert!(super::owner_ids_from_events(&events).is_empty());
}
#[test]
fn one_endpoint_can_advertise_multiple_models() {
let secret = "a".repeat(64);
@@ -415,16 +415,19 @@ function CatalogPicker({
selected: string;
}) {
const [expanded, setExpanded] = React.useState(false);
// Collapsed: recommended + the next few viable entries. Expanded: all.
const visible = expanded ? catalog.entries : catalog.entries.slice(0, 4);
// Above the fold: the Buzz-curated picks (models known to work well with
// agents on shared compute). Below: everything else, as advanced options.
const curated = catalog.entries.filter((e) => e.curated);
const advanced = catalog.entries.filter((e) => !e.curated);
const visible = expanded ? catalog.entries : curated;
return (
<div className="mt-1" data-testid="mesh-share-compute-catalog">
<p className="text-sm font-normal text-muted-foreground">
Suggested for this machine
Recommended for this machine
{catalog.gpuName ? ` (${catalog.gpuName}, ` : " ("}
{catalog.vramDisplay} AI memory):
</p>
<ul className="mt-1.5 flex flex-col gap-1">
<ul className="mt-1.5 flex max-h-56 flex-col gap-1 overflow-y-auto">
{visible.map((entry) => {
const isSelected = entry.name === selected;
const tooLarge = entry.fit === "too_large";
@@ -468,15 +471,16 @@ function CatalogPicker({
);
})}
</ul>
{catalog.entries.length > visible.length || expanded ? (
{advanced.length > 0 ? (
<button
className="mt-1 text-sm text-muted-foreground underline hover:text-foreground"
data-testid="mesh-catalog-advanced-toggle"
onClick={() => setExpanded((v) => !v)}
type="button"
>
{expanded
? "Show fewer"
: `Show all ${catalog.entries.length} models`}
? "Hide advanced models"
: `Advanced: ${advanced.length} more models`}
</button>
) : null}
</div>
+6 -1
View File
@@ -68,6 +68,11 @@ export type MeshCatalogEntry = {
fit: MeshModelFit;
installed: boolean;
recommended: boolean;
/**
* Buzz-curated pick known to survive the agent harness. Curated entries
* render above the fold; everything else is "advanced".
*/
curated: boolean;
};
export type MeshModelCatalog = {
@@ -75,7 +80,7 @@ export type MeshModelCatalog = {
vramDisplay: string;
vramGb: number;
recommended: string | null;
/** Ranked: recommended first, then by fit, then larger first within a fit. */
/** Ranked: recommended first, then curated, then by fit, larger first. */
entries: MeshCatalogEntry[];
};