Skip to main content

problemreductions/registry/
variant.rs

1//! Explicit variant registration via inventory.
2
3use std::any::Any;
4use std::collections::BTreeMap;
5
6use crate::registry::dyn_problem::DynProblem;
7use crate::registry::FieldInfo;
8
9/// Reusable syntax used to transport one construction input.
10///
11/// `Auto` asks a frontend to choose the codec from `type_name`. The explicit
12/// variants are for Rust types whose compact external syntax is ambiguous.
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
14pub enum CreateInputCodec {
15    /// Infer the transport syntax from the Rust value type.
16    #[default]
17    Auto,
18    /// A single scalar value.
19    Scalar,
20    /// A JSON value.
21    Json,
22    /// Comma-separated values.
23    CommaSeparated,
24    /// Semicolon-separated rows or groups.
25    SemicolonSeparated,
26    /// Undirected edges such as `0-1,1-2`.
27    EdgeList,
28    /// Directed arcs such as `0>1,1>2`.
29    ArcList,
30    /// Bipartite-local edges such as `0-0,0-1`.
31    BipartiteEdgeList,
32    /// Equality-linked index pairs such as `2=5;4=3`.
33    EqualityPairList,
34    /// Functional dependencies such as `0,1:2;2:3,4`.
35    FunctionalDependencyList,
36    /// Semicolon-separated character strings sharing one inferred alphabet.
37    CharacterRows,
38}
39
40/// A user-facing input accepted when constructing a problem instance.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
42pub struct CreateInputInfo {
43    /// Input name in snake_case. Frontends may render it in their native style.
44    pub name: &'static str,
45    /// Concrete Rust value type accepted by the construction spec.
46    pub type_name: &'static str,
47    /// Human-readable input description.
48    pub description: &'static str,
49    /// Whether the input must be present.
50    pub required: bool,
51    /// Reusable transport syntax for this input.
52    pub codec: CreateInputCodec,
53}
54
55impl CreateInputInfo {
56    /// Promote catalog field metadata into a required construction input.
57    pub const fn from_field(field: FieldInfo) -> Self {
58        Self {
59            name: field.name,
60            type_name: field.type_name,
61            description: field.description,
62            required: true,
63            codec: CreateInputCodec::Auto,
64        }
65    }
66}
67
68/// Construction-input metadata generated from a typed create spec.
69pub trait CreateSpec {
70    /// Construction-facing field metadata used by the problem catalog.
71    const FIELDS: &'static [FieldInfo];
72    /// Inputs accepted by this specification, including composed decision inputs.
73    fn inputs() -> Vec<CreateInputInfo>;
74
75    /// Deserialize normalized construction inputs into the typed specification.
76    fn deserialize_inputs(data: serde_json::Value) -> Result<Self, serde_json::Error>
77    where
78        Self: Sized + serde::de::DeserializeOwned,
79    {
80        serde_json::from_value(data)
81    }
82}
83
84/// Failure while validating or applying a model construction contract.
85#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
86pub enum ConstructionError {
87    /// No concrete variant matches the requested problem reference.
88    #[error("no registered variant for `{name}` with variant {variant:?}")]
89    UnregisteredVariant {
90        /// Canonical problem name.
91        name: String,
92        /// Exact requested variant.
93        variant: BTreeMap<String, String>,
94    },
95    /// Construction values must be supplied as a named JSON object.
96    #[error("construction inputs must be a JSON object")]
97    ExpectedObject,
98    /// A construction contract declared the same input more than once.
99    #[error("construction input `{0}` is declared more than once")]
100    DuplicateInput(String),
101    /// The caller supplied values outside the declared construction contract.
102    #[error("unknown construction input(s): {}", .0.join(", "))]
103    UnknownInputs(Vec<String>),
104    /// The caller omitted required construction values.
105    #[error("missing required construction input(s): {}", .0.join(", "))]
106    MissingInputs(Vec<String>),
107    /// Normalized values could not be deserialized into the direct model or create spec.
108    #[error("invalid construction input: {0}")]
109    InvalidInput(String),
110    /// A typed create spec failed to convert into the problem model.
111    #[error("problem construction failed: {0}")]
112    Conversion(String),
113    /// Arithmetic used to produce a stored model field overflowed.
114    #[error("integer overflow during construction: {0}")]
115    IntegerOverflow(String),
116    /// A stored approximate value is not finite.
117    #[error("non-finite floating-point construction value: {0}")]
118    NonFiniteFloat(String),
119    /// An exact integer cannot be stored in the target floating-point domain.
120    #[error("inexact integer-to-float construction value: {0}")]
121    InexactFloatConversion(#[from] crate::types::ExactI64ToF64Error),
122}
123
124impl From<String> for ConstructionError {
125    fn from(message: String) -> Self {
126        Self::Conversion(message)
127    }
128}
129
130impl From<&str> for ConstructionError {
131    fn from(message: &str) -> Self {
132        Self::Conversion(message.to_string())
133    }
134}
135
136impl From<std::convert::Infallible> for ConstructionError {
137    fn from(value: std::convert::Infallible) -> Self {
138        match value {}
139    }
140}
141
142/// Type-erased problem constructor used by dynamic frontends.
143pub type ConstructProblemFn =
144    fn(serde_json::Value) -> Result<Box<dyn DynProblem>, ConstructionError>;
145
146/// Random-generation contract for one concrete problem variant.
147#[derive(Clone, Copy)]
148pub struct RandomRegistration {
149    /// Inputs accepted by the generator.
150    pub inputs: fn() -> Vec<CreateInputInfo>,
151    /// Generate a concrete problem from normalized inputs.
152    pub generate: ConstructProblemFn,
153}
154
155/// A concrete problem type that can generate itself from typed random inputs.
156pub trait RandomGenerate: DynProblem + Sized {
157    /// Inputs accepted by this model's random generator.
158    fn inputs() -> Vec<CreateInputInfo>;
159
160    /// Generate a concrete problem from normalized random inputs.
161    fn generate(data: serde_json::Value) -> Result<Self, ConstructionError>;
162}
163
164/// Validate normalized values against a typed construction contract.
165pub fn validate_create_inputs(
166    inputs: &[CreateInputInfo],
167    data: &serde_json::Value,
168) -> Result<(), ConstructionError> {
169    validate_input_contract(
170        inputs.iter().map(|input| (input.name, input.required)),
171        data,
172    )
173}
174
175/// Validate the direct-construction path backed by catalog field metadata.
176///
177/// Direct models have no separate create DTO, so every catalog field is a
178/// required construction input.
179pub fn validate_direct_create_inputs(
180    fields: &[FieldInfo],
181    data: &serde_json::Value,
182) -> Result<(), ConstructionError> {
183    validate_input_contract(fields.iter().map(|field| (field.name, true)), data)
184}
185
186fn validate_input_contract<'a>(
187    inputs: impl IntoIterator<Item = (&'a str, bool)>,
188    data: &serde_json::Value,
189) -> Result<(), ConstructionError> {
190    let object = data.as_object().ok_or(ConstructionError::ExpectedObject)?;
191    let mut declared = BTreeMap::new();
192    for (name, required) in inputs {
193        if declared.insert(name, required).is_some() {
194            return Err(ConstructionError::DuplicateInput(name.to_string()));
195        }
196    }
197
198    let unknown = object
199        .keys()
200        .filter(|name| !declared.contains_key(name.as_str()))
201        .cloned()
202        .collect::<Vec<_>>();
203    if !unknown.is_empty() {
204        return Err(ConstructionError::UnknownInputs(unknown));
205    }
206
207    let missing = declared
208        .into_iter()
209        .filter(|(name, required)| *required && !object.contains_key(*name))
210        .map(|(name, _)| name.to_string())
211        .collect::<Vec<_>>();
212    if !missing.is_empty() {
213        return Err(ConstructionError::MissingInputs(missing));
214    }
215
216    Ok(())
217}
218
219/// A registered problem variant entry.
220///
221/// Submitted by `declare_variants!` for each concrete problem type.
222/// The reduction graph uses these entries to build nodes with complexity metadata.
223pub struct VariantEntry {
224    /// Problem name (from `Problem::NAME`).
225    pub name: &'static str,
226    /// Function returning variant key-value pairs (from `Problem::variant()`).
227    pub variant_fn: fn() -> Vec<(&'static str, &'static str)>,
228    /// Worst-case time complexity expression (e.g., `"2^num_vertices"`).
229    pub complexity: &'static str,
230    /// Compiled complexity evaluation function.
231    /// Takes a `&dyn Any` (must be `&ProblemType`), calls getter methods directly,
232    /// and returns the estimated worst-case time as f64.
233    pub complexity_eval_fn: fn(&dyn Any) -> f64,
234    /// Canonical problem-owned parameter names.
235    pub parameter_names_fn: fn() -> &'static [&'static str],
236    /// Measure the complete canonical parameters of a concrete instance.
237    pub parameter_measure_fn: fn(&dyn Any) -> crate::types::ProblemParameters,
238    /// Whether this entry is the declared default variant for its problem.
239    pub is_default: bool,
240    /// Variant-level aliases (e.g., `&["3SAT"]` for `KSatisfiability<K3>`).
241    ///
242    /// Unlike problem-level aliases (on `ProblemSchemaEntry`), these resolve to a
243    /// specific reduction-graph node, not just to a canonical problem name. The CLI
244    /// resolver tries variant-level aliases first and falls back to problem-level.
245    pub aliases: &'static [&'static str],
246    /// Custom construction inputs. `None` means the catalog schema fields are
247    /// also the construction inputs through the direct path.
248    pub create_inputs: Option<fn() -> Vec<CreateInputInfo>>,
249    /// Construct a validated concrete problem from normalized construction data.
250    pub construct_fn: ConstructProblemFn,
251    /// Model-owned random generator for this exact variant.
252    pub random: Option<RandomRegistration>,
253    /// Factory: deserialize JSON into a boxed dynamic problem.
254    pub factory: fn(serde_json::Value) -> Result<Box<dyn DynProblem>, serde_json::Error>,
255    /// Serialize: downcast `&dyn Any` and serialize to JSON.
256    pub serialize_fn: fn(&dyn Any) -> Option<serde_json::Value>,
257}
258
259impl VariantEntry {
260    /// Inputs accepted by this concrete variant's constructor.
261    pub fn inputs(&self) -> Vec<CreateInputInfo> {
262        match self.create_inputs {
263            Some(inputs) => inputs(),
264            None => super::find_problem_type(self.name)
265                .expect("registered variant must have a problem schema")
266                .fields
267                .iter()
268                .cloned()
269                .map(CreateInputInfo::from_field)
270                .collect(),
271        }
272    }
273
274    /// Get the variant by calling the function.
275    pub fn variant(&self) -> Vec<(&'static str, &'static str)> {
276        (self.variant_fn)()
277    }
278
279    /// Get the variant as a `BTreeMap<String, String>`.
280    pub fn variant_map(&self) -> BTreeMap<String, String> {
281        self.variant()
282            .into_iter()
283            .map(|(k, v)| (k.to_string(), v.to_string()))
284            .collect()
285    }
286
287    /// Return the canonical parameter names for this exact variant.
288    pub fn parameter_names(&self) -> &'static [&'static str] {
289        (self.parameter_names_fn)()
290    }
291}
292
293/// Return every registered concrete problem variant.
294pub fn variant_entries() -> Vec<&'static VariantEntry> {
295    inventory::iter::<VariantEntry>().collect()
296}
297
298/// Validate canonical parameter schemas for every registered exact variant.
299pub fn validate_variant_parameter_schemas() -> Result<(), Vec<String>> {
300    let mut errors = Vec::new();
301    let mut schemas = BTreeMap::<&str, Vec<&str>>::new();
302
303    for entry in inventory::iter::<VariantEntry> {
304        let names = entry.parameter_names();
305        if names.is_empty() {
306            errors.push(format!("{} has no parameters", variant_label(entry)));
307            continue;
308        }
309
310        let unique = names
311            .iter()
312            .copied()
313            .collect::<std::collections::BTreeSet<_>>();
314        if unique.len() != names.len() {
315            errors.push(format!(
316                "{} declares duplicate parameters: {names:?}",
317                variant_label(entry)
318            ));
319        }
320
321        let canonical = names.to_vec();
322        if let Some(expected) = schemas.get(entry.name) {
323            if expected != &canonical {
324                errors.push(format!(
325                    "{} has parameter schema {canonical:?}, expected {expected:?}",
326                    variant_label(entry)
327                ));
328            }
329        } else {
330            schemas.insert(entry.name, canonical.clone());
331        }
332
333        let expression = crate::expr::Expr::parse(entry.complexity);
334        for variable in expression.variables() {
335            if !canonical.contains(&variable) {
336                errors.push(format!(
337                    "{} complexity references unknown parameter `{variable}`; declared: {canonical:?}",
338                    variant_label(entry)
339                ));
340            }
341        }
342    }
343
344    if errors.is_empty() {
345        Ok(())
346    } else {
347        errors.sort();
348        Err(errors)
349    }
350}
351
352/// Find a variant entry by exact problem name and exact variant map.
353///
354/// No alias resolution or default fallback. Both `name` and `variant` must match exactly.
355pub fn find_variant_entry(
356    name: &str,
357    variant: &BTreeMap<String, String>,
358) -> Option<&'static VariantEntry> {
359    inventory::iter::<VariantEntry>()
360        .find(|entry| entry.name == name && entry.variant_map() == *variant)
361}
362
363/// Find a variant entry by a variant-level alias (case-insensitive).
364///
365/// A variant-level alias points at a specific reduction-graph node (e.g., `"3SAT"` →
366/// `KSatisfiability` with variant `{k: "K3"}`), unlike problem-level aliases which
367/// resolve only to a canonical problem name.
368///
369/// Returns the matched entry along with its variant map. The first match in registration
370/// order wins — duplicate variant-level aliases across problems are a declaration bug.
371pub fn find_variant_by_alias(
372    input: &str,
373) -> Option<(&'static VariantEntry, BTreeMap<String, String>)> {
374    let lower = input.to_lowercase();
375    let entry = inventory::iter::<VariantEntry>()
376        .find(|entry| entry.aliases.iter().any(|a| a.to_lowercase() == lower))?;
377    Some((entry, entry.variant_map()))
378}
379
380/// Validate all variant-level aliases registered in inventory.
381///
382/// This is intended for explicit test-time or startup invocation. It rejects
383/// duplicate variant-level aliases, aliases that collide with canonical
384/// problem names or problem-level aliases, and empty aliases for manually
385/// constructed [`VariantEntry`] values that bypass `declare_variants!`.
386pub fn validate_variant_aliases() -> Result<(), Vec<String>> {
387    let mut problem_names: BTreeMap<String, Vec<String>> = BTreeMap::new();
388
389    for problem in super::problem_type::problem_types() {
390        problem_names
391            .entry(problem.canonical_name.to_lowercase())
392            .or_default()
393            .push(format!(
394                "canonical problem name `{}`",
395                problem.canonical_name
396            ));
397
398        for alias in problem.aliases {
399            problem_names
400                .entry(alias.to_lowercase())
401                .or_default()
402                .push(format!(
403                    "problem-level alias `{alias}` for `{}`",
404                    problem.canonical_name
405                ));
406        }
407    }
408
409    let entries: Vec<_> = inventory::iter::<VariantEntry>()
410        .map(|e| (variant_label(e), e.aliases))
411        .collect();
412
413    validate_aliases_inner(&problem_names, &entries)
414}
415
416/// Core validation logic, separated for testability with mock data.
417///
418/// - `problem_names`: lowercase key → list of human-readable sources (canonical names + problem-level aliases).
419/// - `entries`: `(variant_label, aliases_slice)` per variant entry.
420pub fn validate_aliases_inner(
421    problem_names: &BTreeMap<String, Vec<String>>,
422    entries: &[(String, &[&str])],
423) -> Result<(), Vec<String>> {
424    let mut conflicts = Vec::new();
425    let mut variant_aliases: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
426
427    for (target, aliases) in entries {
428        for alias in *aliases {
429            if alias.trim().is_empty() {
430                conflicts.push(format!(
431                    "variant-level alias on {target} is empty or whitespace-only"
432                ));
433                continue;
434            }
435
436            let lower = alias.to_lowercase();
437            if let Some(collisions) = problem_names.get(&lower) {
438                for collision in collisions {
439                    conflicts.push(format!(
440                        "variant-level alias `{alias}` on {target} conflicts with {collision}"
441                    ));
442                }
443            }
444
445            variant_aliases
446                .entry(lower)
447                .or_default()
448                .push((alias.to_string(), target.clone()));
449        }
450    }
451
452    for (lower, registrations) in variant_aliases {
453        if registrations.len() > 1 {
454            let details = registrations
455                .iter()
456                .map(|(alias, target)| format!("`{alias}` on {target}"))
457                .collect::<Vec<_>>()
458                .join("; ");
459            conflicts.push(format!(
460                "duplicate variant-level alias `{lower}` (case-insensitive): {details}"
461            ));
462        }
463    }
464
465    if conflicts.is_empty() {
466        Ok(())
467    } else {
468        conflicts.sort();
469        Err(conflicts)
470    }
471}
472
473pub fn variant_label(entry: &VariantEntry) -> String {
474    let variant = entry.variant();
475    if variant.is_empty() {
476        return entry.name.to_string();
477    }
478
479    let parts = variant
480        .iter()
481        .map(|(key, value)| format!("{key}={value}"))
482        .collect::<Vec<_>>()
483        .join(", ");
484    format!("{} {{{parts}}}", entry.name)
485}
486
487impl std::fmt::Debug for VariantEntry {
488    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489        f.debug_struct("VariantEntry")
490            .field("name", &self.name)
491            .field("variant", &self.variant())
492            .field("complexity", &self.complexity)
493            .finish()
494    }
495}
496
497inventory::collect!(VariantEntry);
498
499#[cfg(test)]
500#[path = "../unit_tests/registry/variant.rs"]
501mod tests;