use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Tenacity {
Relaxed,
#[default]
Standard,
Insistent,
Relentless,
}
impl Tenacity {
pub fn read_only_nudge_after(self) -> usize {
match self {
Self::Relaxed => 6,
Self::Standard => 3,
Self::Insistent => 2,
Self::Relentless => 1,
}
}
pub fn exit_plan_requires_edit(self) -> bool {
matches!(self, Self::Insistent | Self::Relentless)
}
pub fn label(self) -> &'static str {
match self {
Self::Relaxed => "relaxed",
Self::Standard => "standard",
Self::Insistent => "insistent",
Self::Relentless => "relentless",
}
}
pub fn describe(self) -> String {
let edit = if self.exit_plan_requires_edit() {
"exit_plan_mode requires an edit"
} else {
"exit_plan_mode is advisory"
};
format!(
"force an edit after {} read-only round(s); {edit}",
self.read_only_nudge_after()
)
}
pub fn all() -> [Self; 4] {
[
Self::Relaxed,
Self::Standard,
Self::Insistent,
Self::Relentless,
]
}
}
impl fmt::Display for Tenacity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
impl FromStr for Tenacity {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"relaxed" => Ok(Self::Relaxed),
"standard" | "default" => Ok(Self::Standard),
"insistent" => Ok(Self::Insistent),
"relentless" => Ok(Self::Relentless),
other => Err(format!(
"unknown tenacity '{other}' (relaxed|standard|insistent|relentless)"
)),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TenacityConfig {
pub default: Option<Tenacity>,
pub families: std::collections::BTreeMap<String, Tenacity>,
}
impl TenacityConfig {
pub fn resolve(&self, family: Option<&str>) -> Tenacity {
family
.and_then(|f| {
self.families
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(f.trim()))
.map(|(_, v)| *v)
})
.or(self.default)
.unwrap_or_default()
}
pub fn family_for(&self, model: &str, card_family: Option<&str>) -> Option<String> {
if let Some(fam) = card_family {
let fam = fam.trim();
if self.families.keys().any(|k| k.eq_ignore_ascii_case(fam)) {
return Some(fam.to_string());
}
}
let lname = model.to_ascii_lowercase();
self.families
.keys()
.find(|k| lname.contains(&k.to_ascii_lowercase()))
.cloned()
}
}
pub fn resolve_tenacity(
cli: Option<Tenacity>,
config: Option<&TenacityConfig>,
family: Option<&str>,
) -> Tenacity {
cli.unwrap_or_else(|| config.map(|c| c.resolve(family)).unwrap_or_default())
}
static CLI_TENACITY: std::sync::Mutex<Option<Tenacity>> = std::sync::Mutex::new(None);
static TENACITY_CONFIG: std::sync::Mutex<Option<TenacityConfig>> = std::sync::Mutex::new(None);
static ACTIVE_FAMILY: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
pub fn set_cli_tenacity(level: Tenacity) {
if let Ok(mut slot) = CLI_TENACITY.lock() {
*slot = Some(level);
}
}
pub fn set_tenacity_config(config: TenacityConfig) {
if let Ok(mut slot) = TENACITY_CONFIG.lock() {
*slot = Some(config);
}
}
pub fn set_active_model_family(family: Option<String>) {
if let Ok(mut slot) = ACTIVE_FAMILY.lock() {
*slot = family;
}
}
pub fn effective_tenacity() -> Tenacity {
let cli = CLI_TENACITY.lock().ok().and_then(|s| *s);
let config = TENACITY_CONFIG.lock().ok().and_then(|s| s.clone());
let family = ACTIVE_FAMILY.lock().ok().and_then(|s| s.clone());
resolve_tenacity(cli, config.as_ref(), family.as_deref())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_is_the_behaviour_preserving_default() {
assert_eq!(Tenacity::default(), Tenacity::Standard);
assert_eq!(Tenacity::Standard.read_only_nudge_after(), 3);
assert!(!Tenacity::Standard.exit_plan_requires_edit());
}
#[test]
fn higher_tenacity_forces_action_sooner() {
let budgets: Vec<usize> = Tenacity::all()
.iter()
.map(|t| t.read_only_nudge_after())
.collect();
assert_eq!(budgets, vec![6, 3, 2, 1]);
for pair in budgets.windows(2) {
assert!(pair[0] > pair[1], "budget must strictly drop with tenacity");
}
}
#[test]
fn only_the_two_most_forcing_levels_require_an_edit_on_plan_exit() {
assert!(!Tenacity::Relaxed.exit_plan_requires_edit());
assert!(!Tenacity::Standard.exit_plan_requires_edit());
assert!(Tenacity::Insistent.exit_plan_requires_edit());
assert!(Tenacity::Relentless.exit_plan_requires_edit());
}
#[test]
fn parse_is_case_insensitive_and_round_trips_the_label() {
for t in Tenacity::all() {
assert_eq!(t.label().parse::<Tenacity>().unwrap(), t);
assert_eq!(t.to_string(), t.label());
}
assert_eq!(
" RELENTLESS ".parse::<Tenacity>().unwrap(),
Tenacity::Relentless
);
assert_eq!("default".parse::<Tenacity>().unwrap(), Tenacity::Standard);
assert!("banana".parse::<Tenacity>().is_err());
}
#[test]
fn ordering_runs_patient_to_forcing() {
assert!(Tenacity::Relaxed < Tenacity::Standard);
assert!(Tenacity::Standard < Tenacity::Insistent);
assert!(Tenacity::Insistent < Tenacity::Relentless);
}
#[test]
fn serde_uses_the_lowercase_label() {
let json = serde_json::to_string(&Tenacity::Insistent).unwrap();
assert_eq!(json, "\"insistent\"");
let back: Tenacity = serde_json::from_str("\"relentless\"").unwrap();
assert_eq!(back, Tenacity::Relentless);
}
fn cfg(default: Option<Tenacity>, fams: &[(&str, Tenacity)]) -> TenacityConfig {
TenacityConfig {
default,
families: fams.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
}
}
#[test]
fn config_resolve_prefers_family_then_default_then_standard() {
let c = cfg(
Some(Tenacity::Relaxed),
&[("nemotron", Tenacity::Relentless)],
);
assert_eq!(c.resolve(Some("nemotron")), Tenacity::Relentless);
assert_eq!(c.resolve(Some(" NEMOTRON ")), Tenacity::Relentless);
assert_eq!(c.resolve(Some("qwen3")), Tenacity::Relaxed);
assert_eq!(c.resolve(None), Tenacity::Relaxed);
assert_eq!(
TenacityConfig::default().resolve(Some("qwen3")),
Tenacity::Standard
);
assert_eq!(cfg(None, &[]).resolve(None), Tenacity::Standard);
}
#[test]
fn resolve_tenacity_lets_the_cli_flag_win_over_config() {
let c = cfg(
Some(Tenacity::Relaxed),
&[("nemotron", Tenacity::Relentless)],
);
assert_eq!(
resolve_tenacity(Some(Tenacity::Standard), Some(&c), Some("nemotron")),
Tenacity::Standard
);
assert_eq!(
resolve_tenacity(None, Some(&c), Some("nemotron")),
Tenacity::Relentless
);
assert_eq!(
resolve_tenacity(None, Some(&c), Some("kimi")),
Tenacity::Relaxed
);
assert_eq!(
resolve_tenacity(None, None, Some("nemotron")),
Tenacity::Standard
);
assert_eq!(
resolve_tenacity(Some(Tenacity::Insistent), None, None),
Tenacity::Insistent
);
}
#[test]
fn family_for_prefers_card_then_infers_from_the_model_name() {
let c = cfg(
None,
&[
("qwen3", Tenacity::Standard),
("nemotron", Tenacity::Relentless),
],
);
assert_eq!(
c.family_for("whatever", Some("qwen3")).as_deref(),
Some("qwen3")
);
assert_eq!(
c.family_for("qwen3-coder_30b", None).as_deref(),
Some("qwen3")
);
assert_eq!(
c.family_for("NVIDIA-Nemotron-3-Nano", None).as_deref(),
Some("nemotron")
);
assert_eq!(c.family_for("gemma-2-9b", None), None);
assert_eq!(
c.family_for("qwen3-coder_30b", Some("unlisted")).as_deref(),
Some("qwen3")
);
let fam = c.family_for("qwen3-coder_30b", None);
assert_eq!(c.resolve(fam.as_deref()), Tenacity::Standard);
}
#[test]
fn tenacity_config_parses_from_toml() {
let c: TenacityConfig = toml::from_str(
r#"
default = "insistent"
[families]
nemotron = "relentless"
qwen3 = "standard"
"#,
)
.unwrap();
assert_eq!(c.default, Some(Tenacity::Insistent));
assert_eq!(c.resolve(Some("nemotron")), Tenacity::Relentless);
assert_eq!(c.resolve(Some("qwen3")), Tenacity::Standard);
assert_eq!(c.resolve(Some("gemma")), Tenacity::Insistent);
}
}