use crate::types::{Equation, Expr, Model, ModelVariable, VariableType};
use indexmap::IndexMap;
use std::collections::{BTreeMap, BTreeSet};
const TIME: &str = "t";
const SPATIAL_SUGAR_OPS: [&str; 3] = ["grad", "div", "laplacian"];
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum SystemKind {
Sde,
Pde,
Nonlinear,
#[default]
Ode,
}
impl SystemKind {
pub fn as_str(self) -> &'static str {
match self {
SystemKind::Sde => "sde",
SystemKind::Pde => "pde",
SystemKind::Nonlinear => "nonlinear",
SystemKind::Ode => "ode",
}
}
}
impl std::fmt::Display for SystemKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Default)]
pub struct Classification {
pub ode_states: Vec<String>,
pub observed_unknowns: Vec<String>,
pub inlined_unknowns: Vec<String>,
pub algebraic_unknowns: Vec<String>,
pub brownian_parameters: Vec<String>,
pub discrete_parameters: Vec<String>,
pub sampled_parameters: Vec<String>,
pub constant_parameters: Vec<String>,
pub system_kind: SystemKind,
pub observed_definitions: BTreeMap<String, Expr>,
}
impl Classification {
pub fn of(model: &Model) -> Classification {
Self::from_parts(&model.variables, &model.equations)
}
pub fn from_parts(
variables: &IndexMap<String, ModelVariable>,
equations: &[Equation],
) -> Classification {
let UnknownPartition {
unknowns,
ode_states,
observed,
inlined,
observed_definitions,
} = UnknownPartition::of(variables, equations);
let algebraic: Vec<String> = unknowns
.iter()
.filter(|n| ode_states.binary_search(n).is_err() && !observed.contains(*n))
.map(|n| (*n).to_string())
.collect();
let ParameterPartition {
brownian,
discrete,
sampled,
constant,
} = ParameterPartition::of(variables);
let owned =
|names: Vec<&str>| -> Vec<String> { names.into_iter().map(str::to_string).collect() };
let (brownian, discrete, sampled, constant) = (
owned(brownian),
owned(discrete),
owned(sampled),
owned(constant),
);
let system_kind = derive_system_kind(&brownian, equations, &ode_states);
Classification {
ode_states: ode_states.into_iter().map(str::to_string).collect(),
observed_unknowns: observed.into_iter().map(str::to_string).collect(),
inlined_unknowns: inlined.into_iter().map(str::to_string).collect(),
algebraic_unknowns: algebraic,
brownian_parameters: brownian,
discrete_parameters: discrete,
sampled_parameters: sampled,
constant_parameters: constant,
system_kind,
observed_definitions: owned_definitions(observed_definitions),
}
}
pub fn from_json(model: &serde_json::Value) -> Result<Classification, serde_json::Error> {
let variables: IndexMap<String, ModelVariable> = match model.get("variables") {
Some(v) => serde_json::from_value(v.clone())?,
None => IndexMap::new(),
};
let equations: Vec<Equation> = model
.get("equations")
.and_then(|v| v.as_array())
.map(|eqs| {
eqs.iter()
.filter_map(|eq| serde_json::from_value::<Equation>(eq.clone()).ok())
.collect()
})
.unwrap_or_default();
Ok(Classification::from_parts(&variables, &equations))
}
pub fn is_ode_state(&self, name: &str) -> bool {
sorted_contains(&self.ode_states, name)
}
pub fn is_observed(&self, name: &str) -> bool {
sorted_contains(&self.observed_unknowns, name)
}
pub fn is_inlined(&self, name: &str) -> bool {
sorted_contains(&self.inlined_unknowns, name)
}
pub fn is_brownian(&self, name: &str) -> bool {
sorted_contains(&self.brownian_parameters, name)
}
pub fn is_discrete_parameter(&self, name: &str) -> bool {
sorted_contains(&self.discrete_parameters, name)
}
pub fn unknowns(&self) -> Vec<String> {
let mut all: Vec<String> = self
.ode_states
.iter()
.chain(&self.observed_unknowns)
.chain(&self.algebraic_unknowns)
.cloned()
.collect();
all.sort();
all
}
pub fn parameters(&self) -> Vec<String> {
let mut all: Vec<String> = self
.brownian_parameters
.iter()
.chain(&self.discrete_parameters)
.chain(&self.sampled_parameters)
.chain(&self.constant_parameters)
.cloned()
.collect();
all.sort();
all
}
pub fn assert_partitions(&self, model: &Model) -> Result<(), String> {
let declared_unknowns: BTreeSet<&str> = model
.variables
.iter()
.filter(|(_, v)| v.var_type == VariableType::Unknown)
.map(|(k, _)| k.as_str())
.collect();
let declared_parameters: BTreeSet<&str> = model
.variables
.iter()
.filter(|(_, v)| v.var_type == VariableType::Parameter)
.map(|(k, _)| k.as_str())
.collect();
let unknown_total: usize =
self.ode_states.len() + self.observed_unknowns.len() + self.algebraic_unknowns.len();
let unknown_union: BTreeSet<&str> = self
.ode_states
.iter()
.chain(&self.observed_unknowns)
.chain(&self.algebraic_unknowns)
.map(String::as_str)
.collect();
if unknown_union.len() != unknown_total {
return Err("unknown sets overlap: ode_states / observed_unknowns / \
algebraic_unknowns must be disjoint"
.to_string());
}
if unknown_union != declared_unknowns {
return Err(format!(
"unknown sets do not cover the declared unknowns: got {unknown_union:?}, \
declared {declared_unknowns:?}"
));
}
let param_total: usize = self.brownian_parameters.len()
+ self.discrete_parameters.len()
+ self.sampled_parameters.len()
+ self.constant_parameters.len();
let param_union: BTreeSet<&str> = self
.brownian_parameters
.iter()
.chain(&self.discrete_parameters)
.chain(&self.sampled_parameters)
.chain(&self.constant_parameters)
.map(String::as_str)
.collect();
if param_union.len() != param_total {
return Err(
"parameter sets overlap: brownian / discrete / sampled / constant \
must be disjoint"
.to_string(),
);
}
if param_union != declared_parameters {
return Err(format!(
"parameter sets do not cover the declared parameters: got {param_union:?}, \
declared {declared_parameters:?}"
));
}
Ok(())
}
}
pub fn ode_states(model: &Model) -> Vec<String> {
Classification::of(model).ode_states
}
pub fn observed_unknowns(model: &Model) -> Vec<String> {
Classification::of(model).observed_unknowns
}
pub fn inlined_unknowns(model: &Model) -> Vec<String> {
Classification::of(model).inlined_unknowns
}
pub fn algebraic_unknowns(model: &Model) -> Vec<String> {
Classification::of(model).algebraic_unknowns
}
pub fn is_ode_state(model: &Model, name: &str) -> bool {
Classification::of(model).is_ode_state(name)
}
pub fn brownian_parameters(model: &Model) -> Vec<String> {
Classification::of(model).brownian_parameters
}
pub fn discrete_parameters(model: &Model) -> Vec<String> {
Classification::of(model).discrete_parameters
}
pub fn sampled_parameters(model: &Model) -> Vec<String> {
Classification::of(model).sampled_parameters
}
pub fn constant_parameters(model: &Model) -> Vec<String> {
Classification::of(model).constant_parameters
}
pub fn system_kind(model: &Model) -> SystemKind {
Classification::of(model).system_kind
}
pub fn observed_definitions(model: &Model) -> BTreeMap<String, Expr> {
observed_definitions_of_parts(&model.variables, &model.equations)
}
pub(crate) fn first_observed_cycle(
deps: &BTreeMap<String, BTreeSet<String>>,
) -> Option<Vec<String>> {
const ON_CHAIN: u8 = 1;
const FINISHED: u8 = 2;
let mut state: BTreeMap<&str, u8> = BTreeMap::new();
for root in deps.keys() {
if state.contains_key(root.as_str()) {
continue;
}
state.insert(root.as_str(), ON_CHAIN);
let mut chain: Vec<&str> = vec![root.as_str()];
let mut cursors: Vec<std::collections::btree_set::Iter<'_, String>> =
vec![empty_successors(deps, root.as_str())];
while let Some(&name) = chain.last() {
match cursors.last_mut().and_then(Iterator::next) {
None => {
state.insert(name, FINISHED);
chain.pop();
cursors.pop();
}
Some(successor) => match state.get(successor.as_str()).copied() {
Some(ON_CHAIN) => {
let start = chain
.iter()
.position(|c| *c == successor.as_str())
.unwrap_or(0);
let mut cycle: Vec<String> =
chain[start..].iter().map(|s| (*s).to_string()).collect();
cycle.push(successor.clone());
return Some(cycle);
}
Some(_) => {}
None => {
state.insert(successor.as_str(), ON_CHAIN);
chain.push(successor.as_str());
cursors.push(empty_successors(deps, successor.as_str()));
}
},
}
}
}
None
}
fn empty_successors<'a>(
deps: &'a BTreeMap<String, BTreeSet<String>>,
name: &str,
) -> std::collections::btree_set::Iter<'a, String> {
static EMPTY: std::sync::OnceLock<BTreeSet<String>> = std::sync::OnceLock::new();
deps.get(name)
.unwrap_or_else(|| EMPTY.get_or_init(BTreeSet::new))
.iter()
}
pub fn observed_definition_json<'a>(
model: &'a serde_json::Value,
name: &str,
) -> Option<&'a serde_json::Value> {
model
.get("equations")?
.as_array()?
.iter()
.find(|eq| eq.get("lhs").and_then(serde_json::Value::as_str) == Some(name))?
.get("rhs")
}
fn sorted_contains(names: &[String], name: &str) -> bool {
names.binary_search_by(|s| s.as_str().cmp(name)).is_ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LhsForm {
Derivative(String),
Bare(String),
Expression,
}
pub fn lhs_form(lhs: &Expr) -> LhsForm {
match lhs_form_ref(lhs) {
LhsFormRef::Derivative(name) => LhsForm::Derivative(name.to_string()),
LhsFormRef::Bare(name) => LhsForm::Bare(name.to_string()),
LhsFormRef::Expression => LhsForm::Expression,
}
}
pub(crate) enum LhsFormRef<'a> {
Derivative(&'a str),
Bare(&'a str),
Expression,
}
pub(crate) fn lhs_form_ref(lhs: &Expr) -> LhsFormRef<'_> {
match lhs {
Expr::Variable(name) => LhsFormRef::Bare(name),
Expr::Operator(node) => match node.op.as_str() {
"D" if node.wrt.as_deref().unwrap_or(TIME) == TIME => node
.args
.first()
.and_then(base_variable)
.map(LhsFormRef::Derivative)
.unwrap_or(LhsFormRef::Expression),
"faq" => node
.expr
.as_deref()
.map(lhs_form_ref)
.unwrap_or(LhsFormRef::Expression),
"index" => node
.args
.first()
.and_then(base_variable)
.map(LhsFormRef::Bare)
.unwrap_or(LhsFormRef::Expression),
_ => LhsFormRef::Expression,
},
Expr::Integer(_) | Expr::Number(_) => LhsFormRef::Expression,
}
}
fn base_variable(expr: &Expr) -> Option<&str> {
match expr {
Expr::Variable(name) => Some(name),
Expr::Operator(node) => match node.op.as_str() {
"index" | "broadcast" => node.args.first().and_then(base_variable),
"faq" => node
.expr
.as_deref()
.and_then(base_variable)
.or_else(|| node.args.first().and_then(base_variable)),
_ => None,
},
_ => None,
}
}
struct UnknownPartition<'a> {
unknowns: Vec<&'a str>,
ode_states: Vec<&'a str>,
observed: BTreeSet<&'a str>,
inlined: BTreeSet<&'a str>,
observed_definitions: BTreeMap<&'a str, &'a Expr>,
}
impl<'a> UnknownPartition<'a> {
fn of(variables: &'a IndexMap<String, ModelVariable>, equations: &'a [Equation]) -> Self {
let mut ode_states = Vec::new();
let mut observed = BTreeSet::new();
let mut inlined = BTreeSet::new();
let mut observed_definitions = BTreeMap::new();
let mut unknowns: Vec<&str> = variables
.iter()
.filter(|(_, v)| v.var_type == VariableType::Unknown)
.map(|(k, _)| k.as_str())
.collect();
unknowns.sort_unstable();
let is_unknown = |name: &str| unknowns.binary_search(&name).is_ok();
for eq in equations {
match lhs_form_ref(&eq.lhs) {
LhsFormRef::Derivative(name) => {
if is_unknown(name) {
ode_states.push(name);
}
}
LhsFormRef::Bare(name) => {
if is_unknown(name) {
if !observed.contains(name) {
observed_definitions.insert(name, &eq.rhs);
}
if matches!(eq.lhs, Expr::Variable(_)) {
inlined.insert(name);
}
observed.insert(name);
}
}
LhsFormRef::Expression => {}
}
}
ode_states.sort_unstable();
ode_states.dedup();
for name in &ode_states {
observed.remove(name);
inlined.remove(name);
observed_definitions.remove(name);
}
UnknownPartition {
unknowns,
ode_states,
observed,
inlined,
observed_definitions,
}
}
}
struct ParameterPartition<'a> {
brownian: Vec<&'a str>,
discrete: Vec<&'a str>,
sampled: Vec<&'a str>,
constant: Vec<&'a str>,
}
impl<'a> ParameterPartition<'a> {
fn of(variables: &'a IndexMap<String, ModelVariable>) -> Self {
let mut p = ParameterPartition {
brownian: Vec::new(),
discrete: Vec::new(),
sampled: Vec::new(),
constant: Vec::new(),
};
for (name, var) in variables {
if var.var_type != VariableType::Parameter {
continue;
}
let set = match (&var.update, &var.distribution) {
(Some(update), _) if update.is_wiener() => &mut p.brownian,
(Some(_), _) => &mut p.discrete,
(None, Some(_)) => &mut p.sampled,
(None, None) => &mut p.constant,
};
set.push(name.as_str());
}
for set in [
&mut p.brownian,
&mut p.discrete,
&mut p.sampled,
&mut p.constant,
] {
set.sort_unstable();
}
p
}
}
pub(crate) struct VariableRoles<'a> {
observed: BTreeSet<&'a str>,
parameters: ParameterPartition<'a>,
}
impl<'a> VariableRoles<'a> {
pub(crate) fn of(model: &'a Model) -> Self {
let observed = if defines_by_name(&model.equations) {
UnknownPartition::of(&model.variables, &model.equations).observed
} else {
BTreeSet::new()
};
VariableRoles {
observed,
parameters: ParameterPartition::of(&model.variables),
}
}
pub(crate) fn is_observed(&self, name: &str) -> bool {
self.observed.contains(name)
}
pub(crate) fn is_brownian(&self, name: &str) -> bool {
self.parameters.brownian.binary_search(&name).is_ok()
}
pub(crate) fn is_discrete_parameter(&self, name: &str) -> bool {
self.parameters.discrete.binary_search(&name).is_ok()
}
}
pub(crate) fn observed_definitions_of_parts(
variables: &IndexMap<String, ModelVariable>,
equations: &[Equation],
) -> BTreeMap<String, Expr> {
if !defines_by_name(equations) {
return BTreeMap::new();
}
owned_definitions(UnknownPartition::of(variables, equations).observed_definitions)
}
fn defines_by_name(equations: &[Equation]) -> bool {
equations
.iter()
.any(|eq| matches!(lhs_form_ref(&eq.lhs), LhsFormRef::Bare(_)))
}
fn owned_definitions(defs: BTreeMap<&str, &Expr>) -> BTreeMap<String, Expr> {
defs.into_iter()
.map(|(name, rhs)| (name.to_string(), rhs.clone()))
.collect()
}
fn derive_system_kind(
brownian: &[String],
equations: &[Equation],
ode_states: &[&str],
) -> SystemKind {
if !brownian.is_empty() {
return SystemKind::Sde;
}
if equations
.iter()
.any(|eq| has_spatial_derivative(&eq.lhs) || has_spatial_derivative(&eq.rhs))
{
return SystemKind::Pde;
}
if ode_states.is_empty() && !equations.iter().any(|eq| has_time_derivative(&eq.lhs)) {
return SystemKind::Nonlinear;
}
SystemKind::Ode
}
pub fn has_spatial_derivative(expr: &Expr) -> bool {
match expr {
Expr::Operator(node) => {
let here = (node.op == "D" && node.wrt.as_deref().is_some_and(|w| w != TIME))
|| SPATIAL_SUGAR_OPS.contains(&node.op.as_str());
here || node.any_child(&mut has_spatial_derivative)
}
_ => false,
}
}
pub(crate) fn has_time_derivative(expr: &Expr) -> bool {
match expr {
Expr::Operator(node) => {
let here = node.op == "D" && node.wrt.as_deref().unwrap_or(TIME) == TIME;
here || node.any_child(&mut has_time_derivative)
}
_ => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DeclaredRank {
Scalar,
Unknown,
}
impl DeclaredRank {
pub(crate) fn scalar_shape(self) -> &'static [usize] {
match self {
DeclaredRank::Scalar => &[],
DeclaredRank::Unknown => &[1],
}
}
pub(crate) fn of_declaration(decl: Option<&crate::types::ModelVariable>) -> Self {
match decl {
Some(v) if v.shape.as_ref().is_none_or(|s| s.is_empty()) => DeclaredRank::Scalar,
_ => DeclaredRank::Unknown,
}
}
pub(crate) fn of(model: &Model, name: &str) -> Self {
Self::of_declaration(model.variables.get(name))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn model(json: serde_json::Value) -> Model {
serde_json::from_value(json).expect("model deserializes")
}
#[test]
fn worked_example() {
let m = model(serde_json::json!({
"variables": {
"c": { "type": "unknown", "units": "kg" },
"v_dep": { "type": "unknown", "units": "m/s" },
"SO4": { "type": "unknown", "units": "mol" },
"k": { "type": "parameter", "units": "1/s", "default": 0.1 },
"eps": { "type": "parameter", "units": "1/s^0.5",
"distribution": { "kind": "normal", "mean": 0.0, "std": 1.0 },
"update": { "kind": "wiener" } }
},
"equations": [
{ "lhs": { "op": "D", "args": ["c"], "wrt": "t" },
"rhs": { "op": "*", "args": ["k", "c", "eps"] } },
{ "lhs": "v_dep", "rhs": { "op": "/", "args": [1, "k"] } },
{ "lhs": { "op": "*", "args": ["SO4", "SO4"] }, "rhs": "k" }
]
}));
let c = Classification::of(&m);
assert_eq!(c.ode_states, ["c"]);
assert_eq!(c.observed_unknowns, ["v_dep"]);
assert_eq!(c.algebraic_unknowns, ["SO4"]);
assert_eq!(c.brownian_parameters, ["eps"]);
assert_eq!(c.constant_parameters, ["k"]);
assert!(c.sampled_parameters.is_empty());
assert!(c.discrete_parameters.is_empty());
assert_eq!(c.system_kind, SystemKind::Sde);
c.assert_partitions(&m).expect("partitions hold");
}
#[test]
fn parameter_partition_discriminates_distribution_from_update() {
let m = model(serde_json::json!({
"variables": {
"u": { "type": "unknown", "units": "1" },
"p": { "type": "parameter", "units": "1", "default": 1.0 },
"samp": { "type": "parameter", "units": "1",
"distribution": { "kind": "uniform", "low": 0.0, "high": 1.0 } },
"cond": { "type": "parameter", "units": "1", "default": 1.0,
"update": { "kind": "condition",
"when": { "op": ">", "args": ["u", 1.0] },
"expression": 0.5 } },
"w": { "type": "parameter", "units": "1",
"distribution": { "kind": "normal", "mean": 0.0, "std": 1.0 },
"update": { "kind": "wiener" } }
},
"equations": [
{ "lhs": { "op": "D", "args": ["u"], "wrt": "t" }, "rhs": "p" }
]
}));
let c = Classification::of(&m);
assert_eq!(c.brownian_parameters, ["w"]);
assert_eq!(c.discrete_parameters, ["cond"]);
assert_eq!(c.sampled_parameters, ["samp"]);
assert_eq!(c.constant_parameters, ["p"]);
c.assert_partitions(&m).expect("partitions hold");
}
#[test]
fn update_array_is_discrete() {
let m = model(serde_json::json!({
"variables": {
"u": { "type": "unknown", "units": "1" },
"season": { "type": "parameter", "units": "1", "default": 1.0,
"update": [
{ "kind": "condition", "when": { "op": "==", "args": ["u", 1] },
"expression": 1.0 },
{ "kind": "condition", "when": { "op": "==", "args": ["u", 2] },
"expression": 2.0 }
] }
},
"equations": [
{ "lhs": { "op": "D", "args": ["u"], "wrt": "t" }, "rhs": "season" }
]
}));
let c = Classification::of(&m);
assert_eq!(c.discrete_parameters, ["season"]);
assert!(c.brownian_parameters.is_empty());
}
#[test]
fn wrapped_derivative_lhs_credits_the_base_variable() {
let m = model(serde_json::json!({
"variables": {
"u": { "type": "unknown", "units": "1", "shape": ["cells"] },
"k": { "type": "parameter", "units": "1/s", "default": 1.0 }
},
"equations": [
{ "lhs": { "op": "faq", "output_idx": ["i"],
"ranges": { "i": { "from": "cells" } },
"args": ["u"],
"expr": { "op": "D",
"args": [{ "op": "index", "args": ["u", "i"] }],
"wrt": "t" } },
"rhs": "k" }
]
}));
assert_eq!(Classification::of(&m).ode_states, ["u"]);
}
#[test]
fn system_kind_order() {
let steady = model(serde_json::json!({
"variables": { "phi": { "type": "unknown" }, "f": { "type": "parameter" } },
"equations": [ { "lhs": { "op": "laplacian", "args": ["phi"] }, "rhs": "f" } ]
}));
assert_eq!(system_kind(&steady), SystemKind::Pde);
let both = model(serde_json::json!({
"variables": {
"v": { "type": "unknown" },
"xi": { "type": "parameter",
"distribution": { "kind": "normal", "mean": 0.0, "std": 1.0 },
"update": { "kind": "wiener" } }
},
"equations": [ { "lhs": { "op": "D", "args": ["v"], "wrt": "t" },
"rhs": { "op": "*", "args": [{ "op": "grad", "args": ["v"], "dim": "x" }, "xi"] } } ]
}));
assert_eq!(system_kind(&both), SystemKind::Sde);
let nl = model(serde_json::json!({
"variables": { "H": { "type": "unknown" }, "SO4": { "type": "unknown" },
"Ksp": { "type": "parameter" } },
"equations": [
{ "lhs": "H", "rhs": { "op": "*", "args": ["SO4", 2.0] } },
{ "lhs": { "op": "*", "args": ["H", "H", "SO4"] }, "rhs": "Ksp" }
]
}));
assert_eq!(system_kind(&nl), SystemKind::Nonlinear);
let ode = model(serde_json::json!({
"variables": { "u": { "type": "unknown" }, "r": { "type": "parameter" } },
"equations": [ { "lhs": { "op": "D", "args": ["u"], "wrt": "t" }, "rhs": "r" } ]
}));
assert_eq!(system_kind(&ode), SystemKind::Ode);
}
#[test]
fn observed_chain_out_of_order() {
let m = model(serde_json::json!({
"variables": {
"z": { "type": "unknown" }, "y": { "type": "unknown" },
"x": { "type": "unknown" }, "a": { "type": "parameter" }
},
"equations": [
{ "lhs": "z", "rhs": { "op": "*", "args": ["y", 2.0] } },
{ "lhs": { "op": "D", "args": ["x"], "wrt": "t" }, "rhs": "a" },
{ "lhs": "y", "rhs": { "op": "+", "args": ["x", 1.0] } }
]
}));
let c = Classification::of(&m);
assert_eq!(c.ode_states, ["x"]);
assert_eq!(c.observed_unknowns, ["y", "z"]);
assert!(c.observed_definitions.contains_key("y"));
assert!(c.observed_definitions.contains_key("z"));
}
#[test]
fn a_chain_far_deeper_than_a_call_stack_is_walked_without_recursing() {
const N: usize = 200_000;
let mut deps: BTreeMap<String, BTreeSet<String>> = (0..N - 1)
.map(|i| (format!("x{i}"), BTreeSet::from([format!("x{}", i + 1)])))
.collect();
deps.insert(format!("x{}", N - 1), BTreeSet::new());
assert_eq!(first_observed_cycle(&deps), None);
deps.insert(format!("x{}", N - 1), BTreeSet::from(["x0".to_string()]));
let cycle = first_observed_cycle(&deps).expect("the ring is a cycle");
assert_eq!(cycle.len(), N + 1);
assert_eq!(cycle.first().map(String::as_str), Some("x0"));
assert_eq!(cycle.last().map(String::as_str), Some("x0"));
}
#[test]
fn a_diamond_is_not_a_cycle() {
let deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::from([
(
"a".to_string(),
BTreeSet::from(["b".to_string(), "c".to_string()]),
),
("b".to_string(), BTreeSet::from(["d".to_string()])),
("c".to_string(), BTreeSet::from(["d".to_string()])),
("d".to_string(), BTreeSet::new()),
]);
assert_eq!(first_observed_cycle(&deps), None);
}
}