diff --git a/crates/arco-cli/src/inspect.rs b/crates/arco-cli/src/inspect.rs index acae782..b70498e 100644 --- a/crates/arco-cli/src/inspect.rs +++ b/crates/arco-cli/src/inspect.rs @@ -52,8 +52,8 @@ pub struct Counts { pub struct SetRecord { pub id: usize, pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub alias: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub aliases: Vec, pub size: usize, pub dtype: String, #[serde(skip_serializing_if = "Vec::is_empty")] @@ -193,24 +193,33 @@ pub fn build_inspect_payload(entrypoint: &Path, program: &SemanticProgram) -> In let set_sizes: BTreeMap<&str, usize> = program .set_registry .iter() - .map(|(name, resolved)| (name.as_str(), resolved.values.len())) + .map(|(name, resolved)| (name.as_str(), resolved_set_cardinality(resolved))) .collect(); // Build variable records let variable_records = build_variable_records(program, &set_sizes); // Build parameter records - let parameter_records = - build_parameter_records(program, ¶meter_targets, &variable_targets, &set_sizes); + let parameter_records = build_parameter_records( + program, + ¶meter_targets, + &variable_targets, + &set_sizes, + &program.set_aliases, + ); // Build expression records let expression_records = build_expression_records(program, &variable_targets, ¶meter_targets); // Build constraint records - let constraint_records = - build_constraint_records(program, &variable_targets, ¶meter_targets, &set_sizes); - + let constraint_records = build_constraint_records( + program, + &variable_targets, + ¶meter_targets, + &set_sizes, + &program.set_aliases, + ); // Build objective record let objective_record = build_objective_record(program, &variable_targets, ¶meter_targets); @@ -254,15 +263,15 @@ fn build_set_records(program: &SemanticProgram, filtered_set_names: &[&str]) -> .filter(|(name, _)| !filtered_set_names.contains(&name.as_str())) .enumerate() { - let alias = find_set_alias(program, name); + let aliases = find_set_aliases(program, name); let dtype = infer_set_dtype(resolved); let subset_of = find_subset_relations(program, name); records.push(SetRecord { id, name: name.clone(), - alias, - size: resolved.values.len(), + aliases, + size: resolved_set_cardinality(resolved), dtype, subset_of, }); @@ -271,22 +280,44 @@ fn build_set_records(program: &SemanticProgram, filtered_set_names: &[&str]) -> records } -fn find_set_alias(program: &SemanticProgram, set_name: &str) -> Option { - for family in &program.variable_families { - for (index, domain) in &family.index_domains { - if domain == set_name && index != set_name { - return Some(index.clone()); - } - } - } - for constraint in &program.active_constraints { - for binding in &constraint.generation_bindings { - if binding.domain == set_name && binding.variable != set_name { - return Some(binding.variable.clone()); - } - } - } - None +fn find_set_aliases(program: &SemanticProgram, set_name: &str) -> Vec { + program + .set_aliases + .iter() + .filter(|(_, canonical)| *canonical == set_name) + .map(|(alias, _)| alias.clone()) + .collect() +} + +fn resolved_set_cardinality(resolved: &arco_kdl::semantic::ResolvedSet) -> usize { + resolved + .tuple_rows + .as_ref() + .map_or(resolved.values.len(), Vec::len) +} + +fn canonical_set_name<'a>(set_name: &'a str, set_aliases: &'a BTreeMap) -> &'a str { + set_aliases.get(set_name).map_or(set_name, String::as_str) +} + +fn lookup_set_size_option( + set_sizes: &BTreeMap<&str, usize>, + set_aliases: &BTreeMap, + set_name: &str, +) -> Option { + let canonical = canonical_set_name(set_name, set_aliases); + set_sizes + .get(canonical) + .copied() + .or_else(|| set_sizes.get(set_name).copied()) +} + +fn lookup_set_size( + set_sizes: &BTreeMap<&str, usize>, + set_aliases: &BTreeMap, + set_name: &str, +) -> usize { + lookup_set_size_option(set_sizes, set_aliases, set_name).unwrap_or(0) } fn infer_set_dtype(resolved: &arco_kdl::semantic::ResolvedSet) -> String { @@ -327,7 +358,7 @@ fn build_variable_records( kind, lower, upper, - set: build_family_set_bindings(family, set_sizes), + set: build_family_set_bindings(family, set_sizes, &program.set_aliases), } }) .collect() @@ -398,6 +429,7 @@ fn render_bound(bound: &arco_kdl::source::BoundExpr) -> BoundValue { fn build_family_set_bindings( family: &FamilySignature, set_sizes: &BTreeMap<&str, usize>, + set_aliases: &BTreeMap, ) -> Vec { family .indices @@ -408,7 +440,7 @@ fn build_family_set_bindings( .get(index) .cloned() .unwrap_or_else(|| index.clone()); - let size = set_sizes.get(set_name.as_str()).copied().unwrap_or(0); + let size = lookup_set_size(set_sizes, set_aliases, set_name.as_str()); let alias = if index == &set_name { None } else { @@ -430,6 +462,7 @@ fn build_parameter_records( parameter_targets: &BTreeSet, _variable_targets: &BTreeSet, set_sizes: &BTreeMap<&str, usize>, + set_aliases: &BTreeMap, ) -> Vec { let mut records = Vec::new(); let mut id = 0; @@ -463,11 +496,11 @@ fn build_parameter_records( .map(|set_name| SetBinding { name: set_name.to_string(), alias: None, - size: set_sizes.get(set_name).copied().unwrap_or(0), + size: lookup_set_size(set_sizes, set_aliases, set_name), }) .collect() } else { - infer_parameter_sets(program, name, set_sizes) + infer_parameter_sets(program, name, set_sizes, set_aliases) }; records.push(ParameterRecord { @@ -488,7 +521,7 @@ fn build_parameter_records( name: name.clone(), kind: "inferred".to_string(), dtype: infer_parameter_dtype(name), - set: infer_parameter_sets(program, name, set_sizes), + set: infer_parameter_sets(program, name, set_sizes, set_aliases), }); id += 1; } @@ -509,6 +542,7 @@ fn infer_parameter_sets( program: &SemanticProgram, parameter_name: &str, set_sizes: &BTreeMap<&str, usize>, + set_aliases: &BTreeMap, ) -> Vec { // Try to find indexing from constraint/expression usage let mut set_refs = Vec::new(); @@ -526,7 +560,7 @@ fn infer_parameter_sets( return set_refs .into_iter() .map(|set_name| { - let size = set_sizes.get(set_name.as_str()).copied().unwrap_or(0); + let size = lookup_set_size(set_sizes, set_aliases, set_name.as_str()); SetBinding { name: set_name, alias: None, @@ -542,7 +576,7 @@ fn infer_parameter_sets( .asset .contains(¶meter_name.to_string()) { - if let Some(&size) = set_sizes.get("asset_id") { + if let Some(size) = lookup_set_size_option(set_sizes, set_aliases, "asset_id") { return vec![SetBinding { name: "asset_id".to_string(), alias: None, @@ -556,7 +590,7 @@ fn infer_parameter_sets( .series .contains(¶meter_name.to_string()) { - if let Some(&size) = set_sizes.get("time") { + if let Some(size) = lookup_set_size_option(set_sizes, set_aliases, "time") { return vec![SetBinding { name: "time".to_string(), alias: None, @@ -703,6 +737,7 @@ fn build_constraint_records( variable_targets: &BTreeSet, parameter_targets: &BTreeSet, set_sizes: &BTreeMap<&str, usize>, + set_aliases: &BTreeMap, ) -> Vec { program .active_constraints @@ -712,9 +747,11 @@ fn build_constraint_records( build_constraint_record( id, constraint, + program, variable_targets, parameter_targets, set_sizes, + set_aliases, ) }) .collect() @@ -723,12 +760,14 @@ fn build_constraint_records( fn build_constraint_record( id: usize, constraint: &ResolvedConstraint, + program: &SemanticProgram, variable_targets: &BTreeSet, parameter_targets: &BTreeSet, set_sizes: &BTreeMap<&str, usize>, + set_aliases: &BTreeMap, ) -> ConstraintRecord { - let scope = build_constraint_scope(constraint, set_sizes); - let instances = scope.iter().map(|s| s.size).product::().max(1); + let scope = build_constraint_scope(constraint, set_sizes, set_aliases); + let instances = estimate_constraint_instances(program, constraint, set_sizes); let symbol_to_set: BTreeMap<&str, &str> = constraint .generation_bindings @@ -745,6 +784,7 @@ fn build_constraint_record( parameter_targets, &symbol_to_set, set_sizes, + set_aliases, ); let rhs = build_term_refs( right, @@ -752,6 +792,7 @@ fn build_constraint_record( parameter_targets, &symbol_to_set, set_sizes, + set_aliases, ); (relation, lhs, rhs) } @@ -766,6 +807,7 @@ fn build_constraint_record( parameter_targets, &symbol_to_set, set_sizes, + set_aliases, ); (relation, lhs, Vec::new()) } @@ -790,12 +832,13 @@ fn build_constraint_record( fn build_constraint_scope( constraint: &ResolvedConstraint, set_sizes: &BTreeMap<&str, usize>, + set_aliases: &BTreeMap, ) -> Vec { constraint .generation_bindings .iter() .map(|binding| { - let size = set_sizes.get(binding.domain.as_str()).copied().unwrap_or(0); + let size = lookup_set_size(set_sizes, set_aliases, binding.domain.as_str()); let alias = if binding.variable == binding.domain { None } else { @@ -810,12 +853,43 @@ fn build_constraint_scope( .collect() } +fn estimate_constraint_instances( + program: &SemanticProgram, + constraint: &ResolvedConstraint, + set_sizes: &BTreeMap<&str, usize>, +) -> usize { + let mut instances = 1usize; + let mut seen_tuple_domains = BTreeSet::new(); + + for binding in &constraint.generation_bindings { + let canonical_domain = canonical_set_name(binding.domain.as_str(), &program.set_aliases); + + if let Some(resolved_set) = program.set_registry.get(canonical_domain) { + // Non-tuple sets are always counted; tuple-domain sets are counted only + // once per canonical domain to avoid Cartesian overcounting when + // multiple bindings share the same tuple domain. + let should_count = + resolved_set.tuple_rows.is_none() || seen_tuple_domains.insert(canonical_domain); + if should_count { + instances = instances.saturating_mul(resolved_set_cardinality(resolved_set)); + } + continue; + } + + let size = lookup_set_size(set_sizes, &program.set_aliases, binding.domain.as_str()); + instances = instances.saturating_mul(size); + } + + instances.max(1) +} + fn build_term_refs( expr: &Expr, variable_targets: &BTreeSet, parameter_targets: &BTreeSet, symbol_to_set: &BTreeMap<&str, &str>, set_sizes: &BTreeMap<&str, usize>, + set_aliases: &BTreeMap, ) -> Vec { let additive_terms = split_additive_terms(expr); let mut refs = Vec::new(); @@ -827,6 +901,7 @@ fn build_term_refs( parameter_targets, symbol_to_set, set_sizes, + set_aliases, &mut refs, ); } @@ -853,6 +928,7 @@ fn collect_term_refs_from_expr( parameter_targets: &BTreeSet, symbol_to_set: &BTreeMap<&str, &str>, set_sizes: &BTreeMap<&str, usize>, + set_aliases: &BTreeMap, out: &mut Vec, ) { match expr { @@ -872,7 +948,7 @@ fn collect_term_refs_from_expr( let set_name = symbol_to_set .get(symbol.as_str()) .map_or(symbol.clone(), |&s| s.to_string()); - let size = set_sizes.get(set_name.as_str()).copied().unwrap_or(0); + let size = lookup_set_size(set_sizes, set_aliases, set_name.as_str()); let alias = if *symbol == set_name { None } else { @@ -926,6 +1002,7 @@ fn collect_term_refs_from_expr( parameter_targets, &extended, set_sizes, + set_aliases, &mut inner_refs, ); for mut inner_ref in inner_refs { @@ -945,6 +1022,7 @@ fn collect_term_refs_from_expr( parameter_targets, symbol_to_set, set_sizes, + set_aliases, out, ); collect_term_refs_from_expr( @@ -953,6 +1031,7 @@ fn collect_term_refs_from_expr( parameter_targets, symbol_to_set, set_sizes, + set_aliases, out, ); } @@ -963,6 +1042,7 @@ fn collect_term_refs_from_expr( parameter_targets, symbol_to_set, set_sizes, + set_aliases, out, ); } @@ -974,6 +1054,7 @@ fn collect_term_refs_from_expr( parameter_targets, symbol_to_set, set_sizes, + set_aliases, out, ); } @@ -986,6 +1067,7 @@ fn collect_term_refs_from_expr( parameter_targets, symbol_to_set, set_sizes, + set_aliases, out, ); collect_term_refs_from_expr( @@ -994,6 +1076,7 @@ fn collect_term_refs_from_expr( parameter_targets, symbol_to_set, set_sizes, + set_aliases, out, ); } diff --git a/crates/arco-cli/tests/example_cli_commands.rs b/crates/arco-cli/tests/example_cli_commands.rs index 75c2917..7d5018a 100644 --- a/crates/arco-cli/tests/example_cli_commands.rs +++ b/crates/arco-cli/tests/example_cli_commands.rs @@ -195,6 +195,28 @@ fn run_compact_nodal_allocation_tracer_bullet_succeeds() { let inspect_payload: Value = serde_json::from_slice(&inspect_output.stdout).expect("valid inspect json"); + + let inspect_sets = inspect_payload["set"].as_array().expect("set array"); + let feasible_links = inspect_sets + .iter() + .find(|record| record["name"] == "feasible_links") + .expect("feasible_links set record"); + assert_eq!( + feasible_links["size"], + Value::from(4), + "feasible_links should report tuple-row cardinality" + ); + + let priority_links = inspect_sets + .iter() + .find(|record| record["name"] == "priority_links") + .expect("priority_links set record"); + assert_eq!( + priority_links["size"], + Value::from(2), + "priority_links should report filtered tuple-row cardinality" + ); + let variables = inspect_payload["variable"] .as_array() .expect("variable array"); @@ -210,8 +232,36 @@ fn run_compact_nodal_allocation_tracer_bullet_succeeds() { ); for binding in sets { assert_eq!(binding["name"], "feasible_links"); + assert_eq!( + binding["size"], + Value::from(4), + "tuple-domain binding should use tuple-row cardinality" + ); } + let constraints = inspect_payload["constraint"] + .as_array() + .expect("constraint array"); + let dispatch_capacity = constraints + .iter() + .find(|record| record["name"] == "dispatch_capacity") + .expect("dispatch_capacity constraint record"); + assert_eq!( + dispatch_capacity["instances"], + Value::from(4), + "tuple-domain constraint instances should track tuple rows, not Cartesian powers" + ); + + let priority_floor = constraints + .iter() + .find(|record| record["name"] == "priority_floor") + .expect("priority_floor constraint record"); + assert_eq!( + priority_floor["instances"], + Value::from(2), + "filtered tuple-domain constraint instances should track tuple rows" + ); + let run_output = run_cli(&["run", model, "--compact"]); assert!( run_output.status.success(), @@ -231,6 +281,138 @@ fn run_compact_nodal_allocation_tracer_bullet_succeeds() { assert_eq!(counts.get("constraints"), Some(&Value::from(2))); } +#[test] +fn inspect_uses_canonical_set_size_for_alias_collision_bindings() { + let root = unique_temp_dir("inspect-alias-collision"); + let data_dir = root.join("data"); + fs::create_dir_all(&data_dir).expect("create temp data dir"); + fs::write( + data_dir.join("rows.csv"), + "i,node\n1,a\n1,b\n2,c\n2,d\n3,e\n", + ) + .expect("write csv"); + + let model_path = root.join("input.kdl"); + fs::write( + &model_path, + r#" +data collision_data source="data/rows.csv" { + map nodes from="node" + set i + set nodes alias="i" +} + +model Collision { + set i + set nodes + + control x { + index nodes + } + + constraint c { + index n { in i } + expression { x[n] <= 1 } + } + + minimize Obj { + sum(x[n] for n in nodes) + } +} + +scenario S1 { + use Collision +} +"#, + ) + .expect("write model"); + + let model = model_path + .to_str() + .expect("model path contains invalid unicode"); + let output = run_cli(&["inspect", model, "--json"]); + + assert!( + output.status.success(), + "inspect failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let payload: Value = serde_json::from_slice(&output.stdout).expect("valid inspect json"); + let constraints = payload["constraint"].as_array().expect("constraint array"); + let constraint = constraints + .iter() + .find(|record| record["name"] == "c") + .expect("constraint record"); + + assert_eq!(constraint["instances"], Value::from(5)); + assert_eq!(constraint["scope"][0]["size"], Value::from(5)); + assert_eq!(constraint["lhs"][0]["over"][0]["size"], Value::from(5)); +} + +#[test] +fn inspect_set_records_include_all_aliases() { + let root = unique_temp_dir("inspect-multi-alias"); + let data_dir = root.join("data"); + fs::create_dir_all(&data_dir).expect("create temp data dir"); + fs::write(data_dir.join("lines.csv"), "lines\nL1\nL2\nL3\n").expect("write csv"); + + let model_path = root.join("input.kdl"); + fs::write( + &model_path, + r#" +data line_data source="data/lines.csv" { + set lines alias="i" +} + +model AliasModel { + set lines alias="j" + + control flow { + index lines + } + + minimize Obj { + sum(flow[l] for l in lines) + } +} + +scenario S1 { + use AliasModel +} +"#, + ) + .expect("write model"); + + let model = model_path + .to_str() + .expect("model path contains invalid unicode"); + let output = run_cli(&["inspect", model, "--json"]); + + assert!( + output.status.success(), + "inspect failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let payload: Value = serde_json::from_slice(&output.stdout).expect("valid inspect json"); + let sets = payload["set"].as_array().expect("set array"); + let lines_set = sets + .iter() + .find(|record| record["name"] == "lines") + .expect("lines set record"); + + let aliases = lines_set["aliases"].as_array().expect("aliases array"); + let alias_values: Vec<&str> = aliases + .iter() + .map(|value| value.as_str().expect("alias string")) + .collect(); + + assert_eq!(alias_values, vec!["i", "j"]); +} + #[test] fn validate_surfaces_empty_filtered_subset_warning() { let root = unique_temp_dir("validate-empty-filtered-subset");