Stage bounded source8 typed resolution and expression lowering

This commit is contained in:
2026-09-12 22:56:11 -07:00
parent b2c970b467
commit 6de3b53103
9 changed files with 2196 additions and 1 deletions
@@ -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;
@@ -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(())
}
}
@@ -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<Declaration>,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Failure {
/// None denotes an invalid ordinary skeleton, before typed resolution.
pub file: Option<usize>,
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<usize>,
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<Id, a::Definition>,
pub elements: BTreeMap<Id, Element>,
pub occurrences: Vec<Occurrence>,
pub expressions: Vec<ExpressionSite>,
}
#[derive(Clone, Copy)]
struct Symbol {
id: Id,
category: Category,
}
#[derive(Default)]
struct Tables {
aliases: BTreeMap<String, Symbol>,
ids: BTreeSet<Id>,
parameters: BTreeMap<Id, BTreeMap<String, a::ParameterId>>,
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<Id, Error> {
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(&parameter.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(&parameter.identity, parameter.name.span)?);
if names.contains_key(&parameter.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<Lowered, Failure> {
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<usize>,
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<usize>,
role: Role,
expected: impl FnOnce(Category) -> bool,
) -> Result<Symbol, Error> {
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<usize>,
expected: Category,
) -> Result<a::DefinitionRef, Error> {
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<a::AnalysisType, Error> {
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<a::AnalysisSubject, Error> {
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<d::Field<Option<Reference>>>,
) -> Result<Option<a::AnalysisSubject>, 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][&parameter.name.name];
self.occurrence(
&parameter.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(&parameter.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<Id>,
) -> Result<a::Binding, Error> {
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<Call, Error> {
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<Id>,
) -> Result<a::Expr, Error> {
enum Work<'a> {
Visit(&'a Expression, Option<(usize, Child)>),
Build(&'a Expression, usize, Option<Call>, 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<a::Expr>| {
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<a::ParameterId>,
}
#[cfg(test)]
mod tests;
@@ -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" {}
}
}
@@ -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" {}
}
}
@@ -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;
}
@@ -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;
}
@@ -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(),
}
}
@@ -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::<BTreeSet<_>>()
.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::<Vec<_>>()
.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));
}