From 6de3b53103bf6199bf36aec2c1afb180eb9e7e6d Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sat, 12 Sep 2026 22:56:11 -0700 Subject: [PATCH] Stage bounded source8 typed resolution and expression lowering --- .../oa-core/src/language/analysis_source.rs | 3 +- .../src/language/analysis_source/budget.rs | 17 + .../src/language/analysis_source/lower.rs | 939 ++++++++++++++++++ .../lower/fixtures/ordinary-oa.arcadia | 7 + .../lower/fixtures/ordinary-sa.arcadia | 7 + .../lower/fixtures/typed-oa.arcadia | 43 + .../lower/fixtures/typed-sa.arcadia | 43 + .../language/analysis_source/lower/oracle.rs | 263 +++++ .../language/analysis_source/lower/tests.rs | 875 ++++++++++++++++ 9 files changed, 2196 insertions(+), 1 deletion(-) create mode 100644 crates/oa-core/src/language/analysis_source/lower.rs create mode 100644 crates/oa-core/src/language/analysis_source/lower/fixtures/ordinary-oa.arcadia create mode 100644 crates/oa-core/src/language/analysis_source/lower/fixtures/ordinary-sa.arcadia create mode 100644 crates/oa-core/src/language/analysis_source/lower/fixtures/typed-oa.arcadia create mode 100644 crates/oa-core/src/language/analysis_source/lower/fixtures/typed-sa.arcadia create mode 100644 crates/oa-core/src/language/analysis_source/lower/oracle.rs create mode 100644 crates/oa-core/src/language/analysis_source/lower/tests.rs diff --git a/crates/oa-core/src/language/analysis_source.rs b/crates/oa-core/src/language/analysis_source.rs index 42e2b8d2..5de053bd 100644 --- a/crates/oa-core/src/language/analysis_source.rs +++ b/crates/oa-core/src/language/analysis_source.rs @@ -1,9 +1,10 @@ //! Unresolved typed source8 syntax, staged for the complete source adapter. -//! This module does not admit a Project, resolve references or enable source8. +//! This module does not admit a Project or enable public source8. mod budget; mod cursor; mod declarations; mod lex; +mod lower; mod syntax; use super::Span; diff --git a/crates/oa-core/src/language/analysis_source/budget.rs b/crates/oa-core/src/language/analysis_source/budget.rs index 383d7ebf..82c02d11 100644 --- a/crates/oa-core/src/language/analysis_source/budget.rs +++ b/crates/oa-core/src/language/analysis_source/budget.rs @@ -134,4 +134,21 @@ impl Budget { pub fn numeric_units(&self) -> u64 { self.numeric.usage().numeric_units } + /// Check phase allocation bounds without recharging parsed syntax or exact + /// work. S4 must also charge ordinary records through the inherited budget. + pub fn lowering_entries(&self, count: usize, span: Span) -> Result<(), Error> { + self.active()?; + if count > self.limits.entries { + return Err(error(span, "Source8 lowering entry limit exceeded.")); + } + Ok(()) + } + pub fn lowering_occurrences(&self, count: usize, span: Span) -> Result<(), Error> { + self.active()?; + // Parameter declarations are syntax entries, not reference uses. + if count > self.limits.references.saturating_add(self.limits.entries) { + return Err(error(span, "Source8 lowering occurrence limit exceeded.")); + } + Ok(()) + } } diff --git a/crates/oa-core/src/language/analysis_source/lower.rs b/crates/oa-core/src/language/analysis_source/lower.rs new file mode 100644 index 00000000..3a3a0f67 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/lower.rs @@ -0,0 +1,939 @@ +//! Staged typed resolution. The caller owns parsing, ID stabilization and the +//! single complete-Project semantic admission after merging these additions. +use super::declarations::{self as d, Declaration, DeclarationKind}; +use super::*; +use crate::analysis as a; +use crate::analysis_numeric::Dimension; +use crate::{Element, ElementBody, Id, Kind, Layer, Project, SharedBody}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Caller-assigned file key; spans are original offsets within that file. +pub(super) struct Unit { + pub file: usize, + pub declarations: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) struct Failure { + /// None denotes an invalid ordinary skeleton, before typed resolution. + pub file: Option, + pub error: Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Category { + Project, + Element(Kind, Layer), + Relation, + Diagram, + Node, + Artifact, + Trace, + QuantityKind, + Unit, + Calculation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Target { + Global(Id), + Parameter { + calculation: Id, + parameter: a::ParameterId, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Field { + Algebra, + Kind, + Input(a::ParameterId), + Result, + Body, + Subject, + Type, + Base, + DisplayUnit, + DocumentedConstraint, + Predicate, + Override(Id), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Role { + Reference, + ParameterDeclaration, + ParameterUse, + CallTarget, + ArgumentLabel, + OverrideTarget, +} + +/// Stable structural edges, independent of alias spelling and argument order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Child { + Item(usize), + Operand, + Left, + Right, + Condition, + WhenTrue, + WhenFalse, + Argument(a::ParameterId), + Values, + Initial, + Base, + Value, +} + +/// An arena index is only local to this lowering result. Follow parent edges +/// to obtain the owner/field/path identity for S3; never persist the index. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct ExpressionSite { + pub file: usize, + pub owner: Id, + pub field: Field, + pub parent: Option<(usize, Child)>, + pub span: Span, +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) struct Occurrence { + pub file: usize, + pub owner: Id, + pub field: Field, + pub expression: Option, + pub role: Role, + pub target: Target, + /// Alias/name token only, excluding @/$ and surrounding trivia. + pub span: Span, +} + +#[derive(Debug)] +pub(super) struct Lowered { + pub definitions: BTreeMap, + pub elements: BTreeMap, + pub occurrences: Vec, + pub expressions: Vec, +} + +#[derive(Clone, Copy)] +struct Symbol { + id: Id, + category: Category, +} + +#[derive(Default)] +struct Tables { + aliases: BTreeMap, + ids: BTreeSet, + parameters: BTreeMap>, + entries: usize, +} + +fn category(kind: DeclarationKind) -> Category { + match kind { + DeclarationKind::QuantityKind => Category::QuantityKind, + DeclarationKind::Unit => Category::Unit, + DeclarationKind::Calculation => Category::Calculation, + DeclarationKind::Property(layer) => Category::Element(Kind::EngineeringProperty, layer), + DeclarationKind::Check(layer) => Category::Element(Kind::AnalysisCheck, layer), + DeclarationKind::Case(layer) => Category::Element(Kind::AnalysisCase, layer), + } +} + +fn identity(identity: &d::IdentitySyntax, span: Span) -> Result { + identity.value.ok_or_else(|| { + error( + span, + "Missing ID requires source stabilization before lowering.", + ) + }) +} + +impl Tables { + fn reserve(&mut self, span: Span, budget: &Budget) -> Result<(), Error> { + budget.lowering_entries(self.entries + 1, span)?; + self.entries += 1; + Ok(()) + } + fn insert( + &mut self, + id: Id, + alias: &str, + category: Category, + span: Span, + budget: &Budget, + ) -> Result<(), Error> { + // A phase allocation bound, not a second syntax charge. Ordinary + // records, typed headers and scoped parameters share this bound. + self.reserve(span, budget)?; + if self.ids.contains(&id) { + return Err(error(span, "Duplicate global identity.")); + } + if self.aliases.contains_key(alias) { + return Err(error(span, format!("Duplicate global alias '{alias}'."))); + } + self.ids.insert(id); + self.aliases + .insert(alias.to_owned(), Symbol { id, category }); + Ok(()) + } + + fn ordinary(&mut self, project: &Project, budget: &Budget) -> Result<(), Error> { + let at = Span::default(); + self.insert(project.id, &project.alias, Category::Project, at, budget)?; + for value in project.definitions.values() { + let category = match &value.body { + a::DefinitionBody::QuantityKind(_) => Category::QuantityKind, + a::DefinitionBody::Unit(_) => Category::Unit, + a::DefinitionBody::Calculation(_) => Category::Calculation, + }; + self.insert(value.id, &value.alias, category, at, budget)?; + if let a::DefinitionBody::Calculation(calculation) = &value.body { + let mut names = BTreeMap::new(); + let mut ids = BTreeSet::new(); + for parameter in &calculation.parameters { + self.reserve(at, budget)?; + if names.contains_key(¶meter.name) || !ids.insert(parameter.id) { + return Err(error( + at, + "Duplicate parameter name or identity in ordinary catalog.", + )); + } + names.insert(parameter.name.clone(), parameter.id); + } + self.parameters.insert(value.id, names); + } + } + for value in project.elements.values() { + self.insert( + value.id, + &value.alias, + Category::Element(value.kind(), value.layer()), + at, + budget, + )?; + } + for value in project.relations.values() { + self.insert(value.id, &value.alias, Category::Relation, at, budget)?; + } + for value in project.diagrams.values() { + self.insert(value.id, &value.alias, Category::Diagram, at, budget)?; + for node in value.nodes.values() { + self.insert(node.id, &node.alias, Category::Node, at, budget)?; + } + } + for value in project.artifact_links.values() { + self.insert(value.id, &value.alias, Category::Artifact, at, budget)?; + } + for value in project.traces.values() { + self.insert(value.id, &value.alias, Category::Trace, at, budget)?; + } + Ok(()) + } + + fn typed(&mut self, declaration: &Declaration, budget: &Budget) -> Result<(), Error> { + let header = &declaration.header; + let id = identity(&header.identity, header.alias.span)?; + self.insert( + id, + &header.alias.name, + category(header.kind), + header.alias.span, + budget, + )?; + if let d::Body::Calculation { parameters, .. } = &declaration.body { + let mut names = BTreeMap::new(); + let mut ids = BTreeSet::new(); + for parameter in parameters { + self.reserve(parameter.span, budget)?; + let id = a::ParameterId(identity(¶meter.identity, parameter.name.span)?); + if names.contains_key(¶meter.name.name) || !ids.insert(id) { + return Err(error( + parameter.name.span, + "Duplicate parameter name or identity.", + )); + } + names.insert(parameter.name.name.clone(), id); + } + self.parameters.insert(id, names); + } + Ok(()) + } +} + +/// Inputs must come from the same live Budget and stabilization pass. No scan, +/// exact conversion or reference charge occurs here. The ordinary skeleton is +/// not semantically admitted: its typed forward references may need additions. +pub(super) fn lower( + ordinary: &Project, + units: &[Unit], + budget: &Budget, +) -> Result { + budget + .active() + .map_err(|error| Failure { file: None, error })?; + let mut tables = Tables::default(); + tables + .ordinary(ordinary, budget) + .map_err(|error| Failure { file: None, error })?; + let mut files = BTreeSet::new(); + for unit in units { + budget + .lowering_entries(files.len() + 1, Span::default()) + .map_err(|error| Failure { + file: Some(unit.file), + error, + })?; + if !files.insert(unit.file) { + return Err(Failure { + file: Some(unit.file), + error: error(Span::default(), "Duplicate source file key."), + }); + } + for declaration in &unit.declarations { + tables.typed(declaration, budget).map_err(|error| Failure { + file: Some(unit.file), + error, + })?; + } + } + let mut output = Lowered { + definitions: BTreeMap::new(), + elements: BTreeMap::new(), + occurrences: Vec::new(), + expressions: Vec::new(), + }; + for unit in units { + for declaration in &unit.declarations { + let owner = declaration + .header + .identity + .value + .expect("collected stable identity"); + let mut resolver = Resolver { + tables: &tables, + output: &mut output, + budget, + file: unit.file, + owner, + }; + resolver.declaration(declaration).map_err(|error| Failure { + file: Some(unit.file), + error, + })?; + } + } + Ok(output) +} + +struct Resolver<'a> { + tables: &'a Tables, + output: &'a mut Lowered, + budget: &'a Budget, + file: usize, + owner: Id, +} + +impl Resolver<'_> { + fn occurrence( + &mut self, + reference: &Reference, + field: Field, + expression: Option, + role: Role, + target: Target, + ) -> Result<(), Error> { + self.budget + .lowering_occurrences(self.output.occurrences.len() + 1, reference.span)?; + self.output.occurrences.push(Occurrence { + file: self.file, + owner: self.owner, + field, + expression, + role, + target, + span: reference.span, + }); + Ok(()) + } + + fn global( + &mut self, + reference: &Reference, + field: Field, + expression: Option, + role: Role, + expected: impl FnOnce(Category) -> bool, + ) -> Result { + let symbol = *self.tables.aliases.get(&reference.name).ok_or_else(|| { + error( + reference.span, + format!("Unknown global reference '@{}'.", reference.name), + ) + })?; + if !expected(symbol.category) { + return Err(error( + reference.span, + format!( + "Wrong reference domain for '@{}': {:?}.", + reference.name, symbol.category + ), + )); + } + self.occurrence( + reference, + field, + expression, + role, + Target::Global(symbol.id), + )?; + Ok(symbol) + } + + fn definition_ref( + &mut self, + reference: &Reference, + field: Field, + expression: Option, + expected: Category, + ) -> Result { + self.global(reference, field, expression, Role::Reference, |category| { + category == expected + }) + .map(|symbol| a::DefinitionRef::Local(symbol.id)) + } + + fn value_type( + &mut self, + value: &d::TypeSyntax, + field: Field, + ) -> Result { + let scalar = + match &value.scalar.kind { + d::ScalarKind::Boolean => a::ScalarType::Boolean, + d::ScalarKind::Number => a::ScalarType::Number, + d::ScalarKind::Quantity(reference) => a::ScalarType::Quantity( + self.definition_ref(reference, field, None, Category::QuantityKind)?, + ), + }; + Ok(if value.list { + a::AnalysisType::List(scalar) + } else { + a::AnalysisType::Scalar(scalar) + }) + } + + fn subject(&mut self, reference: &Reference) -> Result { + let symbol = self.global( + reference, + Field::Subject, + None, + Role::Reference, + |category| matches!(category, Category::Element(..) | Category::Relation), + )?; + Ok(if symbol.category == Category::Relation { + a::AnalysisSubject::Relation(symbol.id) + } else { + a::AnalysisSubject::Element(symbol.id) + }) + } + + fn optional_subject( + &mut self, + subject: &Option>>, + ) -> Result, Error> { + subject + .as_ref() + .and_then(|field| field.value.as_ref()) + .map(|reference| self.subject(reference)) + .transpose() + } + + fn declaration(&mut self, declaration: &Declaration) -> Result<(), Error> { + let header = &declaration.header; + let definition = match &declaration.body { + d::Body::QuantityKind { dimension, algebra } => { + Some(a::DefinitionBody::QuantityKind(a::QuantityKindDefinition { + dimension: Dimension::new(dimension.value.exponents) + .map_err(|cause| error(dimension.value_span, cause.to_string()))?, + algebra: match &algebra.value { + d::AlgebraSyntax::Linear => a::QuantityAlgebra::Linear, + d::AlgebraSyntax::Affine(reference) => a::QuantityAlgebra::Affine { + difference: self.definition_ref( + reference, + Field::Algebra, + None, + Category::QuantityKind, + )?, + }, + }, + })) + } + d::Body::Unit { + kind, + scale, + offset, + symbol, + } => Some(a::DefinitionBody::Unit(a::UnitDefinition { + kind: self.definition_ref( + &kind.value, + Field::Kind, + None, + Category::QuantityKind, + )?, + scale: scale.value.value.clone(), + offset: offset.value.value.clone(), + symbol: symbol.value.clone(), + })), + d::Body::Calculation { + parameters, + result, + body, + } => { + let mut lowered = Vec::new(); + for parameter in parameters { + self.budget + .lowering_entries(lowered.len() + 1, parameter.span)?; + let id = self.tables.parameters[&self.owner][¶meter.name.name]; + self.occurrence( + ¶meter.name, + Field::Input(id), + None, + Role::ParameterDeclaration, + Target::Parameter { + calculation: self.owner, + parameter: id, + }, + )?; + lowered.push(a::Parameter { + id, + name: parameter.name.name.clone(), + value_type: self.value_type(¶meter.value_type, Field::Input(id))?, + }); + } + Some(a::DefinitionBody::Calculation(a::CalculationDefinition { + parameters: lowered, + result: self.value_type(&result.value, Field::Result)?, + body: self.binding(&body.value, Field::Body, Some(self.owner))?, + })) + } + _ => None, + }; + let description = declaration + .description + .as_ref() + .map_or_else(String::new, |field| field.value.clone()); + if let Some(body) = definition { + self.output.definitions.insert( + self.owner, + a::Definition { + id: self.owner, + alias: header.alias.name.clone(), + name: header.name.clone(), + description, + body, + }, + ); + return Ok(()); + } + let (layer, body) = match (&declaration.body, header.kind) { + ( + d::Body::Property { + subject, + value_type, + base, + display_unit, + }, + DeclarationKind::Property(layer), + ) => ( + layer, + SharedBody::EngineeringProperty { + subject: self.subject(&subject.value)?, + value_type: self.value_type(&value_type.value, Field::Type)?, + base: self.binding(&base.value, Field::Base, None)?, + display_unit: display_unit + .as_ref() + .and_then(|field| field.value.as_ref()) + .map(|reference| { + self.definition_ref(reference, Field::DisplayUnit, None, Category::Unit) + }) + .transpose()?, + }, + ), + ( + d::Body::Check { + subject, + documented_constraint, + predicate, + }, + DeclarationKind::Check(layer), + ) => ( + layer, + SharedBody::AnalysisCheck { + subject: self.optional_subject(subject)?, + documented_constraint: documented_constraint + .as_ref() + .and_then(|field| field.value.as_ref()) + .map(|reference| { + self.global( + reference, + Field::DocumentedConstraint, + None, + Role::Reference, + |category| { + matches!(category, Category::Element(Kind::Constraint, _)) + }, + ) + .map(|symbol| symbol.id) + }) + .transpose()?, + predicate: self.binding(&predicate.value, Field::Predicate, None)?, + }, + ), + ( + d::Body::Case { + subject, overrides, .. + }, + DeclarationKind::Case(layer), + ) => { + let subject = self.optional_subject(subject)?; + let mut lowered = BTreeMap::new(); + for entry in &overrides.value { + let property = self + .global( + &entry.target, + Field::Base, + None, + Role::OverrideTarget, + |category| { + matches!(category, Category::Element(Kind::EngineeringProperty, _)) + }, + )? + .id; + // Replace the placeholder field after resolution so the key + // and its binding share their stable override identity. + self.output + .occurrences + .last_mut() + .expect("override occurrence") + .field = Field::Override(property); + if lowered.contains_key(&property) { + return Err(error( + entry.target.span, + "Duplicate resolved override target.", + )); + } + self.budget + .lowering_entries(lowered.len() + 1, entry.span)?; + lowered.insert( + property, + self.binding(&entry.binding, Field::Override(property), None)?, + ); + } + ( + layer, + SharedBody::AnalysisCase { + subject, + base: a::BaselineBase::ModelBaseline, + overrides: lowered, + }, + ) + } + _ => { + return Err(error( + header.kind_span, + "Typed declaration header/body mismatch.", + )); + } + }; + self.output.elements.insert( + self.owner, + Element { + id: self.owner, + alias: header.alias.name.clone(), + name: header.name.clone(), + description, + parent: None, + body: ElementBody::shared_in(layer, body), + }, + ); + Ok(()) + } + + fn binding( + &mut self, + binding: &Binding, + field: Field, + calculation: Option, + ) -> Result { + match binding { + Binding::Unbound(_) => Ok(a::Binding::Unbound), + Binding::Expression(expression) => self + .expression(expression, field, calculation) + .map(a::Binding::Expression), + } + } + + fn call( + &mut self, + definition: &Reference, + arguments: &[Argument], + field: Field, + site: usize, + ) -> Result { + let calculation = self + .global( + definition, + field, + Some(site), + Role::CallTarget, + |category| category == Category::Calculation, + )? + .id; + let tables = self.tables; + let signature = &tables.parameters[&calculation]; + let mut parameters = Vec::new(); + let mut seen = BTreeSet::new(); + for argument in arguments { + self.budget + .lowering_entries(parameters.len() + 1, argument.parameter.span)?; + let parameter = *signature.get(&argument.parameter.name).ok_or_else(|| { + error( + argument.parameter.span, + "Unknown argument name in called calculation.", + ) + })?; + if !seen.insert(parameter) { + return Err(error( + argument.parameter.span, + "Duplicate resolved argument parameter.", + )); + } + self.occurrence( + &argument.parameter, + field, + Some(site), + Role::ArgumentLabel, + Target::Parameter { + calculation, + parameter, + }, + )?; + parameters.push(parameter); + } + // Argument-set/type checking remains in existing semantic admission. + Ok(Call { + calculation, + parameters, + }) + } + + fn expression( + &mut self, + root: &Expression, + field: Field, + calculation: Option, + ) -> Result { + enum Work<'a> { + Visit(&'a Expression, Option<(usize, Child)>), + Build(&'a Expression, usize, Option, usize), + } + let mut work = vec![Work::Visit(root, None)]; + let mut values = Vec::new(); + while let Some(task) = work.pop() { + match task { + Work::Visit(expression, parent) => { + self.budget + .lowering_entries(self.output.expressions.len() + 1, expression.span)?; + let site = self.output.expressions.len(); + self.output.expressions.push(ExpressionSite { + file: self.file, + owner: self.owner, + field, + parent, + span: expression.span, + }); + let call = if let ExpressionKind::Call { + definition, + arguments, + } = &expression.kind + { + Some(self.call(definition, arguments, field, site)?) + } else { + None + }; + let child_count = match &expression.kind { + ExpressionKind::List(items) => items.len(), + ExpressionKind::Call { arguments, .. } => arguments.len(), + ExpressionKind::Unary { .. } + | ExpressionKind::Power { .. } + | ExpressionKind::AsKind { .. } => 1, + ExpressionKind::Binary { .. } | ExpressionKind::Sum { .. } => 2, + ExpressionKind::If { .. } => 3, + _ => 0, + }; + self.budget + .lowering_entries(work.len() + child_count + 1, expression.span)?; + let mut children = Vec::with_capacity(child_count); + match &expression.kind { + ExpressionKind::List(items) => children.extend( + items + .iter() + .enumerate() + .map(|(index, child)| (child, Child::Item(index))), + ), + ExpressionKind::Unary { operand, .. } => { + children.push((operand.as_ref(), Child::Operand)) + } + ExpressionKind::Binary { left, right, .. } => children + .extend([(left.as_ref(), Child::Left), (right.as_ref(), Child::Right)]), + ExpressionKind::If { + condition, + when_true, + when_false, + } => children.extend([ + (condition.as_ref(), Child::Condition), + (when_true.as_ref(), Child::WhenTrue), + (when_false.as_ref(), Child::WhenFalse), + ]), + ExpressionKind::Call { arguments, .. } => children.extend( + arguments + .iter() + .zip(&call.as_ref().expect("call signature").parameters) + .map(|(argument, id)| (&argument.value, Child::Argument(*id))), + ), + ExpressionKind::Sum { values, initial } => children.extend([ + (values.as_ref(), Child::Values), + (initial.as_ref(), Child::Initial), + ]), + ExpressionKind::Power { base, .. } => { + children.push((base.as_ref(), Child::Base)) + } + ExpressionKind::AsKind { value, .. } => { + children.push((value.as_ref(), Child::Value)) + } + _ => {} + } + work.push(Work::Build(expression, site, call, children.len())); + work.extend( + children + .into_iter() + .rev() + .map(|(child, edge)| Work::Visit(child, Some((site, edge)))), + ); + } + Work::Build(expression, site, call, count) => { + let start = values.len() - count; + let mut children = values.split_off(start).into_iter(); + let next = |children: &mut std::vec::IntoIter| { + Box::new(children.next().expect("visited expression child")) + }; + use ExpressionKind as E; + let value = match &expression.kind { + E::Boolean(value) => a::Expr::Boolean(*value), + E::Number(value) => a::Expr::Number(value.clone()), + E::Quantity { magnitude, unit } => a::Expr::Quantity { + magnitude: magnitude.clone(), + unit: self.definition_ref(unit, field, Some(site), Category::Unit)?, + }, + E::Property(reference) => a::Expr::Property( + self.global( + reference, + field, + Some(site), + Role::Reference, + |category| { + matches!( + category, + Category::Element(Kind::EngineeringProperty, _) + ) + }, + )? + .id, + ), + E::Parameter(reference) => { + let calculation = calculation.ok_or_else(|| { + error( + reference.span, + "Parameter use is only valid in its calculation body.", + ) + })?; + let parameter = *self.tables.parameters[&calculation] + .get(&reference.name) + .ok_or_else(|| { + error( + reference.span, + "Unknown parameter in owning calculation.", + ) + })?; + self.occurrence( + reference, + field, + Some(site), + Role::ParameterUse, + Target::Parameter { + calculation, + parameter, + }, + )?; + a::Expr::Parameter(parameter) + } + E::List(_) => a::Expr::List(children.collect()), + E::Unary { op, .. } => a::Expr::Unary { + op: *op, + operand: next(&mut children), + }, + E::Binary { op, .. } => a::Expr::Binary { + op: *op, + left: next(&mut children), + right: next(&mut children), + }, + E::If { .. } => a::Expr::If { + condition: next(&mut children), + when_true: next(&mut children), + when_false: next(&mut children), + }, + E::Call { .. } => { + let call = call.expect("resolved call"); + a::Expr::Call { + definition: a::DefinitionRef::Local(call.calculation), + arguments: call.parameters.into_iter().zip(children).collect(), + } + } + E::Sum { .. } => a::Expr::Sum { + values: next(&mut children), + initial: next(&mut children), + }, + E::Power { exponent, .. } => a::Expr::Power { + base: next(&mut children), + exponent: *exponent, + }, + E::AsKind { kind, .. } => a::Expr::AsKind { + kind: self.definition_ref( + kind, + field, + Some(site), + Category::QuantityKind, + )?, + value: next(&mut children), + }, + }; + self.budget + .lowering_entries(values.len() + 1, expression.span)?; + values.push(value); + } + } + } + Ok(values.pop().expect("one lowered expression")) + } +} + +struct Call { + calculation: Id, + parameters: Vec, +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-core/src/language/analysis_source/lower/fixtures/ordinary-oa.arcadia b/crates/oa-core/src/language/analysis_source/lower/fixtures/ordinary-oa.arcadia new file mode 100644 index 00000000..6c5cb791 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/lower/fixtures/ordinary-oa.arcadia @@ -0,0 +1,7 @@ +arcadia 7; +project response_budget "Response budget" id "4210b9f2-7046-47f0-aa3b-153f626a579d" { + oa_activity respond "Respond" id "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" { + oa_activity receive "Receive" id "4ab9a8a7-7552-488d-9862-e14b89e7b353" {} + oa_activity dispatch "Dispatch" id "6df991ba-4c5e-4535-99e2-524ae274ee8b" {} + } +} diff --git a/crates/oa-core/src/language/analysis_source/lower/fixtures/ordinary-sa.arcadia b/crates/oa-core/src/language/analysis_source/lower/fixtures/ordinary-sa.arcadia new file mode 100644 index 00000000..fa6f1cc1 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/lower/fixtures/ordinary-sa.arcadia @@ -0,0 +1,7 @@ +arcadia 7; +project response_budget "Response budget" id "c210b9f2-7046-47f0-aa3b-153f626a579d" { + sa_function respond "Respond" id "4abec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" { + sa_function receive "Receive" id "cab9a8a7-7552-488d-9862-e14b89e7b353" {} + sa_function dispatch "Dispatch" id "edf991ba-4c5e-4535-99e2-524ae274ee8b" {} + } +} diff --git a/crates/oa-core/src/language/analysis_source/lower/fixtures/typed-oa.arcadia b/crates/oa-core/src/language/analysis_source/lower/fixtures/typed-oa.arcadia new file mode 100644 index 00000000..eec4dd64 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/lower/fixtures/typed-oa.arcadia @@ -0,0 +1,43 @@ +// Staged typed roots from the r2 example; ordinary roots are supplied separately. +// Forward uses precede the neutral definitions. 雪 keeps offsets UTF-8 sensitive. +oa_property receive_time "Receive time" id "63a931c2-675a-4fce-89e5-04361cb99450" { + subject @receive; type Quantity(@duration); display_unit @millisecond; + base 750 [@millisecond]; +} +oa_property dispatch_time "Dispatch time" id "c33fc772-2a60-4044-9af9-73c61c563ace" { + subject @dispatch; type Quantity(@duration); display_unit @second; + base 1 [@second]; +} +oa_property total_time "Total time" id "bd5b68e8-65fc-4e35-9071-a73061316a4b" { + subject @respond; type Quantity(@duration); display_unit @second; + base @combine(a: @receive_time, b: @dispatch_time); +} +oa_property limit "Allowed duration" id "437bedbb-2cf4-44ee-a139-6563cc5086ec" { + subject @respond; type Quantity(@duration); display_unit @second; + base 2 [@second]; +} +oa_check budget "Response budget" id "bac0de99-9d2f-4e53-9158-d424ecac61bf" { + subject none; documented_constraint none; predicate @total_time <= @limit; +} +oa_analysis degraded "Degraded radio" id "38036c65-16d4-449c-837b-91eb141c1d93" { + subject none; base ModelBaseline; + overrides { @receive_time = 1250 [@millisecond]; }; +} +oa_analysis unmeasured "Unmeasured radio" id "99d3e09a-0ec3-4194-9dd4-9f3de6625cde" { + subject none; base ModelBaseline; + overrides { @receive_time = unbound; }; +} +calculation combine "Combine durations" id "fd7ce7ab-f789-40bc-b699-0c37f1b6902f" { + input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration); + input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration); + result Quantity(@duration); body $a + $b; +} +unit second "Second" id "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce" { + kind @duration; scale 1; offset 0; symbol "s"; +} +unit millisecond "Millisecond" id "39053f37-fec9-4708-9f0a-3959d223f891" { + kind @duration; scale ratio(1, 1000); offset 0; symbol "ms"; +} +quantity_kind duration "Duration" id "188b911c-5f8b-431c-9eea-2d41f3953767" { + dimension { time 1; }; algebra Linear; +} diff --git a/crates/oa-core/src/language/analysis_source/lower/fixtures/typed-sa.arcadia b/crates/oa-core/src/language/analysis_source/lower/fixtures/typed-sa.arcadia new file mode 100644 index 00000000..d660edcf --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/lower/fixtures/typed-sa.arcadia @@ -0,0 +1,43 @@ +// Staged typed roots from the r2 example; ordinary roots are supplied separately. +// Forward uses precede the neutral definitions. 雪 keeps offsets UTF-8 sensitive. +sa_property receive_time "Receive time" id "e3a931c2-675a-4fce-89e5-04361cb99450" { + subject @receive; type Quantity(@duration); display_unit @millisecond; + base 750 [@millisecond]; +} +sa_property dispatch_time "Dispatch time" id "433fc772-2a60-4044-9af9-73c61c563ace" { + subject @dispatch; type Quantity(@duration); display_unit @second; + base 1 [@second]; +} +sa_property total_time "Total time" id "3d5b68e8-65fc-4e35-9071-a73061316a4b" { + subject @respond; type Quantity(@duration); display_unit @second; + base @combine(a: @receive_time, b: @dispatch_time); +} +sa_property limit "Allowed duration" id "c37bedbb-2cf4-44ee-a139-6563cc5086ec" { + subject @respond; type Quantity(@duration); display_unit @second; + base 2 [@second]; +} +sa_check budget "Response budget" id "3ac0de99-9d2f-4e53-9158-d424ecac61bf" { + subject none; documented_constraint none; predicate @total_time <= @limit; +} +sa_analysis degraded "Degraded radio" id "b8036c65-16d4-449c-837b-91eb141c1d93" { + subject none; base ModelBaseline; + overrides { @receive_time = 1250 [@millisecond]; }; +} +sa_analysis unmeasured "Unmeasured radio" id "19d3e09a-0ec3-4194-9dd4-9f3de6625cde" { + subject none; base ModelBaseline; + overrides { @receive_time = unbound; }; +} +calculation combine "Combine durations" id "7d7ce7ab-f789-40bc-b699-0c37f1b6902f" { + input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration); + input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration); + result Quantity(@duration); body $a + $b; +} +unit second "Second" id "d34f4672-1ba5-4157-8b0a-b9cf41d0a3ce" { + kind @duration; scale 1; offset 0; symbol "s"; +} +unit millisecond "Millisecond" id "b9053f37-fec9-4708-9f0a-3959d223f891" { + kind @duration; scale ratio(1, 1000); offset 0; symbol "ms"; +} +quantity_kind duration "Duration" id "988b911c-5f8b-431c-9eea-2d41f3953767" { + dimension { time 1; }; algebra Linear; +} diff --git a/crates/oa-core/src/language/analysis_source/lower/oracle.rs b/crates/oa-core/src/language/analysis_source/lower/oracle.rs new file mode 100644 index 00000000..7acca881 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/lower/oracle.rs @@ -0,0 +1,263 @@ +//! Independent model construction: never parses source or calls the writer. +use crate::analysis::*; +use crate::analysis_numeric::{Dimension, Exact, ExactWire, WorkLimits, WorkMeter}; +use crate::*; +use std::collections::BTreeMap; + +pub(super) const NAMES: [(&str, &str); 14] = [ + ("duration", "Duration"), + ("second", "Second"), + ("millisecond", "Millisecond"), + ("combine", "Combine durations"), + ("respond", "Respond"), + ("receive", "Receive"), + ("dispatch", "Dispatch"), + ("receive_time", "Receive time"), + ("dispatch_time", "Dispatch time"), + ("total_time", "Total time"), + ("limit", "Allowed duration"), + ("budget", "Response budget"), + ("degraded", "Degraded radio"), + ("unmeasured", "Unmeasured radio"), +]; + +fn named(alias: &str) -> String { + NAMES + .iter() + .find(|(key, _)| *key == alias) + .unwrap() + .1 + .into() +} +fn local(id: Id) -> DefinitionRef { + DefinitionRef::Local(id) +} +fn quantity(value: i64, unit: Id) -> Expr { + Expr::Quantity { + magnitude: Exact::from_integer(value), + unit: local(unit), + } +} +fn expression(value: Expr) -> Binding { + Binding::Expression(value) +} +fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr { + Expr::Binary { + op, + left: Box::new(left), + right: Box::new(right), + } +} + +pub(super) fn project(layer: Layer) -> Project { + // OA is the fixed r2 set; SA independently constructs fresh global IDs. + let id = |text| { + let id = Id::parse_str(text).unwrap(); + if layer == Layer::Oa { + id + } else { + Id::from_u128(id.as_u128() ^ (1 << 127)) + } + }; + let project_id = id("4210b9f2-7046-47f0-aa3b-153f626a579d"); + let duration = id("188b911c-5f8b-431c-9eea-2d41f3953767"); + let second = id("534f4672-1ba5-4157-8b0a-b9cf41d0a3ce"); + let millisecond = id("39053f37-fec9-4708-9f0a-3959d223f891"); + let combine = id("fd7ce7ab-f789-40bc-b699-0c37f1b6902f"); + // Parameter IDs may be reused in another calculation/project. + let a = ParameterId(Id::parse_str("28ff40b0-1b8f-4735-8536-5a6ab21b56cb").unwrap()); + let b = ParameterId(Id::parse_str("7a47cc1e-8ae5-4551-b2d8-8a68108e0976").unwrap()); + let respond = id("cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59"); + let receive = id("4ab9a8a7-7552-488d-9862-e14b89e7b353"); + let dispatch = id("6df991ba-4c5e-4535-99e2-524ae274ee8b"); + let receive_time = id("63a931c2-675a-4fce-89e5-04361cb99450"); + let dispatch_time = id("c33fc772-2a60-4044-9af9-73c61c563ace"); + let total = id("bd5b68e8-65fc-4e35-9071-a73061316a4b"); + let limit = id("437bedbb-2cf4-44ee-a139-6563cc5086ec"); + let check = id("bac0de99-9d2f-4e53-9158-d424ecac61bf"); + let degraded = id("38036c65-16d4-449c-837b-91eb141c1d93"); + let unmeasured = id("99d3e09a-0ec3-4194-9dd4-9f3de6625cde"); + let duration_type = || AnalysisType::Scalar(ScalarType::Quantity(local(duration))); + let definition = |id, alias: &str, body| Definition { + id, + alias: alias.into(), + name: named(alias), + description: String::new(), + body, + }; + let shared = |id, alias: &str, body| Element { + id, + alias: alias.into(), + name: named(alias), + description: String::new(), + parent: None, + body: ElementBody::shared_in(layer, body), + }; + let work = |id, alias: &str, parent| Element { + id, + alias: alias.into(), + name: named(alias), + description: String::new(), + parent, + body: if layer == Layer::Oa { + ElementBody::Oa(OaBody::Activity) + } else { + ElementBody::Sa(SaBody::Function) + }, + }; + let definitions = [ + definition( + duration, + "duration", + DefinitionBody::QuantityKind(QuantityKindDefinition { + dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(), + algebra: QuantityAlgebra::Linear, + }), + ), + definition( + second, + "second", + DefinitionBody::Unit(UnitDefinition { + kind: local(duration), + scale: Exact::from_integer(1), + offset: Exact::from_integer(0), + symbol: "s".into(), + }), + ), + definition( + millisecond, + "millisecond", + DefinitionBody::Unit(UnitDefinition { + kind: local(duration), + scale: Exact::from_wire( + &ExactWire { + numerator: "1".into(), + denominator: "1000".into(), + }, + &mut WorkMeter::new(WorkLimits::REFERENCE), + ) + .unwrap(), + offset: Exact::from_integer(0), + symbol: "ms".into(), + }), + ), + definition( + combine, + "combine", + DefinitionBody::Calculation(CalculationDefinition { + parameters: vec![ + Parameter { + id: a, + name: "a".into(), + value_type: duration_type(), + }, + Parameter { + id: b, + name: "b".into(), + value_type: duration_type(), + }, + ], + result: duration_type(), + body: expression(binary( + BinaryOp::Add, + Expr::Parameter(a), + Expr::Parameter(b), + )), + }), + ), + ] + .into_iter() + .map(|definition| (definition.id, definition)) + .collect(); + let property = |id, alias, subject, display, base| { + shared( + id, + alias, + SharedBody::EngineeringProperty { + subject: AnalysisSubject::Element(subject), + value_type: duration_type(), + display_unit: Some(local(display)), + base: expression(base), + }, + ) + }; + let case = |id, alias, binding| { + shared( + id, + alias, + SharedBody::AnalysisCase { + subject: None, + base: BaselineBase::ModelBaseline, + overrides: BTreeMap::from([(receive_time, binding)]), + }, + ) + }; + let elements = [ + work(respond, "respond", None), + work(receive, "receive", Some(respond)), + work(dispatch, "dispatch", Some(respond)), + property( + receive_time, + "receive_time", + receive, + millisecond, + quantity(750, millisecond), + ), + property( + dispatch_time, + "dispatch_time", + dispatch, + second, + quantity(1, second), + ), + property( + total, + "total_time", + respond, + second, + Expr::Call { + definition: local(combine), + arguments: BTreeMap::from([ + (a, Expr::Property(receive_time)), + (b, Expr::Property(dispatch_time)), + ]), + }, + ), + property(limit, "limit", respond, second, quantity(2, second)), + shared( + check, + "budget", + SharedBody::AnalysisCheck { + subject: None, + documented_constraint: None, + predicate: expression(binary( + BinaryOp::LessOrEqual, + Expr::Property(total), + Expr::Property(limit), + )), + }, + ), + case( + degraded, + "degraded", + expression(quantity(1250, millisecond)), + ), + case(unmeasured, "unmeasured", Binding::Unbound), + ] + .into_iter() + .map(|element| (element.id, element)) + .collect(); + Project { + schema_version: 8, + id: project_id, + alias: "response_budget".into(), + name: "Response budget".into(), + description: String::new(), + definitions, + elements, + relations: BTreeMap::new(), + diagrams: BTreeMap::new(), + artifact_links: BTreeMap::new(), + traces: BTreeMap::new(), + } +} diff --git a/crates/oa-core/src/language/analysis_source/lower/tests.rs b/crates/oa-core/src/language/analysis_source/lower/tests.rs new file mode 100644 index 00000000..6ad6ac82 --- /dev/null +++ b/crates/oa-core/src/language/analysis_source/lower/tests.rs @@ -0,0 +1,875 @@ +use super::*; +use crate::language::ParseLimits; +use crate::language::analysis_source::{budget::Limits, cursor::Cursor}; + +#[path = "oracle.rs"] +mod oracle; + +const OA: &str = include_str!("fixtures/typed-oa.arcadia"); +const SA: &str = include_str!("fixtures/typed-sa.arcadia"); +const ORDINARY_OA: &str = include_str!("fixtures/ordinary-oa.arcadia"); +const ORDINARY_SA: &str = include_str!("fixtures/ordinary-sa.arcadia"); + +fn budget() -> Budget { + Budget::new(Limits::source8(ParseLimits::UNLIMITED)).unwrap() +} +fn id(text: &str) -> Id { + Id::parse_str(text).unwrap() +} +fn by_alias(project: &Project, alias: &str) -> Id { + project + .identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 +} +fn ordinary(source: &str) -> Project { + crate::language::parse(source).unwrap() +} +fn unit(source: &str, file: usize, budget: &mut Budget) -> Unit { + let tokens = lex::tokens(source, budget).unwrap(); + let mut cursor = Cursor::new(source, &tokens, budget); + let mut declarations = Vec::new(); + while cursor.cursor < tokens.len() { + declarations.push(d::parse(&mut cursor, 8, None).unwrap()); + } + Unit { file, declarations } +} +fn merge(mut ordinary: Project, lowered: Lowered) -> Project { + ordinary.definitions.extend(lowered.definitions); + ordinary.elements.extend(lowered.elements); + ordinary +} +fn admit(project: &Project) { + a::admit(project, a::AdmissionLimits::REFERENCE).unwrap(); +} +fn failure(source: &str, ordinary: &Project) -> Failure { + let mut budget = budget(); + let unit = unit(source, 7, &mut budget); + lower(ordinary, &[unit], &budget).unwrap_err() +} + +#[test] +fn staged_oa_and_sa_lowering_match_complete_independent_17_uuid_oracles() { + for (layer, ordinary_source, typed_source) in + [(Layer::Oa, ORDINARY_OA, OA), (Layer::Sa, ORDINARY_SA, SA)] + { + let expected = oracle::project(layer); + assert_eq!(expected.name, "Response budget"); + assert_eq!(expected.identities().len(), 15); // two additional scoped IDs + for (alias, name) in oracle::NAMES { + let object = by_alias(&expected, alias); + let actual_name = expected + .definitions + .get(&object) + .map(|value| value.name.as_str()) + .or_else(|| { + expected + .elements + .get(&object) + .map(|value| value.name.as_str()) + }) + .unwrap(); + assert_eq!(actual_name, name); + assert_ne!(actual_name, alias); + } + let mut budget = budget(); + let units = [unit(typed_source, 17, &mut budget)]; + let usage = budget.usage; + let numeric = budget.numeric_units(); + let skeleton = ordinary(ordinary_source); + let first = lower(&skeleton, &units, &budget).unwrap(); + let second = lower(&skeleton, &units, &budget).unwrap(); + assert_eq!(first.occurrences, second.occurrences); + assert_eq!(first.expressions, second.expressions); + assert_eq!(budget.usage, usage); + assert_eq!(budget.numeric_units(), numeric); + assert_eq!( + first + .occurrences + .iter() + .filter(|occurrence| occurrence.role != Role::ParameterDeclaration) + .count(), + usage.references + ); + let actual = merge(skeleton, first); + assert_eq!(actual, expected); + admit(&actual); // the complete model's one semantic admission + } + assert!(crate::language::parse(&ORDINARY_OA.replacen("arcadia 7", "arcadia 8", 1)).is_err()); +} + +#[test] +fn forward_cross_unit_resolution_keeps_original_offsets_and_shared_work() { + let source = OA.replace('\n', "\r\n"); + let split = source.find("calculation combine").unwrap(); + let sources = [&source[..split], &source[split..]]; + let mut budget = budget(); + let mut units = [ + unit(sources[0], 4, &mut budget), + unit(sources[1], 9, &mut budget), + ]; + let usage = budget.usage; + assert_eq!(usage.bytes, source.len()); + let lowered = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap(); + units.reverse(); + // This second order uses the same bounded lowerer; output models must not + // depend on declaration/file traversal order. It is not an include parser. + let reordered = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap(); + assert_eq!( + merge(ordinary(ORDINARY_OA), reordered), + oracle::project(Layer::Oa) + ); + for occurrence in &lowered.occurrences { + let source = if occurrence.file == 4 { + sources[0] + } else { + assert_eq!(occurrence.file, 9); + sources[1] + }; + let text = &source[occurrence.span.start..occurrence.span.end]; + assert!(crate::valid_alias(text)); + assert!(!text.starts_with('@') && !text.starts_with('$')); + } + assert_eq!(budget.usage, usage); + assert_eq!( + merge(ordinary(ORDINARY_OA), lowered), + oracle::project(Layer::Oa) + ); +} + +fn number(value: i64) -> a::Expr { + a::Expr::Number(crate::analysis_numeric::Exact::from_integer(value)) +} +fn binary(op: a::BinaryOp, left: a::Expr, right: a::Expr) -> a::Expr { + a::Expr::Binary { + op, + left: Box::new(left), + right: Box::new(right), + } +} + +#[test] +fn all_thirteen_expression_variants_lower_to_real_typed_values_and_admit() { + let mut project = oracle::project(Layer::Oa); + let duration = by_alias(&project, "duration"); + let second = by_alias(&project, "second"); + let property = by_alias(&project, "receive_time"); + let owner = by_alias(&project, "total_time"); + let calculation = by_alias(&project, "combine"); + let parameter = a::ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb")); + let b = a::ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976")); + let quantity = + a::AnalysisType::Scalar(a::ScalarType::Quantity(a::DefinitionRef::Local(duration))); + let numeric = a::AnalysisType::Scalar(a::ScalarType::Number); + let boolean = a::AnalysisType::Scalar(a::ScalarType::Boolean); + let cases = [ + ("true", a::Expr::Boolean(true), boolean.clone()), + ( + "ratio(3, 2)", + a::Expr::Number( + Exact::from_wire( + &crate::analysis_numeric::ExactWire { + numerator: "3".into(), + denominator: "2".into(), + }, + &mut crate::analysis_numeric::WorkMeter::new( + crate::analysis_numeric::WorkLimits::REFERENCE, + ), + ) + .unwrap(), + ), + numeric.clone(), + ), + ( + "2 [@second]", + a::Expr::Quantity { + magnitude: Exact::from_integer(2), + unit: a::DefinitionRef::Local(second), + }, + quantity.clone(), + ), + ( + "@receive_time", + a::Expr::Property(property), + quantity.clone(), + ), + ("$a", a::Expr::Parameter(parameter), quantity.clone()), + ( + "[1, 2]", + a::Expr::List(vec![number(1), number(2)]), + a::AnalysisType::List(a::ScalarType::Number), + ), + ( + "abs(-2)", + a::Expr::Unary { + op: a::UnaryOp::Absolute, + operand: Box::new(a::Expr::Unary { + op: a::UnaryOp::Negate, + operand: Box::new(number(2)), + }), + }, + numeric.clone(), + ), + ( + "1 + 2", + binary(a::BinaryOp::Add, number(1), number(2)), + numeric.clone(), + ), + ( + "if(true, 1, 2)", + a::Expr::If { + condition: Box::new(a::Expr::Boolean(true)), + when_true: Box::new(number(1)), + when_false: Box::new(number(2)), + }, + numeric.clone(), + ), + ( + "@combine(b: 2 [@second], a: 1 [@second])", + a::Expr::Call { + definition: a::DefinitionRef::Local(calculation), + arguments: BTreeMap::from([ + ( + parameter, + a::Expr::Quantity { + magnitude: Exact::from_integer(1), + unit: a::DefinitionRef::Local(second), + }, + ), + ( + b, + a::Expr::Quantity { + magnitude: Exact::from_integer(2), + unit: a::DefinitionRef::Local(second), + }, + ), + ]), + }, + quantity.clone(), + ), + ( + "sum([1, 2], 0)", + a::Expr::Sum { + values: Box::new(a::Expr::List(vec![number(1), number(2)])), + initial: Box::new(number(0)), + }, + numeric.clone(), + ), + ( + "power(2, -2)", + a::Expr::Power { + base: Box::new(number(2)), + exponent: -2, + }, + numeric.clone(), + ), + ( + "as_kind(@duration, 1 [@second])", + a::Expr::AsKind { + kind: a::DefinitionRef::Local(duration), + value: Box::new(a::Expr::Quantity { + magnitude: Exact::from_integer(1), + unit: a::DefinitionRef::Local(second), + }), + }, + quantity.clone(), + ), + ]; + // Remove the original check's dependent quantity comparison: each case + // installs a different declared result type on total_time below. + project.elements.remove(&by_alias(&project, "budget")); + for (source, expected, result_type) in cases { + let mut budget = budget(); + let Binding::Expression(syntax) = super::super::parse(source, &mut budget).unwrap() else { + panic!("expression") + }; + let mut tables = Tables::default(); + tables.ordinary(&project, &budget).unwrap(); + let mut output = Lowered { + definitions: BTreeMap::new(), + elements: BTreeMap::new(), + occurrences: Vec::new(), + expressions: Vec::new(), + }; + let is_parameter = source == "$a"; + let expression_owner = if is_parameter { calculation } else { owner }; + let actual = Resolver { + tables: &tables, + output: &mut output, + budget: &budget, + file: 3, + owner: expression_owner, + } + .expression( + &syntax, + if is_parameter { + Field::Body + } else { + Field::Base + }, + is_parameter.then_some(calculation), + ) + .unwrap(); + assert_eq!(actual, expected, "{source}"); + assert_eq!(output.expressions[0].span, syntax.span); + assert!(output.expressions[0].parent.is_none()); + let mut candidate = project.clone(); + if is_parameter { + let a::DefinitionBody::Calculation(definition) = + &mut candidate.definitions.get_mut(&calculation).unwrap().body + else { + panic!("calculation") + }; + definition.body = a::Binding::Expression(actual); + } else { + let SharedBody::EngineeringProperty { + value_type, + display_unit, + base, + .. + } = candidate + .elements + .get_mut(&owner) + .unwrap() + .body + .shared_mut() + .unwrap() + else { + panic!("property") + }; + *value_type = result_type; + *display_unit = None; + *base = a::Binding::Expression(actual); + } + admit(&candidate); + } +} + +#[test] +fn call_sites_and_parameter_scopes_preserve_distinct_original_occurrences() { + let source = OA.replace("base @combine(a: @receive_time, b: @dispatch_time);", "base @combine(a: @receive_time, /* first */ b: @dispatch_time) + @combine(/* second */ b: @dispatch_time, a: @receive_time);") + r#" +calculation other "Other" id "00000000-0000-0000-0000-000000000020" { + input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Number; + result Number; body $a; +} +quantity_kind collision "Global ID equals a scoped ID" id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" { dimension {}; algebra Linear; } +calculation caller "Caller" id "00000000-0000-0000-0000-000000000021" { + input a id "00000000-0000-0000-0000-000000000022" : Number; + result Number; body @other(a: $a) + @other(a: 3); +} +"#; + let mut budget = budget(); + let units = [unit(&source, 2, &mut budget)]; + let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap(); + let total = id("bd5b68e8-65fc-4e35-9071-a73061316a4b"); + let combine = id("fd7ce7ab-f789-40bc-b699-0c37f1b6902f"); + let labels: Vec<_> = output + .occurrences + .iter() + .filter(|value| value.owner == total && value.role == Role::ArgumentLabel) + .collect(); + assert_eq!(labels.len(), 4); + assert_eq!( + labels + .iter() + .map(|value| value.span.start) + .collect::>() + .len(), + 4 + ); + let sites: BTreeSet<_> = labels + .iter() + .map(|value| value.expression.unwrap()) + .collect(); + assert_eq!(sites.len(), 2); + let paths: Vec<_> = sites + .iter() + .map(|site| output.expressions[*site].parent.unwrap().1) + .collect(); + assert_eq!(paths, [Child::Left, Child::Right]); + for label in labels { + assert_eq!(label.field, Field::Base); + assert!( + matches!(label.target, Target::Parameter { calculation, .. } if calculation == combine) + ); + assert!(matches!( + &source[label.span.start..label.span.end], + "a" | "b" + )); + } + let reused = a::ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb")); + let scopes: BTreeSet<_> = output + .occurrences + .iter() + .filter_map(|occurrence| match occurrence.target { + Target::Parameter { + calculation, + parameter, + } if parameter == reused => Some(calculation), + _ => None, + }) + .collect(); + assert_eq!( + scopes, + BTreeSet::from([combine, id("00000000-0000-0000-0000-000000000020")]) + ); + let caller = id("00000000-0000-0000-0000-000000000021"); + let use_in_caller = output + .occurrences + .iter() + .find(|value| value.owner == caller && value.role == Role::ParameterUse) + .unwrap(); + assert_eq!( + use_in_caller.target, + Target::Parameter { + calculation: caller, + parameter: a::ParameterId(id("00000000-0000-0000-0000-000000000022")) + } + ); + for site in &output.expressions { + assert_eq!(site.file, 2); + assert!(source.get(site.span.start..site.span.end).is_some()); + } + admit(&merge(ordinary(ORDINARY_OA), output)); +} + +#[test] +fn missing_ids_require_stabilization_without_uuid_or_budget_churn() { + for source in [ + r#"calculation pending "Pending" { result Number; body unbound; }"#, + r#"calculation pending "Pending" id "00000000-0000-0000-0000-000000000040" { input value : Number; result Number; body $value; }"#, + ] { + let mut budget = budget(); + let units = [unit(source, 3, &mut budget)]; + let usage = budget.usage; + let skeleton = ordinary(ORDINARY_OA); + let first = lower(&skeleton, &units, &budget).unwrap_err(); + assert_eq!(first, lower(&skeleton, &units, &budget).unwrap_err()); + assert!(first.error.message.contains("stabilization")); + assert_eq!(budget.usage, usage); + let declaration = &units[0].declarations[0]; + if declaration.header.identity.value.is_none() { + assert!(declaration.header.identity.insertion.is_some()); + } else { + let d::Body::Calculation { parameters, .. } = &declaration.body else { + panic!("calculation") + }; + assert!(parameters[0].identity.value.is_none()); + assert!(parameters[0].identity.insertion.is_some()); + } + } +} + +#[test] +fn duplicate_global_aliases_and_ids_fail_before_body_resolution() { + let ordinary = ordinary(ORDINARY_OA); + for (source, message) in [ + ( + OA.replace("quantity_kind duration", "quantity_kind respond"), + "Duplicate global alias", + ), + ( + OA.replace( + "188b911c-5f8b-431c-9eea-2d41f3953767", + "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59", + ), + "Duplicate global identity", + ), + (format!("{OA}\n{OA}"), "Duplicate global"), + ] { + let failed = failure(&source, &ordinary); + assert_eq!(failed.file, Some(7)); + assert!(failed.error.message.contains(message), "{:?}", failed); + assert!(!failed.error.message.contains("Unknown")); + } + let mut budget = budget(); + let units = [unit(OA, 3, &mut budget), unit(OA, 5, &mut budget)]; + let failed = lower(&ordinary, &units, &budget).unwrap_err(); + assert_eq!(failed.file, Some(5)); + assert!(failed.error.message.contains("Duplicate global identity")); +} + +#[test] +fn typed_reference_domains_unknown_names_and_parameter_scope_fail_at_tokens() { + let skeleton = ordinary(ORDINARY_OA); + for (old, new, token, message) in [ + ( + "subject @receive", + "subject @second", + "second", + "Wrong reference domain", + ), + ( + "Quantity(@duration)", + "Quantity(@second)", + "second", + "Wrong reference domain", + ), + ( + "display_unit @second", + "display_unit @duration", + "duration", + "Wrong reference domain", + ), + ( + "750 [@millisecond]", + "750 [@duration]", + "duration", + "Wrong reference domain", + ), + ( + "base 1 [@second]", + "base @respond", + "respond", + "Wrong reference domain", + ), + ( + "@combine(a:", + "@second(a:", + "second", + "Wrong reference domain", + ), + ( + "@combine(a:", + "@combine(unknown:", + "unknown", + "Unknown argument", + ), + ( + "body $a + $b", + "body $outside + $b", + "outside", + "Unknown parameter", + ), + ( + "base 1 [@second]", + "base $a", + "a", + "only valid in its calculation", + ), + ( + "subject @receive", + "subject @missing", + "missing", + "Unknown global", + ), + ( + "documented_constraint none", + "documented_constraint @respond", + "respond", + "Wrong reference domain", + ), + ( + "@receive_time = unbound", + "@second = unbound", + "second", + "Wrong reference domain", + ), + ] { + let source = OA.replace(old, new); + assert_ne!(source, OA, "mutation {old}"); + let failed = failure(&source, &skeleton); + assert!( + failed.error.message.contains(message), + "{new}: {:?}", + failed + ); + assert_eq!( + &source[failed.error.span.start..failed.error.span.end], + token + ); + } +} + +#[test] +fn ordinary_non_element_categories_are_collected_and_never_property_fallbacks() { + let mut skeleton = ordinary(ORDINARY_OA); + let respond = by_alias(&skeleton, "respond"); + let receive = by_alias(&skeleton, "receive"); + let relation = Id::from_u128(100); + skeleton.relations.insert( + relation, + crate::Relation { + id: relation, + alias: "link".into(), + name: "Link".into(), + description: String::new(), + body: crate::RelationBody::new( + crate::RelationKind::FunctionalExchange, + respond, + receive, + ), + }, + ); + let diagram = skeleton.ensure_diagram(Layer::Oa); + skeleton.diagrams.get_mut(&diagram).unwrap().alias = "view".into(); + let node = skeleton.place(diagram, respond, crate::Position { x: 0., y: 0. }); + skeleton + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .alias = "placement".into(); + let artifact = Id::from_u128(101); + skeleton.artifact_links.insert( + artifact, + crate::ArtifactLink { + id: artifact, + alias: "document".into(), + name: "Document".into(), + description: String::new(), + owner: respond, + relation: crate::ArtifactRelation::References, + uri: "urn:test".into(), + revision: None, + fragment: None, + }, + ); + let trace = Id::from_u128(102); + skeleton.traces.insert( + trace, + crate::Trace { + id: trace, + alias: "mapping".into(), + name: "Mapping".into(), + description: String::new(), + oa: respond, + sa: receive, + kind: crate::TraceKind::Manual, + baseline: None, + }, + ); + // Resolution classification is independent of whole-model admission; the + // synthetic trace is deliberately not asserted to be a valid OA/SA trace. + let budget = budget(); + let mut tables = Tables::default(); + tables.ordinary(&skeleton, &budget).unwrap(); + for (alias, category) in [ + ("response_budget", Category::Project), + ("link", Category::Relation), + ("view", Category::Diagram), + ("placement", Category::Node), + ("document", Category::Artifact), + ("mapping", Category::Trace), + ] { + assert_eq!(tables.aliases[alias].category, category); + let failed = failure( + &OA.replace("base 1 [@second]", &format!("base @{alias}")), + &skeleton, + ); + assert!( + failed.error.message.contains("Wrong reference domain"), + "{:?}", + failed + ); + } + let source = OA.replace("subject @receive", "subject @link"); + let mut budget = self::budget(); + let units = [unit(&source, 1, &mut budget)]; + let output = lower(&skeleton, &units, &budget).unwrap(); + let property = output + .elements + .values() + .find(|value| value.alias == "receive_time") + .unwrap(); + assert!( + matches!(property.body.shared(), Some(SharedBody::EngineeringProperty { subject: a::AnalysisSubject::Relation(value), .. }) if *value == relation) + ); +} + +#[test] +fn structural_lowering_defers_type_purity_layer_and_argument_sets_to_admission() { + for (old, new) in [ + ("base 750 [@millisecond]", "base true"), + ("body $a + $b", "body @receive_time"), + ("a: @receive_time, b: @dispatch_time", "a: @receive_time"), + ("oa_property receive_time", "sa_property receive_time"), + ("base 1 [@second]", "base @dispatch_time"), + ] { + let source = OA.replace(old, new); + let mut budget = budget(); + let units = [unit(&source, 1, &mut budget)]; + let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap(); + let project = merge(ordinary(ORDINARY_OA), output); + assert!( + a::admit(&project, a::AdmissionLimits::REFERENCE).is_err(), + "{new}" + ); + } +} + +#[test] +fn lowering_phase_bounds_count_ordinary_globals_and_scoped_parameters_together() { + let ordinary = oracle::project(Layer::Oa); + for (maximum, passes) in [(0, false), (16, false), (17, true)] { + let mut limits = Limits::source8(ParseLimits::UNLIMITED); + limits.entries = maximum; + let budget = Budget::new(limits).unwrap(); + let result = lower(&ordinary, &[], &budget); + assert_eq!(result.is_ok(), passes, "entry bound {maximum}"); + if let Err(failure) = result { + assert!(failure.error.message.contains("lowering entry limit")); + assert_eq!(failure.file, None); + } + assert_eq!(budget.usage, budget::Usage::default()); + } +} + +#[test] +fn affine_kinds_optional_fields_list_signatures_and_unused_unbound_definitions_survive() { + let source = r#" +unit celsius "Celsius" id "00000000-0000-0000-0000-000000000061" { + kind @point; scale 1; offset ratio(27315, 100); symbol "°C"; +} +quantity_kind point "Temperature point" id "00000000-0000-0000-0000-000000000062" { + dimension { temperature 1; }; algebra Affine(@interval); +} +quantity_kind interval "Temperature interval" id "00000000-0000-0000-0000-000000000063" { + dimension { temperature 1; }; algebra Linear; +} +calculation unused "Unused list calculation" id "00000000-0000-0000-0000-000000000064" { + input xs id "00000000-0000-0000-0000-000000000065" : List(Quantity(@interval)); + result List(Quantity(@interval)); body unbound; description "保留"; +} +calculation empty "Empty calculation" id "00000000-0000-0000-0000-000000000066" { + result Boolean; body unbound; +} +oa_property ambient "Ambient temperature" id "00000000-0000-0000-0000-000000000067" { + subject @respond; type Quantity(@point); display_unit none; base 0 [@celsius]; parent none; +} +oa_check documented "Documented check" id "00000000-0000-0000-0000-000000000068" { + subject @respond; documented_constraint @requirement; predicate unbound; +} +oa_analysis changed "Changed temperature" id "00000000-0000-0000-0000-000000000069" { + subject @respond; base ModelBaseline; overrides { @ambient = unbound; }; +} +"#; + let mut skeleton = ordinary(ORDINARY_OA); + let requirement = Id::from_u128(70); + skeleton.elements.insert( + requirement, + Element { + id: requirement, + alias: "requirement".into(), + name: "Requirement".into(), + description: String::new(), + parent: None, + body: ElementBody::shared_in( + Layer::Oa, + SharedBody::Constraint { + expression: String::new(), + constrained: Vec::new(), + }, + ), + }, + ); + let mut budget = budget(); + let units = [unit(source, 8, &mut budget)]; + let output = lower(&skeleton, &units, &budget).unwrap(); + assert_eq!(output.definitions.len(), 5); + let point = id("00000000-0000-0000-0000-000000000062"); + let interval = id("00000000-0000-0000-0000-000000000063"); + let a::DefinitionBody::QuantityKind(kind) = &output.definitions[&point].body else { + panic!("quantity kind") + }; + assert_eq!( + kind.algebra, + a::QuantityAlgebra::Affine { + difference: a::DefinitionRef::Local(interval) + } + ); + let unused = &output.definitions[&id("00000000-0000-0000-0000-000000000064")]; + assert_eq!(unused.description, "保留"); + let a::DefinitionBody::Calculation(calculation) = &unused.body else { + panic!("calculation") + }; + assert_eq!(calculation.body, a::Binding::Unbound); + assert_eq!( + calculation.parameters[0].value_type, + a::AnalysisType::List(a::ScalarType::Quantity(a::DefinitionRef::Local(interval))) + ); + assert_eq!(calculation.result, calculation.parameters[0].value_type); + assert!( + output + .occurrences + .iter() + .any(|occurrence| occurrence.field == Field::DocumentedConstraint + && occurrence.target == Target::Global(requirement)) + ); + let override_id = id("00000000-0000-0000-0000-000000000067"); + assert!(output.occurrences.iter().any(|occurrence| occurrence.field + == Field::Override(override_id) + && occurrence.role == Role::OverrideTarget)); + assert!( + output + .occurrences + .iter() + .any(|occurrence| occurrence.owner == point + && occurrence.field == Field::Algebra + && occurrence.target == Target::Global(interval)) + ); + admit(&merge(skeleton, output)); +} + +#[test] +fn duplicate_file_keys_and_exhausted_shared_budget_cannot_enter_lowering() { + let mut budget = budget(); + let units = [unit("", 3, &mut budget), unit("", 3, &mut budget)]; + let failed = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap_err(); + assert_eq!(failed.file, Some(3)); + assert!(failed.error.message.contains("Duplicate source file")); + let failure = budget + .bytes(usize::MAX, Span { start: 5, end: 6 }) + .unwrap_err(); + let failed = lower(&ordinary(ORDINARY_OA), &[], &budget).unwrap_err(); + assert_eq!(failed.error, failure); +} + +#[test] +fn iterative_lowering_preserves_128_deep_and_1024_wide_accepted_expressions() { + let deep = format!("{}1{}", "abs(".repeat(127), ")".repeat(127)); + let wide = std::iter::repeat_n("1", 1024) + .collect::>() + .join(", "); + let source = format!( + r#" +calculation deep "Deep" id "00000000-0000-0000-0000-000000000081" {{ result Number; body {deep}; }} +calculation wide "Wide" id "00000000-0000-0000-0000-000000000082" {{ result List(Number); body [{wide}]; }} +"# + ); + let mut budget = budget(); + let units = [unit(&source, 1, &mut budget)]; + let usage = budget.usage; + let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap(); + assert_eq!(output.expressions.len(), 128 + 1025); + let a::DefinitionBody::Calculation(deep) = &output.definitions[&Id::from_u128(0x81)].body + else { + panic!("deep calculation") + }; + let a::Binding::Expression(root) = &deep.body else { + panic!("deep expression") + }; + let mut current = root; + let mut depth = 1; + while let a::Expr::Unary { operand, .. } = current { + depth += 1; + current = operand; + } + assert_eq!(depth, 128); + assert_eq!(current, &number(1)); + let a::DefinitionBody::Calculation(wide) = &output.definitions[&Id::from_u128(0x82)].body + else { + panic!("wide calculation") + }; + let a::Binding::Expression(a::Expr::List(values)) = &wide.body else { + panic!("wide list") + }; + assert_eq!(values.len(), 1024); + assert!(values.iter().all(|value| *value == number(1))); + assert_eq!(budget.usage, usage); + admit(&merge(ordinary(ORDINARY_OA), output)); +}