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 ConstructionError {
125    pub(crate) fn length_mismatch(field: &str, actual: usize, expected: usize) -> Self {
126        Self::Conversion(format!("{field} has length {actual}, expected {expected}"))
127    }
128}
129
130impl From<String> for ConstructionError {
131    fn from(message: String) -> Self {
132        Self::Conversion(message)
133    }
134}
135
136impl From<&str> for ConstructionError {
137    fn from(message: &str) -> Self {
138        Self::Conversion(message.to_string())
139    }
140}
141
142impl From<std::convert::Infallible> for ConstructionError {
143    fn from(value: std::convert::Infallible) -> Self {
144        match value {}
145    }
146}
147
148/// Type-erased problem constructor used by dynamic frontends.
149pub type ConstructProblemFn =
150    fn(serde_json::Value) -> Result<Box<dyn DynProblem>, ConstructionError>;
151
152/// Random-generation contract for one concrete problem variant.
153#[derive(Clone, Copy)]
154pub struct RandomRegistration {
155    /// Inputs accepted by the generator.
156    pub inputs: fn() -> Vec<CreateInputInfo>,
157    /// Generate a concrete problem from normalized inputs.
158    pub generate: ConstructProblemFn,
159}
160
161/// A concrete problem type that can generate itself from typed random inputs.
162pub trait RandomGenerate: DynProblem + Sized {
163    /// Inputs accepted by this model's random generator.
164    fn inputs() -> Vec<CreateInputInfo>;
165
166    /// Generate a concrete problem from normalized random inputs.
167    fn generate(data: serde_json::Value) -> Result<Self, ConstructionError>;
168}
169
170/// Validate normalized values against a typed construction contract.
171pub fn validate_create_inputs(
172    inputs: &[CreateInputInfo],
173    data: &serde_json::Value,
174) -> Result<(), ConstructionError> {
175    validate_input_contract(
176        inputs.iter().map(|input| (input.name, input.required)),
177        data,
178    )
179}
180
181/// Validate the direct-construction path backed by catalog field metadata.
182///
183/// Direct models have no separate create DTO, so every catalog field is a
184/// required construction input.
185pub fn validate_direct_create_inputs(
186    fields: &[FieldInfo],
187    data: &serde_json::Value,
188) -> Result<(), ConstructionError> {
189    validate_input_contract(fields.iter().map(|field| (field.name, true)), data)
190}
191
192fn validate_input_contract<'a>(
193    inputs: impl IntoIterator<Item = (&'a str, bool)>,
194    data: &serde_json::Value,
195) -> Result<(), ConstructionError> {
196    let object = data.as_object().ok_or(ConstructionError::ExpectedObject)?;
197    let mut declared = BTreeMap::new();
198    for (name, required) in inputs {
199        if declared.insert(name, required).is_some() {
200            return Err(ConstructionError::DuplicateInput(name.to_string()));
201        }
202    }
203
204    let unknown = object
205        .keys()
206        .filter(|name| !declared.contains_key(name.as_str()))
207        .cloned()
208        .collect::<Vec<_>>();
209    if !unknown.is_empty() {
210        return Err(ConstructionError::UnknownInputs(unknown));
211    }
212
213    let missing = declared
214        .into_iter()
215        .filter(|(name, required)| *required && !object.contains_key(*name))
216        .map(|(name, _)| name.to_string())
217        .collect::<Vec<_>>();
218    if !missing.is_empty() {
219        return Err(ConstructionError::MissingInputs(missing));
220    }
221
222    Ok(())
223}
224
225/// A registered problem variant entry.
226///
227/// Submitted by `declare_variants!` for each concrete problem type.
228/// The reduction graph uses these entries to build nodes with complexity metadata.
229pub struct VariantEntry {
230    /// Problem name (from `Problem::NAME`).
231    pub name: &'static str,
232    /// Function returning variant key-value pairs (from `Problem::variant()`).
233    pub variant_fn: fn() -> Vec<(&'static str, &'static str)>,
234    /// Worst-case time complexity expression (e.g., `"2^num_vertices"`).
235    pub complexity: &'static str,
236    /// Compiled complexity evaluation function.
237    /// Takes a `&dyn Any` (must be `&ProblemType`), calls getter methods directly,
238    /// and returns the estimated worst-case time as f64.
239    pub complexity_eval_fn: fn(&dyn Any) -> f64,
240    /// Canonical problem-owned parameter names.
241    pub parameter_names_fn: fn() -> &'static [&'static str],
242    /// Measure the complete canonical parameters of a concrete instance.
243    pub parameter_measure_fn: fn(&dyn Any) -> crate::types::ProblemParameters,
244    /// Whether this entry is the declared default variant for its problem.
245    pub is_default: bool,
246    /// Variant-level aliases (e.g., `&["3SAT"]` for `KSatisfiability<K3>`).
247    ///
248    /// Unlike problem-level aliases (on `ProblemSchemaEntry`), these resolve to a
249    /// specific reduction-graph node, not just to a canonical problem name. The CLI
250    /// resolver tries variant-level aliases first and falls back to problem-level.
251    pub aliases: &'static [&'static str],
252    /// Custom construction inputs. `None` means the catalog schema fields are
253    /// also the construction inputs through the direct path.
254    pub create_inputs: Option<fn() -> Vec<CreateInputInfo>>,
255    /// Construct a validated concrete problem from normalized construction data.
256    pub construct_fn: ConstructProblemFn,
257    /// Model-owned random generator for this exact variant.
258    pub random: Option<RandomRegistration>,
259    /// Factory: deserialize JSON into a boxed dynamic problem.
260    pub factory: fn(serde_json::Value) -> Result<Box<dyn DynProblem>, serde_json::Error>,
261    /// Serialize: downcast `&dyn Any` and serialize to JSON.
262    pub serialize_fn: fn(&dyn Any) -> Option<serde_json::Value>,
263    /// Borrow a registered concrete instance without serializing or cloning it.
264    pub borrow_fn: fn(&dyn Any) -> Option<&dyn DynProblem>,
265}
266
267impl VariantEntry {
268    /// Inputs accepted by this concrete variant's constructor.
269    pub fn inputs(&self) -> Vec<CreateInputInfo> {
270        match self.create_inputs {
271            Some(inputs) => inputs(),
272            None => super::find_problem_type(self.name)
273                .expect("registered variant must have a problem schema")
274                .fields
275                .iter()
276                .cloned()
277                .map(CreateInputInfo::from_field)
278                .collect(),
279        }
280    }
281
282    /// Get the variant by calling the function.
283    pub fn variant(&self) -> Vec<(&'static str, &'static str)> {
284        (self.variant_fn)()
285    }
286
287    /// Get the variant as a `BTreeMap<String, String>`.
288    pub fn variant_map(&self) -> BTreeMap<String, String> {
289        self.variant()
290            .into_iter()
291            .map(|(k, v)| (k.to_string(), v.to_string()))
292            .collect()
293    }
294
295    /// Return the canonical parameter names for this exact variant.
296    pub fn parameter_names(&self) -> &'static [&'static str] {
297        (self.parameter_names_fn)()
298    }
299}
300
301/// Return every registered concrete problem variant.
302pub fn variant_entries() -> Vec<&'static VariantEntry> {
303    inventory::iter::<VariantEntry>().collect()
304}
305
306/// Validate canonical parameter schemas for every registered exact variant.
307pub fn validate_variant_parameter_schemas() -> Result<(), Vec<String>> {
308    let mut errors = Vec::new();
309    let mut schemas = BTreeMap::<&str, Vec<&str>>::new();
310
311    for entry in inventory::iter::<VariantEntry> {
312        let names = entry.parameter_names();
313        if names.is_empty() {
314            errors.push(format!("{} has no parameters", variant_label(entry)));
315            continue;
316        }
317
318        let unique = names
319            .iter()
320            .copied()
321            .collect::<std::collections::BTreeSet<_>>();
322        if unique.len() != names.len() {
323            errors.push(format!(
324                "{} declares duplicate parameters: {names:?}",
325                variant_label(entry)
326            ));
327        }
328
329        let canonical = names.to_vec();
330        if let Some(expected) = schemas.get(entry.name) {
331            if expected != &canonical {
332                errors.push(format!(
333                    "{} has parameter schema {canonical:?}, expected {expected:?}",
334                    variant_label(entry)
335                ));
336            }
337        } else {
338            schemas.insert(entry.name, canonical.clone());
339        }
340
341        let expression = crate::expr::Expr::parse(entry.complexity);
342        for variable in expression.variables() {
343            if !canonical.contains(&variable) {
344                errors.push(format!(
345                    "{} complexity references unknown parameter `{variable}`; declared: {canonical:?}",
346                    variant_label(entry)
347                ));
348            }
349        }
350    }
351
352    if errors.is_empty() {
353        Ok(())
354    } else {
355        errors.sort();
356        Err(errors)
357    }
358}
359
360/// Find a variant entry by exact problem name and exact variant map.
361///
362/// No alias resolution or default fallback. Both `name` and `variant` must match exactly.
363pub fn find_variant_entry(
364    name: &str,
365    variant: &BTreeMap<String, String>,
366) -> Option<&'static VariantEntry> {
367    inventory::iter::<VariantEntry>()
368        .find(|entry| entry.name == name && entry.variant_map() == *variant)
369}
370
371/// Find a variant entry by a variant-level alias (case-insensitive).
372///
373/// A variant-level alias points at a specific reduction-graph node (e.g., `"3SAT"` →
374/// `KSatisfiability` with variant `{k: "K3"}`), unlike problem-level aliases which
375/// resolve only to a canonical problem name.
376///
377/// Returns the matched entry along with its variant map. The first match in registration
378/// order wins — duplicate variant-level aliases across problems are a declaration bug.
379pub fn find_variant_by_alias(
380    input: &str,
381) -> Option<(&'static VariantEntry, BTreeMap<String, String>)> {
382    let lower = input.to_lowercase();
383    let entry = inventory::iter::<VariantEntry>()
384        .find(|entry| entry.aliases.iter().any(|a| a.to_lowercase() == lower))?;
385    Some((entry, entry.variant_map()))
386}
387
388/// Validate all variant-level aliases registered in inventory.
389///
390/// This is intended for explicit test-time or startup invocation. It rejects
391/// duplicate variant-level aliases, aliases that collide with canonical
392/// problem names or problem-level aliases, and empty aliases for manually
393/// constructed [`VariantEntry`] values that bypass `declare_variants!`.
394pub fn validate_variant_aliases() -> Result<(), Vec<String>> {
395    let mut problem_names: BTreeMap<String, Vec<String>> = BTreeMap::new();
396
397    for problem in super::problem_type::problem_types() {
398        problem_names
399            .entry(problem.canonical_name.to_lowercase())
400            .or_default()
401            .push(format!(
402                "canonical problem name `{}`",
403                problem.canonical_name
404            ));
405
406        for alias in problem.aliases {
407            problem_names
408                .entry(alias.to_lowercase())
409                .or_default()
410                .push(format!(
411                    "problem-level alias `{alias}` for `{}`",
412                    problem.canonical_name
413                ));
414        }
415    }
416
417    let entries: Vec<_> = inventory::iter::<VariantEntry>()
418        .map(|e| (variant_label(e), e.aliases))
419        .collect();
420
421    validate_aliases_inner(&problem_names, &entries)
422}
423
424/// Core validation logic, separated for testability with mock data.
425///
426/// - `problem_names`: lowercase key → list of human-readable sources (canonical names + problem-level aliases).
427/// - `entries`: `(variant_label, aliases_slice)` per variant entry.
428pub fn validate_aliases_inner(
429    problem_names: &BTreeMap<String, Vec<String>>,
430    entries: &[(String, &[&str])],
431) -> Result<(), Vec<String>> {
432    let mut conflicts = Vec::new();
433    let mut variant_aliases: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
434
435    for (target, aliases) in entries {
436        for alias in *aliases {
437            if alias.trim().is_empty() {
438                conflicts.push(format!(
439                    "variant-level alias on {target} is empty or whitespace-only"
440                ));
441                continue;
442            }
443
444            let lower = alias.to_lowercase();
445            if let Some(collisions) = problem_names.get(&lower) {
446                for collision in collisions {
447                    conflicts.push(format!(
448                        "variant-level alias `{alias}` on {target} conflicts with {collision}"
449                    ));
450                }
451            }
452
453            variant_aliases
454                .entry(lower)
455                .or_default()
456                .push((alias.to_string(), target.clone()));
457        }
458    }
459
460    for (lower, registrations) in variant_aliases {
461        if registrations.len() > 1 {
462            let details = registrations
463                .iter()
464                .map(|(alias, target)| format!("`{alias}` on {target}"))
465                .collect::<Vec<_>>()
466                .join("; ");
467            conflicts.push(format!(
468                "duplicate variant-level alias `{lower}` (case-insensitive): {details}"
469            ));
470        }
471    }
472
473    if conflicts.is_empty() {
474        Ok(())
475    } else {
476        conflicts.sort();
477        Err(conflicts)
478    }
479}
480
481pub fn variant_label(entry: &VariantEntry) -> String {
482    let variant = entry.variant();
483    if variant.is_empty() {
484        return entry.name.to_string();
485    }
486
487    let parts = variant
488        .iter()
489        .map(|(key, value)| format!("{key}={value}"))
490        .collect::<Vec<_>>()
491        .join(", ");
492    format!("{} {{{parts}}}", entry.name)
493}
494
495impl std::fmt::Debug for VariantEntry {
496    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
497        f.debug_struct("VariantEntry")
498            .field("name", &self.name)
499            .field("variant", &self.variant())
500            .field("complexity", &self.complexity)
501            .finish()
502    }
503}
504
505inventory::collect!(VariantEntry);
506
507#[cfg(test)]
508#[path = "../unit_tests/registry/variant.rs"]
509mod tests;