use pounce_common::options_list::OptionsList;
use pounce_nlp::SolveStatistics;
use pounce_nlp::return_codes::ApplicationReturnStatus;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecondOpinionRung {
pub label: &'static str,
pub assignments: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecondOpinionTrigger {
LocalInfeasibility,
InvalidNumber,
RestorationFailure,
IterationLimit,
}
#[derive(Debug, Clone, Copy)]
pub struct SecondOpinionAvailability {
pub trigger: SecondOpinionTrigger,
pub scaling_retry_enabled: bool,
pub mu_retry_enabled: bool,
pub perturbed_start_retry_enabled: bool,
pub already_mc64: bool,
pub already_adaptive: bool,
pub already_perturbed: bool,
pub increase_quality_retry_enabled: bool,
pub already_no_increase_quality: bool,
pub baseline_quality_escalations: u64,
pub baseline_scaling: Option<&'static str>,
}
pub fn second_opinion_rungs(avail: SecondOpinionAvailability) -> Vec<SecondOpinionRung> {
let mut rungs = Vec::new();
let infeasible = avail.trigger == SecondOpinionTrigger::LocalInfeasibility;
if infeasible && avail.scaling_retry_enabled && !avail.already_mc64 {
rungs.push(SecondOpinionRung {
label: "feral_scaling=mc64",
assignments: vec!["feral_scaling mc64\n".to_string()],
});
}
if avail.baseline_scaling.is_some()
&& infeasible
&& avail.mu_retry_enabled
&& !avail.already_adaptive
{
rungs.push(SecondOpinionRung {
label: "mu_strategy=adaptive",
assignments: vec!["mu_strategy adaptive\n".to_string()],
});
}
if avail.baseline_scaling.is_some()
&& avail.trigger != SecondOpinionTrigger::IterationLimit
&& avail.perturbed_start_retry_enabled
&& !avail.already_perturbed
{
rungs.push(SecondOpinionRung {
label: "start_point_perturbation=1e-2",
assignments: vec!["start_point_perturbation 1e-2\n".to_string()],
});
}
if matches!(
avail.trigger,
SecondOpinionTrigger::RestorationFailure
| SecondOpinionTrigger::IterationLimit
| SecondOpinionTrigger::LocalInfeasibility
) && avail.increase_quality_retry_enabled
&& !avail.already_no_increase_quality
&& avail.baseline_quality_escalations >= 1
{
rungs.push(SecondOpinionRung {
label: "feral_increase_quality=no",
assignments: vec!["feral_increase_quality no\n".to_string()],
});
}
rungs
}
pub fn narration_is_wanted(options: &OptionsList) -> bool {
options
.get_integer_value("print_level", "")
.map(|(level, found)| !found || level >= 1)
.unwrap_or(true)
}
pub fn scaling_retry_promoted(retry_status: ApplicationReturnStatus) -> bool {
matches!(
retry_status,
ApplicationReturnStatus::SolveSucceeded | ApplicationReturnStatus::SolvedToAcceptableLevel
)
}
pub fn resolve_scaling_retry_outcome(
original_status: ApplicationReturnStatus,
retry_status: ApplicationReturnStatus,
original_stats: SolveStatistics,
retry_stats: SolveStatistics,
) -> (ApplicationReturnStatus, SolveStatistics) {
if scaling_retry_promoted(retry_status) {
(retry_status, retry_stats)
} else {
(original_status, original_stats)
}
}
impl SecondOpinionTrigger {
pub fn for_status(status: ApplicationReturnStatus) -> Option<Self> {
match status {
ApplicationReturnStatus::InfeasibleProblemDetected => {
Some(SecondOpinionTrigger::LocalInfeasibility)
}
ApplicationReturnStatus::InvalidNumberDetected => {
Some(SecondOpinionTrigger::InvalidNumber)
}
ApplicationReturnStatus::RestorationFailed => {
Some(SecondOpinionTrigger::RestorationFailure)
}
ApplicationReturnStatus::MaximumIterationsExceeded => {
Some(SecondOpinionTrigger::IterationLimit)
}
_ => None,
}
}
pub fn describe(self) -> &'static str {
match self {
SecondOpinionTrigger::LocalInfeasibility => "local infeasibility",
SecondOpinionTrigger::InvalidNumber => "invalid number",
SecondOpinionTrigger::RestorationFailure => "restoration failure",
SecondOpinionTrigger::IterationLimit => {
"iteration limit after a factorization escalation"
}
}
}
}
impl SecondOpinionAvailability {
pub fn from_options(
options: &OptionsList,
trigger: SecondOpinionTrigger,
baseline_quality_escalations: u64,
) -> Self {
let scaling = crate::application::feral_config_from_options(options).scaling;
let baseline_scaling = match scaling {
pounce_feral::ScalingStrategy::Auto => Some("auto"),
pounce_feral::ScalingStrategy::InfNorm => Some("infnorm"),
pounce_feral::ScalingStrategy::Mc64Symmetric => Some("mc64"),
pounce_feral::ScalingStrategy::Identity => Some("identity"),
pounce_feral::ScalingStrategy::External(_) => None,
};
let already_adaptive = options
.get_string_value("mu_strategy", "")
.map(|(v, _found)| v == "adaptive")
.unwrap_or(false);
Self {
trigger,
scaling_retry_enabled: options
.get_bool_value("feral_infeasibility_scaling_retry", "")
.map(|(v, _found)| v)
.unwrap_or(true),
mu_retry_enabled: options
.get_bool_value("infeasibility_mu_strategy_retry", "")
.map(|(v, _found)| v)
.unwrap_or(true),
perturbed_start_retry_enabled: options
.get_bool_value("infeasibility_perturbed_start_retry", "")
.map(|(v, _found)| v)
.unwrap_or(true),
already_mc64: matches!(scaling, pounce_feral::ScalingStrategy::Mc64Symmetric),
already_adaptive,
already_perturbed: options
.get_numeric_value("start_point_perturbation", "")
.map(|(v, _found)| v > 0.0)
.unwrap_or(false),
increase_quality_retry_enabled: options
.get_bool_value("feral_increase_quality_retry", "")
.map(|(v, _found)| v)
.unwrap_or(true),
already_no_increase_quality: options
.get_bool_value("feral_increase_quality", "")
.map(|(v, _found)| !v)
.unwrap_or(false),
baseline_quality_escalations,
baseline_scaling,
}
}
}
#[cfg(test)]
mod scaling_retry_tests {
use super::{
SecondOpinionAvailability, SecondOpinionTrigger, narration_is_wanted,
resolve_scaling_retry_outcome, scaling_retry_promoted, second_opinion_rungs,
};
use pounce_common::options_list::OptionsList;
use pounce_nlp::SolveStatistics;
use pounce_nlp::return_codes::ApplicationReturnStatus;
fn avail() -> SecondOpinionAvailability {
SecondOpinionAvailability {
trigger: SecondOpinionTrigger::LocalInfeasibility,
scaling_retry_enabled: true,
mu_retry_enabled: true,
perturbed_start_retry_enabled: true,
already_mc64: false,
already_adaptive: false,
already_perturbed: false,
increase_quality_retry_enabled: true,
already_no_increase_quality: false,
baseline_quality_escalations: 0,
baseline_scaling: Some("auto"),
}
}
#[test]
fn default_ladder_is_scaling_then_barrier_strategy_then_start() {
let rungs = second_opinion_rungs(avail());
let labels: Vec<_> = rungs.iter().map(|r| r.label).collect();
assert_eq!(
labels,
[
"feral_scaling=mc64",
"mu_strategy=adaptive",
"start_point_perturbation=1e-2"
]
);
}
#[test]
fn barrier_rung_varies_only_the_barrier_strategy() {
for baseline in ["auto", "infnorm"] {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
baseline_scaling: Some(baseline),
..avail()
});
let barrier = rungs
.iter()
.find(|r| r.label == "mu_strategy=adaptive")
.expect("barrier rung present");
let assigned: Vec<_> = barrier.assignments.iter().map(|a| a.trim()).collect();
assert_eq!(assigned, ["mu_strategy adaptive"]);
}
}
#[test]
fn rungs_already_satisfied_at_baseline_are_dropped() {
let only_barrier = second_opinion_rungs(SecondOpinionAvailability {
already_mc64: true,
..avail()
});
assert_eq!(
only_barrier.iter().map(|r| r.label).collect::<Vec<_>>(),
["mu_strategy=adaptive", "start_point_perturbation=1e-2"],
);
let only_scaling = second_opinion_rungs(SecondOpinionAvailability {
already_adaptive: true,
..avail()
});
assert_eq!(
only_scaling.iter().map(|r| r.label).collect::<Vec<_>>(),
["feral_scaling=mc64", "start_point_perturbation=1e-2"],
);
assert!(
second_opinion_rungs(SecondOpinionAvailability {
already_mc64: true,
already_adaptive: true,
already_perturbed: true,
..avail()
})
.is_empty(),
"nothing left to vary means no ladder at all",
);
}
#[test]
fn barrier_rung_is_dropped_when_the_baseline_scaling_has_no_tag() {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
baseline_scaling: None,
..avail()
});
assert_eq!(
rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
["feral_scaling=mc64"],
);
}
#[test]
fn each_rung_can_be_disabled_independently() {
assert_eq!(
second_opinion_rungs(SecondOpinionAvailability {
scaling_retry_enabled: false,
..avail()
})
.iter()
.map(|r| r.label)
.collect::<Vec<_>>(),
["mu_strategy=adaptive", "start_point_perturbation=1e-2"],
);
assert_eq!(
second_opinion_rungs(SecondOpinionAvailability {
mu_retry_enabled: false,
..avail()
})
.iter()
.map(|r| r.label)
.collect::<Vec<_>>(),
["feral_scaling=mc64", "start_point_perturbation=1e-2"],
);
assert_eq!(
second_opinion_rungs(SecondOpinionAvailability {
perturbed_start_retry_enabled: false,
..avail()
})
.iter()
.map(|r| r.label)
.collect::<Vec<_>>(),
["feral_scaling=mc64", "mu_strategy=adaptive"],
);
assert!(
second_opinion_rungs(SecondOpinionAvailability {
scaling_retry_enabled: false,
mu_retry_enabled: false,
perturbed_start_retry_enabled: false,
..avail()
})
.is_empty(),
);
}
#[test]
fn start_rung_assigns_only_the_displacement() {
for baseline_scaling in ["auto", "infnorm"] {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
baseline_scaling: Some(baseline_scaling),
..avail()
});
let start = rungs
.iter()
.find(|r| r.label == "start_point_perturbation=1e-2")
.expect("start rung present");
let assigned: Vec<_> = start.assignments.iter().map(|a| a.trim()).collect();
assert_eq!(assigned, ["start_point_perturbation 1e-2"]);
}
}
#[test]
fn no_rung_writes_back_a_knob_it_does_not_vary() {
for avail in [
avail(),
SecondOpinionAvailability {
already_adaptive: true,
..avail()
},
SecondOpinionAvailability {
trigger: SecondOpinionTrigger::InvalidNumber,
..avail()
},
] {
for rung in second_opinion_rungs(avail) {
let varies = rung.label.split('=').next().expect("label has a tag");
for a in &rung.assignments {
let tag = a.trim().split_whitespace().next().expect("tag");
assert_eq!(
tag,
varies,
"rung `{}` writes `{}`, which it does not vary",
rung.label,
a.trim(),
);
}
}
}
}
#[test]
fn start_rung_is_dropped_when_the_baseline_scaling_has_no_tag() {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
baseline_scaling: None,
..avail()
});
assert!(
!rungs
.iter()
.any(|r| r.label == "start_point_perturbation=1e-2"),
"{:?}",
rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
);
}
#[test]
fn narration_follows_print_level() {
let mut opts = OptionsList::new();
assert!(narration_is_wanted(&opts));
for (level, want) in [(0, false), (1, true), (5, true), (12, true)] {
opts.set_integer_value("print_level", level, true, true)
.unwrap();
assert_eq!(narration_is_wanted(&opts), want, "print_level={level}");
}
}
#[test]
fn an_invalid_number_reaches_only_the_start_rung() {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
trigger: SecondOpinionTrigger::InvalidNumber,
..avail()
});
assert_eq!(
rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
["start_point_perturbation=1e-2"],
);
}
#[test]
fn a_restoration_failure_reaches_only_the_start_rung() {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
trigger: SecondOpinionTrigger::RestorationFailure,
..avail()
});
assert_eq!(
rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
["start_point_perturbation=1e-2"],
);
}
#[test]
fn an_escalating_restoration_failure_appends_the_quality_rung() {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
trigger: SecondOpinionTrigger::RestorationFailure,
baseline_quality_escalations: 2,
..avail()
});
assert_eq!(
rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
["start_point_perturbation=1e-2", "feral_increase_quality=no",],
);
}
#[test]
fn an_escalating_budget_exit_reaches_only_the_quality_rung() {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
trigger: SecondOpinionTrigger::IterationLimit,
baseline_quality_escalations: 25,
..avail()
});
assert_eq!(
rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
["feral_increase_quality=no"],
);
assert_eq!(rungs[0].assignments, ["feral_increase_quality no\n"]);
}
#[test]
fn an_escalating_infeasibility_verdict_appends_the_quality_rung() {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
trigger: SecondOpinionTrigger::LocalInfeasibility,
baseline_quality_escalations: 25,
..avail()
});
assert_eq!(
rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
[
"feral_scaling=mc64",
"mu_strategy=adaptive",
"start_point_perturbation=1e-2",
"feral_increase_quality=no",
],
);
}
#[test]
fn an_infeasibility_verdict_that_never_escalated_gets_no_quality_rung() {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
trigger: SecondOpinionTrigger::LocalInfeasibility,
baseline_quality_escalations: 0,
..avail()
});
assert_eq!(
rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
[
"feral_scaling=mc64",
"mu_strategy=adaptive",
"start_point_perturbation=1e-2",
],
);
}
#[test]
fn a_budget_exit_that_never_escalated_opens_no_rung() {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
trigger: SecondOpinionTrigger::IterationLimit,
baseline_quality_escalations: 0,
..avail()
});
assert!(
rungs.is_empty(),
"a budget exit with no escalation has nothing for the ladder to \
test, and must not pay for a solve: {:?}",
rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
);
}
#[test]
fn a_budget_exit_does_not_reach_the_displacement_rung() {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
trigger: SecondOpinionTrigger::IterationLimit,
baseline_quality_escalations: 7,
..avail()
});
assert!(
!rungs
.iter()
.any(|r| r.label == "start_point_perturbation=1e-2"),
"{:?}",
rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
);
}
#[test]
fn the_quality_rung_is_droppable_and_never_a_no_op() {
let escalating = SecondOpinionAvailability {
trigger: SecondOpinionTrigger::IterationLimit,
baseline_quality_escalations: 3,
..avail()
};
assert!(
second_opinion_rungs(SecondOpinionAvailability {
increase_quality_retry_enabled: false,
..escalating
})
.is_empty()
);
assert!(
second_opinion_rungs(SecondOpinionAvailability {
already_no_increase_quality: true,
..escalating
})
.is_empty()
);
}
#[test]
fn only_path_verdicts_open_a_ladder() {
use ApplicationReturnStatus as A;
for (status, want) in [
(
A::InfeasibleProblemDetected,
Some(SecondOpinionTrigger::LocalInfeasibility),
),
(
A::InvalidNumberDetected,
Some(SecondOpinionTrigger::InvalidNumber),
),
(
A::RestorationFailed,
Some(SecondOpinionTrigger::RestorationFailure),
),
(
A::MaximumIterationsExceeded,
Some(SecondOpinionTrigger::IterationLimit),
),
(A::MaximumCpuTimeExceeded, None),
(A::SolveSucceeded, None),
(A::SolvedToAcceptableLevel, None),
(A::ErrorInStepComputation, None),
] {
assert_eq!(
SecondOpinionTrigger::for_status(status),
want,
"{status:?} opened the wrong ladder"
);
}
}
#[test]
fn an_invalid_number_with_the_start_rung_off_has_no_ladder() {
assert!(
second_opinion_rungs(SecondOpinionAvailability {
trigger: SecondOpinionTrigger::InvalidNumber,
perturbed_start_retry_enabled: false,
..avail()
})
.is_empty(),
);
}
#[test]
fn a_baseline_that_already_perturbs_drops_the_start_rung() {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
already_perturbed: true,
..avail()
});
assert_eq!(
rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
["feral_scaling=mc64", "mu_strategy=adaptive"],
);
}
#[test]
fn a_failed_ladder_keeps_whichever_verdict_opened_it() {
for original in [
ApplicationReturnStatus::InfeasibleProblemDetected,
ApplicationReturnStatus::InvalidNumberDetected,
] {
let (status, stats) = resolve_scaling_retry_outcome(
original,
ApplicationReturnStatus::MaximumIterationsExceeded,
stats_with_iters(7),
stats_with_iters(42),
);
assert_eq!(status, original);
assert_eq!(stats.iteration_count, 7);
}
}
fn stats_with_iters(n: i32) -> SolveStatistics {
SolveStatistics {
iteration_count: n,
final_objective: n as f64,
..SolveStatistics::default()
}
}
#[test]
fn failed_retry_keeps_original_status_and_stats() {
let original = stats_with_iters(7);
let retry = stats_with_iters(42);
for retry_status in [
ApplicationReturnStatus::InfeasibleProblemDetected,
ApplicationReturnStatus::MaximumIterationsExceeded,
ApplicationReturnStatus::RestorationFailed,
] {
assert!(!scaling_retry_promoted(retry_status));
let (status, stats) = resolve_scaling_retry_outcome(
ApplicationReturnStatus::InfeasibleProblemDetected,
retry_status,
original.clone(),
retry.clone(),
);
assert_eq!(
status,
ApplicationReturnStatus::InfeasibleProblemDetected,
"a non-promoting retry ({retry_status:?}) keeps the original verdict"
);
assert_eq!(
stats.iteration_count, 7,
"stats must stay the original solve's, not the failed retry's"
);
assert_eq!(stats.final_objective, 7.0);
}
}
#[test]
fn promoted_retry_adopts_retry_status_and_stats() {
let original = stats_with_iters(7);
let retry = stats_with_iters(42);
for retry_status in [
ApplicationReturnStatus::SolveSucceeded,
ApplicationReturnStatus::SolvedToAcceptableLevel,
] {
assert!(scaling_retry_promoted(retry_status));
let (status, stats) = resolve_scaling_retry_outcome(
ApplicationReturnStatus::InfeasibleProblemDetected,
retry_status,
original.clone(),
retry.clone(),
);
assert_eq!(status, retry_status, "a promoting retry adopts its verdict");
assert_eq!(
stats.iteration_count, 42,
"promoted: stats must be the retry solve's"
);
assert_eq!(stats.final_objective, 42.0);
}
}
}