WIP checkpoint schema8 analysis core before user reboot
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
//! Schema 8 engineering definitions and static admission. No expression evaluation.
|
||||
mod admission;
|
||||
pub mod references;
|
||||
mod resolve;
|
||||
mod typecheck;
|
||||
mod types;
|
||||
|
||||
pub(crate) use admission::admit_structure_checked;
|
||||
pub use admission::{AdmissionLimits, AdmissionUsage, admit};
|
||||
pub use resolve::{EffectiveBinding, KindHandle, ResolvedAnalysis, ResolvedScalar, ResolvedType};
|
||||
pub use types::*;
|
||||
|
||||
/// Ratified semantic bounds. Aggregate admission work is a separate caller policy.
|
||||
pub const MAX_EXPRESSION_DEPTH: usize = 128;
|
||||
pub const MAX_CLOSURE_NODES: usize = 65_536;
|
||||
pub const MAX_LIST_LENGTH: usize = 1_024;
|
||||
pub const MAX_CALL_DEPTH: usize = 32;
|
||||
|
||||
/// Additional persisted records for existing transport ParseLimits. Definitions
|
||||
/// themselves count as graph objects there; this counts parameters, overrides,
|
||||
/// and every stored expression node, with saturating arithmetic and no recursion.
|
||||
pub(crate) fn nested_record_count(project: &crate::Project) -> usize {
|
||||
let mut count = 0usize;
|
||||
fn binding(value: &Binding, count: &mut usize) {
|
||||
if let Binding::Expression(expression) = value {
|
||||
references::walk(expression, &mut |_| {
|
||||
*count = count.saturating_add(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
for definition in project.definitions.values() {
|
||||
if let DefinitionBody::Calculation(calculation) = &definition.body {
|
||||
count = count.saturating_add(calculation.parameters.len());
|
||||
binding(&calculation.body, &mut count);
|
||||
}
|
||||
}
|
||||
for element in project.elements.values() {
|
||||
match element.shared() {
|
||||
Some(crate::SharedBody::EngineeringProperty { base, .. }) => binding(base, &mut count),
|
||||
Some(crate::SharedBody::AnalysisCase { overrides, .. }) => {
|
||||
count = count.saturating_add(overrides.len());
|
||||
for value in overrides.values() {
|
||||
binding(value, &mut count);
|
||||
}
|
||||
}
|
||||
Some(crate::SharedBody::AnalysisCheck { predicate, .. }) => {
|
||||
binding(predicate, &mut count)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,821 @@
|
||||
//! Bounded static admission for the actual Project catalog and analysis records.
|
||||
use super::*;
|
||||
use crate::analysis_numeric::{ResolvedKind, Unit};
|
||||
use crate::{Id, Kind, Layer, Project, SharedBody};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Provisional ceiling; final acceptance requires the root-owned measured gate.
|
||||
/// This is separate from runtime work limits and must be recorded by consumers.
|
||||
pub const MAX_STATIC_EVENTS: u64 = 1_000_000;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AdmissionLimits {
|
||||
pub max_static_events: u64,
|
||||
}
|
||||
impl AdmissionLimits {
|
||||
pub const REFERENCE: Self = Self {
|
||||
max_static_events: MAX_STATIC_EVENTS,
|
||||
};
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct AdmissionUsage {
|
||||
pub static_events: u64,
|
||||
pub stored_expression_nodes: usize,
|
||||
}
|
||||
|
||||
pub(super) struct Budget {
|
||||
limit: u64,
|
||||
pub(super) usage: AdmissionUsage,
|
||||
exhausted: bool,
|
||||
}
|
||||
impl Budget {
|
||||
pub(super) fn charge(
|
||||
&mut self,
|
||||
owner: Id,
|
||||
field: &str,
|
||||
events: u64,
|
||||
) -> Result<(), AnalysisError> {
|
||||
if self.exhausted || events > self.limit.saturating_sub(self.usage.static_events) {
|
||||
self.exhausted = true;
|
||||
return Err(AnalysisError::new(
|
||||
owner,
|
||||
field,
|
||||
"analysis_limit",
|
||||
"Static admission work limit exhausted.",
|
||||
));
|
||||
}
|
||||
self.usage.static_events += events;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Admits a complete Project; this neither evaluates nor chooses a case.
|
||||
/// Caller lowering is explicit. Passing a ceiling above the profile is an error.
|
||||
pub fn admit(
|
||||
project: &Project,
|
||||
limits: AdmissionLimits,
|
||||
) -> Result<ResolvedAnalysis<'_>, AnalysisError> {
|
||||
let environment = admit_structure_checked(project, limits)?;
|
||||
if let Some(issue) = crate::validation::validate_structure(project)
|
||||
.into_iter()
|
||||
.find(|issue| issue.severity == crate::validation::Severity::Error)
|
||||
{
|
||||
return Err(AnalysisError::new(
|
||||
issue.element.unwrap_or(project.id),
|
||||
issue.field.unwrap_or_default(),
|
||||
&issue.code,
|
||||
issue.message,
|
||||
));
|
||||
}
|
||||
Ok(environment)
|
||||
}
|
||||
|
||||
pub(crate) fn admit_structure_checked(
|
||||
project: &Project,
|
||||
limits: AdmissionLimits,
|
||||
) -> Result<ResolvedAnalysis<'_>, AnalysisError> {
|
||||
if limits.max_static_events > MAX_STATIC_EVENTS {
|
||||
return Err(AnalysisError::new(
|
||||
project.id,
|
||||
"analysis_limits",
|
||||
"analysis_limit",
|
||||
"Static event limit exceeds the provisional reference profile.",
|
||||
));
|
||||
}
|
||||
let mut budget = Budget {
|
||||
limit: limits.max_static_events,
|
||||
usage: AdmissionUsage::default(),
|
||||
exhausted: false,
|
||||
};
|
||||
budget.charge(project.id, "analysis", 1)?;
|
||||
let mut environment = ResolvedAnalysis {
|
||||
project,
|
||||
kinds: BTreeMap::new(),
|
||||
units: BTreeMap::new(),
|
||||
limits,
|
||||
usage: AdmissionUsage::default(),
|
||||
};
|
||||
resolve_definitions(&mut environment, &mut budget)?;
|
||||
let index = Index::build(&environment, &mut budget)?;
|
||||
check_bindings(&environment, &mut budget)?;
|
||||
check_graphs(&environment, &index, &mut budget)?;
|
||||
environment.usage = budget.usage;
|
||||
Ok(environment)
|
||||
}
|
||||
|
||||
fn resolve_definitions(
|
||||
environment: &mut ResolvedAnalysis<'_>,
|
||||
budget: &mut Budget,
|
||||
) -> Result<(), AnalysisError> {
|
||||
let project = environment.project;
|
||||
for (id, definition) in &project.definitions {
|
||||
budget.charge(*id, "definition", 1)?;
|
||||
if id != &definition.id {
|
||||
return Err(AnalysisError::new(
|
||||
*id,
|
||||
"id",
|
||||
"identity_mismatch",
|
||||
"Definition map key must equal its identity.",
|
||||
));
|
||||
}
|
||||
for (field, value) in [
|
||||
("name", &definition.name),
|
||||
("description", &definition.description),
|
||||
] {
|
||||
if !crate::validation::valid_text(value) {
|
||||
return Err(AnalysisError::new(
|
||||
*id,
|
||||
field,
|
||||
"invalid_text",
|
||||
"Definition text contains a forbidden character.",
|
||||
));
|
||||
}
|
||||
}
|
||||
if let DefinitionBody::QuantityKind(kind) = &definition.body
|
||||
&& kind.algebra == QuantityAlgebra::Linear
|
||||
{
|
||||
environment.kinds.insert(
|
||||
*id,
|
||||
ResolvedKind::linear(KindHandle::local(project.id, *id), kind.dimension),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (id, definition) in &project.definitions {
|
||||
budget.charge(*id, "definition", 1)?;
|
||||
if let DefinitionBody::QuantityKind(QuantityKindDefinition {
|
||||
dimension,
|
||||
algebra: QuantityAlgebra::Affine { difference },
|
||||
}) = &definition.body
|
||||
{
|
||||
let difference = environment.kind(*difference).ok_or_else(|| {
|
||||
AnalysisError::new(
|
||||
*id,
|
||||
"difference",
|
||||
"analysis_kind",
|
||||
"Affine difference must reference a linear quantity kind.",
|
||||
)
|
||||
})?;
|
||||
if difference.is_affine() || difference.dimension() != *dimension {
|
||||
return Err(AnalysisError::new(
|
||||
*id,
|
||||
"difference",
|
||||
"analysis_kind",
|
||||
"Affine point and linear difference kinds must have identical dimensions.",
|
||||
));
|
||||
}
|
||||
let resolved = ResolvedKind::affine(KindHandle::local(project.id, *id), difference)
|
||||
.map_err(|error| {
|
||||
AnalysisError::new(*id, "difference", "analysis_kind", error.to_string())
|
||||
})?;
|
||||
environment.kinds.insert(*id, resolved);
|
||||
}
|
||||
}
|
||||
for (id, definition) in &project.definitions {
|
||||
budget.charge(*id, "definition", 1)?;
|
||||
if let DefinitionBody::Unit(unit) = &definition.body {
|
||||
let kind = environment
|
||||
.kind(unit.kind)
|
||||
.ok_or_else(|| {
|
||||
AnalysisError::new(
|
||||
*id,
|
||||
"kind",
|
||||
"analysis_kind",
|
||||
"Unit must reference a quantity kind.",
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
if !crate::validation::valid_text(&unit.symbol) {
|
||||
return Err(AnalysisError::new(
|
||||
*id,
|
||||
"symbol",
|
||||
"invalid_text",
|
||||
"Unit symbol contains a forbidden character.",
|
||||
));
|
||||
}
|
||||
let resolved =
|
||||
Unit::new(kind, unit.scale.clone(), unit.offset.clone()).map_err(|error| {
|
||||
AnalysisError::new(*id, "unit", "analysis_unit", error.to_string())
|
||||
})?;
|
||||
environment.units.insert(*id, resolved);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Info {
|
||||
nodes: usize,
|
||||
properties: BTreeSet<Id>,
|
||||
calls: BTreeSet<Id>,
|
||||
}
|
||||
impl Info {
|
||||
fn read(
|
||||
owner: Id,
|
||||
field: &str,
|
||||
binding: &Binding,
|
||||
budget: &mut Budget,
|
||||
) -> Result<Self, AnalysisError> {
|
||||
let mut info = Self::default();
|
||||
let Binding::Expression(expression) = binding else {
|
||||
return Ok(info);
|
||||
};
|
||||
let mut pending = vec![(expression, 1usize)];
|
||||
while let Some((node, depth)) = pending.pop() {
|
||||
budget.charge(owner, field, 1)?;
|
||||
if depth > MAX_EXPRESSION_DEPTH {
|
||||
return Err(AnalysisError::new(
|
||||
owner,
|
||||
field,
|
||||
"analysis_depth",
|
||||
"Expression exceeds logical depth 128.",
|
||||
));
|
||||
}
|
||||
info.nodes += 1;
|
||||
budget.usage.stored_expression_nodes += 1;
|
||||
if info.nodes > MAX_CLOSURE_NODES {
|
||||
return Err(AnalysisError::new(
|
||||
owner,
|
||||
field,
|
||||
"analysis_nodes",
|
||||
"Expression exceeds the stored closure node limit.",
|
||||
));
|
||||
}
|
||||
let mut push = |child| pending.push((child, depth + 1));
|
||||
match node {
|
||||
Expr::Property(id) => {
|
||||
info.properties.insert(*id);
|
||||
}
|
||||
Expr::List(items) => {
|
||||
if items.len() > MAX_LIST_LENGTH {
|
||||
return Err(AnalysisError::new(
|
||||
owner,
|
||||
field,
|
||||
"analysis_list",
|
||||
"List exceeds 1024 entries.",
|
||||
));
|
||||
}
|
||||
for child in items.iter().rev() {
|
||||
push(child);
|
||||
}
|
||||
}
|
||||
Expr::Unary { operand, .. } => push(operand),
|
||||
Expr::Binary { left, right, .. } => {
|
||||
push(right);
|
||||
push(left);
|
||||
}
|
||||
Expr::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
push(when_false);
|
||||
push(when_true);
|
||||
push(condition);
|
||||
}
|
||||
Expr::Call {
|
||||
definition,
|
||||
arguments,
|
||||
} => {
|
||||
if arguments.len() > MAX_CLOSURE_NODES {
|
||||
return Err(AnalysisError::new(
|
||||
owner,
|
||||
field,
|
||||
"analysis_nodes",
|
||||
"Call argument count exceeds the stored closure limit.",
|
||||
));
|
||||
}
|
||||
info.calls.insert(definition.local());
|
||||
for child in arguments.values().rev() {
|
||||
push(child);
|
||||
}
|
||||
}
|
||||
Expr::Sum { values, initial } => {
|
||||
push(values);
|
||||
push(initial);
|
||||
}
|
||||
Expr::Power { base, .. } => push(base),
|
||||
Expr::AsKind { value, .. } => push(value),
|
||||
_ => {}
|
||||
}
|
||||
if pending.len() > MAX_CLOSURE_NODES {
|
||||
return Err(AnalysisError::new(
|
||||
owner,
|
||||
field,
|
||||
"analysis_nodes",
|
||||
"Pending expression nodes exceed the stored closure limit.",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Index {
|
||||
calculations: BTreeMap<Id, Info>,
|
||||
properties: BTreeMap<Id, Info>,
|
||||
checks: BTreeMap<Id, Info>,
|
||||
cases: BTreeMap<Id, BTreeMap<Id, Info>>,
|
||||
}
|
||||
impl Index {
|
||||
fn build(
|
||||
environment: &ResolvedAnalysis<'_>,
|
||||
budget: &mut Budget,
|
||||
) -> Result<Self, AnalysisError> {
|
||||
let mut index = Self::default();
|
||||
for (id, definition) in &environment.project.definitions {
|
||||
budget.charge(*id, "definition", 1)?;
|
||||
if let DefinitionBody::Calculation(calculation) = &definition.body {
|
||||
index
|
||||
.calculations
|
||||
.insert(*id, Info::read(*id, "body", &calculation.body, budget)?);
|
||||
}
|
||||
}
|
||||
for (id, element) in &environment.project.elements {
|
||||
budget.charge(*id, "element", 1)?;
|
||||
if element.kind().is_analysis() && element.parent.is_some() {
|
||||
return Err(AnalysisError::new(
|
||||
*id,
|
||||
"parent",
|
||||
"analysis_parent",
|
||||
"Analysis records are layer roots; subject is an explicit reference.",
|
||||
));
|
||||
}
|
||||
match element.shared() {
|
||||
Some(SharedBody::EngineeringProperty { base, .. }) => {
|
||||
index
|
||||
.properties
|
||||
.insert(*id, Info::read(*id, "base", base, budget)?);
|
||||
}
|
||||
Some(SharedBody::AnalysisCheck { predicate, .. }) => {
|
||||
index
|
||||
.checks
|
||||
.insert(*id, Info::read(*id, "predicate", predicate, budget)?);
|
||||
}
|
||||
Some(SharedBody::AnalysisCase { overrides, .. }) => {
|
||||
let mut cases = BTreeMap::new();
|
||||
for (property, binding) in overrides {
|
||||
budget.charge(*id, "overrides", 1)?;
|
||||
cases.insert(*property, Info::read(*id, "overrides", binding, budget)?);
|
||||
}
|
||||
index.cases.insert(*id, cases);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
}
|
||||
|
||||
fn subject(
|
||||
environment: &ResolvedAnalysis<'_>,
|
||||
owner: Id,
|
||||
layer: Layer,
|
||||
value: AnalysisSubject,
|
||||
) -> Result<(), AnalysisError> {
|
||||
let project = environment.project;
|
||||
let valid = match value {
|
||||
AnalysisSubject::Element(id) => project
|
||||
.elements
|
||||
.get(&id)
|
||||
.is_some_and(|element| element.layer() == layer && !element.kind().is_analysis()),
|
||||
AnalysisSubject::Relation(id) => {
|
||||
project.relations.contains_key(&id) && project.layer_of(id) == Some(layer)
|
||||
}
|
||||
};
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AnalysisError::new(
|
||||
owner,
|
||||
"subject",
|
||||
"analysis_subject",
|
||||
"Subject must be an existing architectural element or relation in the same layer.",
|
||||
))
|
||||
}
|
||||
}
|
||||
fn declared(
|
||||
environment: &ResolvedAnalysis<'_>,
|
||||
owner: Id,
|
||||
field: &str,
|
||||
value: &AnalysisType,
|
||||
) -> Result<ResolvedType, AnalysisError> {
|
||||
environment.declared_type(value).ok_or_else(|| {
|
||||
AnalysisError::new(
|
||||
owner,
|
||||
field,
|
||||
"analysis_kind",
|
||||
"Declared quantity kind does not resolve to a quantity-kind definition.",
|
||||
)
|
||||
})
|
||||
}
|
||||
fn check_bindings(
|
||||
environment: &ResolvedAnalysis<'_>,
|
||||
budget: &mut Budget,
|
||||
) -> Result<(), AnalysisError> {
|
||||
let no_parameters = BTreeMap::new();
|
||||
for (id, definition) in &environment.project.definitions {
|
||||
budget.charge(*id, "definition", 1)?;
|
||||
if let DefinitionBody::Calculation(calculation) = &definition.body {
|
||||
let mut parameters = BTreeMap::new();
|
||||
let mut names = BTreeSet::new();
|
||||
for parameter in &calculation.parameters {
|
||||
budget.charge(*id, "parameters", 1)?;
|
||||
if !crate::valid_alias(¶meter.name) || !names.insert(¶meter.name) {
|
||||
return Err(AnalysisError::new(
|
||||
*id,
|
||||
"parameters",
|
||||
"analysis_parameter",
|
||||
"Parameter names must be unique valid identifiers in their calculation.",
|
||||
));
|
||||
}
|
||||
let value = declared(environment, *id, "parameters", ¶meter.value_type)?;
|
||||
if parameters.insert(parameter.id, value).is_some() {
|
||||
return Err(AnalysisError::new(
|
||||
*id,
|
||||
"parameters",
|
||||
"analysis_parameter",
|
||||
"Parameter identities must be unique within their calculation.",
|
||||
));
|
||||
}
|
||||
}
|
||||
let expected = declared(environment, *id, "result", &calculation.result)?;
|
||||
super::typecheck::binding(
|
||||
environment,
|
||||
*id,
|
||||
"body",
|
||||
&calculation.body,
|
||||
&expected,
|
||||
None,
|
||||
¶meters,
|
||||
budget,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
for (id, element) in &environment.project.elements {
|
||||
budget.charge(*id, "element", 1)?;
|
||||
let layer = element.layer();
|
||||
match element.shared() {
|
||||
Some(SharedBody::EngineeringProperty {
|
||||
subject: target,
|
||||
value_type,
|
||||
display_unit,
|
||||
base,
|
||||
}) => {
|
||||
subject(environment, *id, layer, *target)?;
|
||||
let expected = declared(environment, *id, "value_type", value_type)?;
|
||||
if let Some(reference) = display_unit {
|
||||
let unit = environment.unit(*reference).ok_or_else(|| {
|
||||
AnalysisError::new(
|
||||
*id,
|
||||
"display_unit",
|
||||
"analysis_unit",
|
||||
"Display unit must reference a unit definition.",
|
||||
)
|
||||
})?;
|
||||
let scalar = match &expected {
|
||||
ResolvedType::Scalar(value) | ResolvedType::List(value) => value,
|
||||
};
|
||||
if scalar
|
||||
!= &ResolvedScalar::Numeric(crate::analysis_numeric::NumericType::Named(
|
||||
unit.kind().clone(),
|
||||
))
|
||||
{
|
||||
return Err(AnalysisError::new(
|
||||
*id,
|
||||
"display_unit",
|
||||
"analysis_unit",
|
||||
"Display unit must have the property's declared nominal quantity kind.",
|
||||
));
|
||||
}
|
||||
}
|
||||
super::typecheck::binding(
|
||||
environment,
|
||||
*id,
|
||||
"base",
|
||||
base,
|
||||
&expected,
|
||||
Some(layer),
|
||||
&no_parameters,
|
||||
budget,
|
||||
)?;
|
||||
}
|
||||
Some(SharedBody::AnalysisCase {
|
||||
subject: target,
|
||||
overrides,
|
||||
..
|
||||
}) => {
|
||||
if let Some(target) = target {
|
||||
subject(environment, *id, layer, *target)?;
|
||||
}
|
||||
for (property, binding) in overrides {
|
||||
budget.charge(*id, "overrides", 1)?;
|
||||
let target = environment.project.elements.get(property).ok_or_else(|| {
|
||||
AnalysisError::new(
|
||||
*id,
|
||||
"overrides",
|
||||
"analysis_property",
|
||||
"Override target does not exist.",
|
||||
)
|
||||
})?;
|
||||
let Some(SharedBody::EngineeringProperty { value_type, .. }) = target.shared()
|
||||
else {
|
||||
return Err(AnalysisError::new(
|
||||
*id,
|
||||
"overrides",
|
||||
"analysis_property",
|
||||
"Override target must be an engineering property.",
|
||||
));
|
||||
};
|
||||
if target.layer() != layer {
|
||||
return Err(AnalysisError::new(
|
||||
*id,
|
||||
"overrides",
|
||||
"analysis_layer",
|
||||
"Case and overridden property must share a layer.",
|
||||
));
|
||||
}
|
||||
let expected = declared(environment, *id, "overrides", value_type)?;
|
||||
super::typecheck::binding(
|
||||
environment,
|
||||
*id,
|
||||
"overrides",
|
||||
binding,
|
||||
&expected,
|
||||
Some(layer),
|
||||
&no_parameters,
|
||||
budget,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Some(SharedBody::AnalysisCheck {
|
||||
subject: target,
|
||||
documented_constraint,
|
||||
predicate,
|
||||
}) => {
|
||||
if let Some(target) = target {
|
||||
subject(environment, *id, layer, *target)?;
|
||||
}
|
||||
if let Some(constraint) = documented_constraint
|
||||
&& !environment
|
||||
.project
|
||||
.elements
|
||||
.get(constraint)
|
||||
.is_some_and(|target| {
|
||||
target.kind() == Kind::Constraint && target.layer() == layer
|
||||
})
|
||||
{
|
||||
return Err(AnalysisError::new(
|
||||
*id,
|
||||
"documented_constraint",
|
||||
"analysis_constraint",
|
||||
"Check documentation must reference a constraint in the same layer.",
|
||||
));
|
||||
}
|
||||
super::typecheck::binding(
|
||||
environment,
|
||||
*id,
|
||||
"predicate",
|
||||
predicate,
|
||||
&ResolvedType::boolean(),
|
||||
Some(layer),
|
||||
&no_parameters,
|
||||
budget,
|
||||
)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Iterative graph admission; neither model depth nor call depth grows this stack.
|
||||
fn acyclic(
|
||||
owner: Id,
|
||||
field: &str,
|
||||
graph: &BTreeMap<Id, BTreeSet<Id>>,
|
||||
call_graph: bool,
|
||||
budget: &mut Budget,
|
||||
) -> Result<(), AnalysisError> {
|
||||
let mut degrees = BTreeMap::new();
|
||||
let mut users: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
|
||||
let mut heights = BTreeMap::new();
|
||||
let mut ready = BTreeSet::new();
|
||||
for (id, dependencies) in graph {
|
||||
budget.charge(owner, field, 1)?;
|
||||
degrees.insert(*id, dependencies.len());
|
||||
heights.insert(*id, 1usize);
|
||||
if dependencies.is_empty() {
|
||||
ready.insert(*id);
|
||||
}
|
||||
for dependency in dependencies {
|
||||
budget.charge(owner, field, 1)?;
|
||||
if !graph.contains_key(dependency) {
|
||||
return Err(AnalysisError::new(
|
||||
owner,
|
||||
field,
|
||||
"analysis_reference",
|
||||
"Dependency is outside its typed graph.",
|
||||
));
|
||||
}
|
||||
users.entry(*dependency).or_default().push(*id);
|
||||
}
|
||||
}
|
||||
let mut visited = 0usize;
|
||||
while let Some(id) = ready.pop_first() {
|
||||
budget.charge(owner, field, 1)?;
|
||||
visited += 1;
|
||||
let height = heights[&id];
|
||||
if call_graph && height > MAX_CALL_DEPTH {
|
||||
return Err(AnalysisError::new(
|
||||
owner,
|
||||
field,
|
||||
"analysis_call_depth",
|
||||
"Calculation call graph exceeds 32 active definition calls.",
|
||||
));
|
||||
}
|
||||
if let Some(dependents) = users.get(&id) {
|
||||
for dependent in dependents {
|
||||
budget.charge(owner, field, 1)?;
|
||||
let degree = degrees.get_mut(dependent).expect("indexed graph node");
|
||||
*degree -= 1;
|
||||
let current = heights.get_mut(dependent).expect("indexed graph node");
|
||||
*current = (*current).max(height + 1);
|
||||
if *degree == 0 {
|
||||
ready.insert(*dependent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if visited != graph.len() {
|
||||
return Err(AnalysisError::new(
|
||||
owner,
|
||||
field,
|
||||
"analysis_cycle",
|
||||
"Stored dependencies contain a cycle, including statically unused branches.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn closure(
|
||||
owner: Id,
|
||||
base_nodes: usize,
|
||||
calls: impl IntoIterator<Item = Id>,
|
||||
index: &Index,
|
||||
budget: &mut Budget,
|
||||
) -> Result<(), AnalysisError> {
|
||||
let mut nodes = base_nodes;
|
||||
let mut pending: Vec<_> = calls.into_iter().collect();
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = pending.pop() {
|
||||
budget.charge(owner, "closure", 1)?;
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let info = index.calculations.get(&id).ok_or_else(|| {
|
||||
AnalysisError::new(
|
||||
owner,
|
||||
"closure",
|
||||
"analysis_calculation",
|
||||
"Calculation dependency does not resolve.",
|
||||
)
|
||||
})?;
|
||||
nodes = nodes.checked_add(info.nodes).ok_or_else(|| {
|
||||
AnalysisError::new(
|
||||
owner,
|
||||
"closure",
|
||||
"analysis_nodes",
|
||||
"Stored node count overflow.",
|
||||
)
|
||||
})?;
|
||||
if nodes > MAX_CLOSURE_NODES {
|
||||
return Err(AnalysisError::new(
|
||||
owner,
|
||||
"closure",
|
||||
"analysis_nodes",
|
||||
"Stored effective closure exceeds 65,536 expression nodes.",
|
||||
));
|
||||
}
|
||||
for dependency in &info.calls {
|
||||
budget.charge(owner, "closure", 1)?;
|
||||
pending.push(*dependency);
|
||||
}
|
||||
}
|
||||
if nodes > MAX_CLOSURE_NODES {
|
||||
return Err(AnalysisError::new(
|
||||
owner,
|
||||
"closure",
|
||||
"analysis_nodes",
|
||||
"Stored effective closure exceeds 65,536 expression nodes.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn check_graphs(
|
||||
environment: &ResolvedAnalysis<'_>,
|
||||
index: &Index,
|
||||
budget: &mut Budget,
|
||||
) -> Result<(), AnalysisError> {
|
||||
let project = environment.project;
|
||||
let mut calls = BTreeMap::new();
|
||||
for (id, info) in &index.calculations {
|
||||
budget.charge(*id, "calls", 1 + info.calls.len() as u64)?;
|
||||
calls.insert(*id, info.calls.clone());
|
||||
}
|
||||
acyclic(project.id, "calculations", &calls, true, budget)?;
|
||||
for id in index.calculations.keys() {
|
||||
closure(*id, 0, [*id], index, budget)?;
|
||||
}
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut contexts = vec![EvaluationContext::ModelBaseline];
|
||||
for id in index.cases.keys() {
|
||||
budget.charge(*id, "context", 1)?;
|
||||
if project.layer_of(*id) == Some(layer) {
|
||||
contexts.push(EvaluationContext::Case(*id));
|
||||
}
|
||||
}
|
||||
for context in contexts {
|
||||
let owner = match context {
|
||||
EvaluationContext::ModelBaseline => project.id,
|
||||
EvaluationContext::Case(id) => id,
|
||||
};
|
||||
let mut graph = BTreeMap::new();
|
||||
let mut nodes = 0usize;
|
||||
let mut definitions = BTreeSet::new();
|
||||
for (id, base) in &index.properties {
|
||||
budget.charge(owner, "effective_properties", 1)?;
|
||||
if project.layer_of(*id) != Some(layer) {
|
||||
continue;
|
||||
}
|
||||
let effective = match context {
|
||||
EvaluationContext::ModelBaseline => base,
|
||||
EvaluationContext::Case(case) => index.cases[&case].get(id).unwrap_or(base),
|
||||
};
|
||||
budget.charge(
|
||||
owner,
|
||||
"effective_properties",
|
||||
(effective.properties.len() + effective.calls.len()) as u64,
|
||||
)?;
|
||||
nodes = nodes.checked_add(effective.nodes).ok_or_else(|| {
|
||||
AnalysisError::new(
|
||||
owner,
|
||||
"closure",
|
||||
"analysis_nodes",
|
||||
"Stored node count overflow.",
|
||||
)
|
||||
})?;
|
||||
graph.insert(*id, effective.properties.clone());
|
||||
definitions.extend(&effective.calls);
|
||||
}
|
||||
for (id, info) in &index.checks {
|
||||
budget.charge(owner, "effective_checks", 1)?;
|
||||
if project.layer_of(*id) == Some(layer) {
|
||||
budget.charge(owner, "effective_checks", info.calls.len() as u64)?;
|
||||
nodes = nodes.checked_add(info.nodes).ok_or_else(|| {
|
||||
AnalysisError::new(
|
||||
owner,
|
||||
"closure",
|
||||
"analysis_nodes",
|
||||
"Stored node count overflow.",
|
||||
)
|
||||
})?;
|
||||
definitions.extend(&info.calls);
|
||||
}
|
||||
}
|
||||
acyclic(owner, "effective_properties", &graph, false, budget)?;
|
||||
closure(owner, nodes, definitions, index, budget)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod meter_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn static_meter_exact_boundary_zero_charge_and_exhaustion_latch() {
|
||||
let owner = Id::nil();
|
||||
let mut budget = Budget {
|
||||
limit: 3,
|
||||
usage: AdmissionUsage::default(),
|
||||
exhausted: false,
|
||||
};
|
||||
budget.charge(owner, "test", 0).unwrap();
|
||||
budget.charge(owner, "test", 3).unwrap();
|
||||
budget.charge(owner, "test", 0).unwrap();
|
||||
assert_eq!(budget.usage.static_events, 3);
|
||||
assert!(budget.charge(owner, "test", 1).is_err());
|
||||
assert!(budget.charge(owner, "test", 0).is_err());
|
||||
assert!(budget.charge(owner, "test", u64::MAX).is_err());
|
||||
assert_eq!(budget.usage.static_events, 3);
|
||||
let mut zero = Budget {
|
||||
limit: 0,
|
||||
usage: AdmissionUsage::default(),
|
||||
exhausted: false,
|
||||
};
|
||||
zero.charge(owner, "test", 0).unwrap();
|
||||
assert!(zero.charge(owner, "test", 1).is_err());
|
||||
assert!(zero.exhausted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Typed live reference domains; parameter IDs and opaque historical captures are separate.
|
||||
use super::*;
|
||||
use crate::{Element, Id, Project, SharedBody};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub fn expression_properties(expression: &Expr) -> BTreeSet<Id> {
|
||||
let mut found = BTreeSet::new();
|
||||
walk(expression, &mut |node| {
|
||||
if let Expr::Property(id) = node {
|
||||
found.insert(*id);
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
pub fn expression_definitions(expression: &Expr) -> BTreeSet<Id> {
|
||||
let mut found = BTreeSet::new();
|
||||
walk(expression, &mut |node| match node {
|
||||
Expr::Quantity { unit, .. } => {
|
||||
found.insert(unit.local());
|
||||
}
|
||||
Expr::Call { definition, .. } => {
|
||||
found.insert(definition.local());
|
||||
}
|
||||
Expr::AsKind { kind, .. } => {
|
||||
found.insert(kind.local());
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
found
|
||||
}
|
||||
pub(crate) fn walk<'a>(expression: &'a Expr, visit: &mut impl FnMut(&'a Expr)) {
|
||||
let mut pending = vec![expression];
|
||||
while let Some(node) = pending.pop() {
|
||||
visit(node);
|
||||
match node {
|
||||
Expr::List(items) => pending.extend(items.iter().rev()),
|
||||
Expr::Unary { operand, .. } => pending.push(operand),
|
||||
Expr::Binary { left, right, .. } => {
|
||||
pending.push(right);
|
||||
pending.push(left);
|
||||
}
|
||||
Expr::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
pending.push(when_false);
|
||||
pending.push(when_true);
|
||||
pending.push(condition);
|
||||
}
|
||||
Expr::Call { arguments, .. } => pending.extend(arguments.values().rev()),
|
||||
Expr::Sum { values, initial } => {
|
||||
pending.push(values);
|
||||
pending.push(initial);
|
||||
}
|
||||
Expr::Power { base, .. } => pending.push(base),
|
||||
Expr::AsKind { value, .. } => pending.push(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn walk_mut(expression: &mut Expr, visit: &mut impl FnMut(&mut Expr)) {
|
||||
let mut pending = vec![expression];
|
||||
while let Some(node) = pending.pop() {
|
||||
visit(node);
|
||||
match node {
|
||||
Expr::List(items) => pending.extend(items.iter_mut()),
|
||||
Expr::Unary { operand, .. } => pending.push(operand),
|
||||
Expr::Binary { left, right, .. } => {
|
||||
pending.push(left);
|
||||
pending.push(right);
|
||||
}
|
||||
Expr::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
pending.push(condition);
|
||||
pending.push(when_true);
|
||||
pending.push(when_false);
|
||||
}
|
||||
Expr::Call { arguments, .. } => pending.extend(arguments.values_mut()),
|
||||
Expr::Sum { values, initial } => {
|
||||
pending.push(values);
|
||||
pending.push(initial);
|
||||
}
|
||||
Expr::Power { base, .. } => pending.push(base),
|
||||
Expr::AsKind { value, .. } => pending.push(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn element_model_references(element: &Element) -> BTreeSet<Id> {
|
||||
let mut result = BTreeSet::new();
|
||||
let mut binding = |value: &Binding| {
|
||||
if let Binding::Expression(expression) = value {
|
||||
result.extend(expression_properties(expression));
|
||||
}
|
||||
};
|
||||
match element.shared() {
|
||||
Some(SharedBody::EngineeringProperty { base, .. }) => binding(base),
|
||||
Some(SharedBody::AnalysisCase { overrides, .. }) => {
|
||||
for value in overrides.values() {
|
||||
binding(value);
|
||||
}
|
||||
}
|
||||
Some(SharedBody::AnalysisCheck { predicate, .. }) => binding(predicate),
|
||||
_ => {}
|
||||
}
|
||||
match element.shared() {
|
||||
Some(SharedBody::EngineeringProperty { subject, .. }) => {
|
||||
result.insert(subject.id());
|
||||
}
|
||||
Some(SharedBody::AnalysisCase {
|
||||
subject, overrides, ..
|
||||
}) => {
|
||||
result.extend(subject.map(AnalysisSubject::id));
|
||||
result.extend(overrides.keys());
|
||||
}
|
||||
Some(SharedBody::AnalysisCheck {
|
||||
subject,
|
||||
documented_constraint,
|
||||
..
|
||||
}) => {
|
||||
result.extend(subject.map(AnalysisSubject::id));
|
||||
result.extend(documented_constraint);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn remap_ref(reference: &mut DefinitionRef, old: Id, new: Id) {
|
||||
let DefinitionRef::Local(id) = reference;
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
}
|
||||
fn remap_type(value: &mut AnalysisType, old: Id, new: Id) {
|
||||
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
|
||||
if let ScalarType::Quantity(reference) = scalar {
|
||||
remap_ref(reference, old, new);
|
||||
}
|
||||
}
|
||||
fn remap_binding(value: &mut Binding, old: Id, new: Id) {
|
||||
if let Binding::Expression(expression) = value {
|
||||
walk_mut(expression, &mut |node| match node {
|
||||
Expr::Property(id) if *id == old => *id = new,
|
||||
Expr::Quantity { unit, .. } => remap_ref(unit, old, new),
|
||||
Expr::Call { definition, .. } => remap_ref(definition, old, new),
|
||||
Expr::AsKind { kind, .. } => remap_ref(kind, old, new),
|
||||
_ => {} // Parameter nodes and argument keys are scoped, never global.
|
||||
});
|
||||
}
|
||||
}
|
||||
fn remap_subject(subject: &mut AnalysisSubject, old: Id, new: Id) {
|
||||
let (AnalysisSubject::Element(id) | AnalysisSubject::Relation(id)) = subject;
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check before any mutation so a map-key collision never silently discards a binding.
|
||||
pub(crate) fn check_remap(project: &Project, old: Id, new: Id) -> Result<(), AnalysisError> {
|
||||
if old != new {
|
||||
for element in project.elements.values() {
|
||||
if let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared()
|
||||
&& overrides.contains_key(&old)
|
||||
&& overrides.contains_key(&new)
|
||||
{
|
||||
return Err(AnalysisError::new(
|
||||
element.id,
|
||||
"overrides",
|
||||
"analysis_reference_collision",
|
||||
"Reidentification would merge two distinct override keys.",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn remap_project(project: &mut Project, old: Id, new: Id) {
|
||||
for element in project.elements.values_mut() {
|
||||
match element.shared_mut() {
|
||||
Some(SharedBody::EngineeringProperty {
|
||||
subject,
|
||||
value_type,
|
||||
display_unit,
|
||||
base,
|
||||
}) => {
|
||||
remap_subject(subject, old, new);
|
||||
remap_type(value_type, old, new);
|
||||
if let Some(reference) = display_unit {
|
||||
remap_ref(reference, old, new);
|
||||
}
|
||||
remap_binding(base, old, new);
|
||||
}
|
||||
Some(SharedBody::AnalysisCase {
|
||||
subject, overrides, ..
|
||||
}) => {
|
||||
if let Some(subject) = subject {
|
||||
remap_subject(subject, old, new);
|
||||
}
|
||||
if old != new
|
||||
&& let Some(value) = overrides.remove(&old)
|
||||
{
|
||||
overrides.insert(new, value);
|
||||
}
|
||||
for value in overrides.values_mut() {
|
||||
remap_binding(value, old, new);
|
||||
}
|
||||
}
|
||||
Some(SharedBody::AnalysisCheck {
|
||||
subject,
|
||||
documented_constraint,
|
||||
predicate,
|
||||
}) => {
|
||||
if let Some(subject) = subject {
|
||||
remap_subject(subject, old, new);
|
||||
}
|
||||
if *documented_constraint == Some(old) {
|
||||
*documented_constraint = Some(new);
|
||||
}
|
||||
remap_binding(predicate, old, new);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for definition in project.definitions.values_mut() {
|
||||
match &mut definition.body {
|
||||
DefinitionBody::QuantityKind(QuantityKindDefinition {
|
||||
algebra: QuantityAlgebra::Affine { difference },
|
||||
..
|
||||
}) => remap_ref(difference, old, new),
|
||||
DefinitionBody::Unit(unit) => remap_ref(&mut unit.kind, old, new),
|
||||
DefinitionBody::Calculation(calculation) => {
|
||||
for parameter in &mut calculation.parameters {
|
||||
remap_type(&mut parameter.value_type, old, new);
|
||||
}
|
||||
remap_type(&mut calculation.result, old, new);
|
||||
remap_binding(&mut calculation.body, old, new);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use super::*;
|
||||
use crate::analysis_numeric::{NumericType, ResolvedKind, Unit};
|
||||
use crate::{Id, Project, SharedBody};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Bounded local nominal origin; no rooted graph is cloned during arithmetic.
|
||||
/// Full ResolvedKind equality additionally includes dimension/algebra/difference.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct KindHandle {
|
||||
model: Id,
|
||||
definition: Id,
|
||||
}
|
||||
impl KindHandle {
|
||||
pub fn definition(self) -> Id {
|
||||
self.definition
|
||||
}
|
||||
pub(crate) fn local(model: Id, definition: Id) -> Self {
|
||||
Self { model, definition }
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolvedScalar {
|
||||
Boolean,
|
||||
Numeric(NumericType<KindHandle>),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolvedType {
|
||||
Scalar(ResolvedScalar),
|
||||
List(ResolvedScalar),
|
||||
}
|
||||
impl ResolvedType {
|
||||
pub(crate) fn boolean() -> Self {
|
||||
Self::Scalar(ResolvedScalar::Boolean)
|
||||
}
|
||||
pub(crate) fn numeric(value: NumericType<KindHandle>) -> Self {
|
||||
Self::Scalar(ResolvedScalar::Numeric(value.normalized()))
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EffectiveBinding<'a> {
|
||||
pub binding: &'a Binding,
|
||||
pub origin: BindingOrigin,
|
||||
}
|
||||
|
||||
/// Constructed only by admission; exact source/capture provenance stays with its owner.
|
||||
/// Values from different accepted environments must not be mixed by an evaluator.
|
||||
pub struct ResolvedAnalysis<'p> {
|
||||
pub(super) project: &'p Project,
|
||||
pub(super) kinds: BTreeMap<Id, ResolvedKind<KindHandle>>,
|
||||
pub(super) units: BTreeMap<Id, Unit<KindHandle>>,
|
||||
pub(super) limits: AdmissionLimits,
|
||||
pub(super) usage: AdmissionUsage,
|
||||
}
|
||||
impl<'p> ResolvedAnalysis<'p> {
|
||||
pub fn project(&self) -> &'p Project {
|
||||
self.project
|
||||
}
|
||||
pub fn admission_usage(&self) -> AdmissionUsage {
|
||||
self.usage
|
||||
}
|
||||
pub fn admission_limits(&self) -> AdmissionLimits {
|
||||
self.limits
|
||||
}
|
||||
pub fn definition(&self, reference: DefinitionRef) -> Option<&'p Definition> {
|
||||
self.project.definitions.get(&reference.local())
|
||||
}
|
||||
pub fn kind(&self, reference: DefinitionRef) -> Option<&ResolvedKind<KindHandle>> {
|
||||
self.kinds.get(&reference.local())
|
||||
}
|
||||
pub fn unit(&self, reference: DefinitionRef) -> Option<&Unit<KindHandle>> {
|
||||
self.units.get(&reference.local())
|
||||
}
|
||||
pub fn calculation(&self, reference: DefinitionRef) -> Option<&'p CalculationDefinition> {
|
||||
match &self.definition(reference)?.body {
|
||||
DefinitionBody::Calculation(value) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn declared_type(&self, value: &AnalysisType) -> Option<ResolvedType> {
|
||||
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
|
||||
let scalar = match scalar {
|
||||
ScalarType::Boolean => ResolvedScalar::Boolean,
|
||||
ScalarType::Number => ResolvedScalar::Numeric(NumericType::Number),
|
||||
ScalarType::Quantity(reference) => {
|
||||
ResolvedScalar::Numeric(NumericType::Named(self.kind(*reference)?.clone()))
|
||||
}
|
||||
};
|
||||
Some(match value {
|
||||
AnalysisType::Scalar(_) => ResolvedType::Scalar(scalar),
|
||||
AnalysisType::List(_) => ResolvedType::List(scalar),
|
||||
})
|
||||
}
|
||||
pub fn effective_binding(
|
||||
&self,
|
||||
property: Id,
|
||||
context: EvaluationContext,
|
||||
) -> Result<EffectiveBinding<'p>, AnalysisError> {
|
||||
let element = self.project.elements.get(&property).ok_or_else(|| {
|
||||
AnalysisError::new(
|
||||
property,
|
||||
"context",
|
||||
"analysis_property",
|
||||
"Property does not exist.",
|
||||
)
|
||||
})?;
|
||||
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared() else {
|
||||
return Err(AnalysisError::new(
|
||||
property,
|
||||
"context",
|
||||
"analysis_property",
|
||||
"Target is not an engineering property.",
|
||||
));
|
||||
};
|
||||
if let EvaluationContext::Case(id) = context {
|
||||
let case = self.project.elements.get(&id).ok_or_else(|| {
|
||||
AnalysisError::new(id, "context", "analysis_case", "Case does not exist.")
|
||||
})?;
|
||||
let Some(SharedBody::AnalysisCase { overrides, .. }) = case.shared() else {
|
||||
return Err(AnalysisError::new(
|
||||
id,
|
||||
"context",
|
||||
"analysis_case",
|
||||
"Context is not an analysis case.",
|
||||
));
|
||||
};
|
||||
if case.layer() != element.layer() {
|
||||
return Err(AnalysisError::new(
|
||||
id,
|
||||
"context",
|
||||
"analysis_layer",
|
||||
"Case and property must share a layer.",
|
||||
));
|
||||
}
|
||||
if let Some(binding) = overrides.get(&property) {
|
||||
return Ok(EffectiveBinding {
|
||||
binding,
|
||||
origin: BindingOrigin::CaseOverride(id),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(EffectiveBinding {
|
||||
binding: base,
|
||||
origin: BindingOrigin::ModelBaseline,
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,277 @@
|
||||
//! Static expression checking shares numeric operator algebra with execution.
|
||||
use super::admission::Budget;
|
||||
use super::*;
|
||||
use crate::analysis_numeric::{NumericError, NumericType};
|
||||
use crate::{Id, Layer, SharedBody};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) fn binding(
|
||||
environment: &ResolvedAnalysis<'_>,
|
||||
owner: Id,
|
||||
field: &str,
|
||||
value: &Binding,
|
||||
expected: &ResolvedType,
|
||||
layer: Option<Layer>,
|
||||
parameters: &BTreeMap<ParameterId, ResolvedType>,
|
||||
budget: &mut Budget,
|
||||
) -> Result<(), AnalysisError> {
|
||||
if let Binding::Expression(expression) = value {
|
||||
let mut checker = Checker {
|
||||
environment,
|
||||
owner,
|
||||
field,
|
||||
layer,
|
||||
parameters,
|
||||
budget,
|
||||
};
|
||||
checker.expression(expression, Some(expected), 1)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Checker<'a, 'p, 'b> {
|
||||
environment: &'a ResolvedAnalysis<'p>,
|
||||
owner: Id,
|
||||
field: &'a str,
|
||||
layer: Option<Layer>,
|
||||
parameters: &'a BTreeMap<ParameterId, ResolvedType>,
|
||||
budget: &'b mut Budget,
|
||||
}
|
||||
impl Checker<'_, '_, '_> {
|
||||
fn error(&self, code: &str, message: impl Into<String>) -> AnalysisError {
|
||||
AnalysisError::new(self.owner, self.field, code, message)
|
||||
}
|
||||
fn numeric(&self, value: ResolvedType) -> Result<NumericType<KindHandle>, AnalysisError> {
|
||||
match value {
|
||||
ResolvedType::Scalar(ResolvedScalar::Numeric(value)) => Ok(value),
|
||||
_ => Err(self.error("analysis_type", "A scalar numeric operand is required.")),
|
||||
}
|
||||
}
|
||||
fn operation(
|
||||
&self,
|
||||
result: Result<NumericType<KindHandle>, NumericError>,
|
||||
) -> Result<ResolvedType, AnalysisError> {
|
||||
result
|
||||
.map(ResolvedType::numeric)
|
||||
.map_err(|error| self.error("analysis_type", error.to_string()))
|
||||
}
|
||||
fn expression(
|
||||
&mut self,
|
||||
expression: &Expr,
|
||||
expected: Option<&ResolvedType>,
|
||||
depth: usize,
|
||||
) -> Result<ResolvedType, AnalysisError> {
|
||||
self.budget.charge(self.owner, self.field, 1)?;
|
||||
if depth > MAX_EXPRESSION_DEPTH {
|
||||
return Err(self.error(
|
||||
"analysis_depth",
|
||||
"Expression exceeds the logical depth limit.",
|
||||
));
|
||||
}
|
||||
let next = depth + 1;
|
||||
let actual = match expression {
|
||||
Expr::Boolean(_) => ResolvedType::boolean(),
|
||||
Expr::Number(_) => ResolvedType::numeric(NumericType::Number),
|
||||
Expr::Quantity { unit, .. } => {
|
||||
let unit = self.environment.unit(*unit).ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_unit",
|
||||
"Quantity literal must reference an existing unit definition.",
|
||||
)
|
||||
})?;
|
||||
ResolvedType::numeric(NumericType::Named(unit.kind().clone()))
|
||||
}
|
||||
Expr::Property(id) => {
|
||||
let layer = self.layer.ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_purity",
|
||||
"Reusable calculations cannot capture project properties.",
|
||||
)
|
||||
})?;
|
||||
let element =
|
||||
self.environment.project.elements.get(id).ok_or_else(|| {
|
||||
self.error("analysis_property", "Property does not exist.")
|
||||
})?;
|
||||
let Some(SharedBody::EngineeringProperty { value_type, .. }) = element.shared()
|
||||
else {
|
||||
return Err(self.error(
|
||||
"analysis_property",
|
||||
"Reference must target an engineering property.",
|
||||
));
|
||||
};
|
||||
if element.layer() != layer {
|
||||
return Err(self.error(
|
||||
"analysis_layer",
|
||||
"Property references must stay in the same layer.",
|
||||
));
|
||||
}
|
||||
self.environment.declared_type(value_type).ok_or_else(|| {
|
||||
self.error("analysis_kind", "Declared property kind does not resolve.")
|
||||
})?
|
||||
}
|
||||
Expr::Parameter(id) => self.parameters.get(id).cloned().ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_parameter",
|
||||
"Parameter does not belong to this calculation definition.",
|
||||
)
|
||||
})?,
|
||||
Expr::List(items) => {
|
||||
if items.len() > MAX_LIST_LENGTH {
|
||||
return Err(
|
||||
self.error("analysis_list", "List exceeds the flat-list length limit.")
|
||||
);
|
||||
}
|
||||
let mut scalar = match expected {
|
||||
Some(ResolvedType::List(value)) => Some(value.clone()),
|
||||
_ => None,
|
||||
};
|
||||
for item in items {
|
||||
let wanted = scalar.clone().map(ResolvedType::Scalar);
|
||||
let ResolvedType::Scalar(found) =
|
||||
self.expression(item, wanted.as_ref(), next)?
|
||||
else {
|
||||
return Err(self.error(
|
||||
"analysis_list",
|
||||
"Lists are flat; nested lists are not admitted.",
|
||||
));
|
||||
};
|
||||
scalar = Some(found);
|
||||
}
|
||||
ResolvedType::List(scalar.ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_list",
|
||||
"An empty list requires an explicit surrounding list type.",
|
||||
)
|
||||
})?)
|
||||
}
|
||||
Expr::Unary { op, operand } => match op {
|
||||
UnaryOp::Not => {
|
||||
self.expression(operand, Some(&ResolvedType::boolean()), next)?;
|
||||
ResolvedType::boolean()
|
||||
}
|
||||
UnaryOp::Negate | UnaryOp::Absolute => {
|
||||
let operand = self.expression(operand, None, next)?;
|
||||
self.operation(self.numeric(operand)?.unary_result())?
|
||||
}
|
||||
},
|
||||
Expr::Binary { op, left, right } => {
|
||||
let left = self.expression(left, None, next)?;
|
||||
let right = self.expression(right, None, next)?;
|
||||
match op {
|
||||
BinaryOp::And | BinaryOp::Or => {
|
||||
if left != ResolvedType::boolean() || right != ResolvedType::boolean() {
|
||||
return Err(
|
||||
self.error("analysis_type", "Boolean operands are required.")
|
||||
);
|
||||
}
|
||||
ResolvedType::boolean()
|
||||
}
|
||||
BinaryOp::Equal | BinaryOp::NotEqual
|
||||
if left == ResolvedType::boolean() && right == ResolvedType::boolean() =>
|
||||
{
|
||||
ResolvedType::boolean()
|
||||
}
|
||||
BinaryOp::Equal
|
||||
| BinaryOp::NotEqual
|
||||
| BinaryOp::Less
|
||||
| BinaryOp::LessOrEqual
|
||||
| BinaryOp::Greater
|
||||
| BinaryOp::GreaterOrEqual => {
|
||||
self.numeric(left)?
|
||||
.comparison_compatible(&self.numeric(right)?)
|
||||
.map_err(|error| self.error("analysis_type", error.to_string()))?;
|
||||
ResolvedType::boolean()
|
||||
}
|
||||
BinaryOp::Add | BinaryOp::Subtract => self.operation(
|
||||
self.numeric(left)?
|
||||
.additive_result(&self.numeric(right)?, *op == BinaryOp::Subtract),
|
||||
)?,
|
||||
BinaryOp::Multiply | BinaryOp::Divide => self.operation(
|
||||
self.numeric(left)?
|
||||
.multiplicative_result(&self.numeric(right)?, *op == BinaryOp::Divide),
|
||||
)?,
|
||||
}
|
||||
}
|
||||
Expr::If {
|
||||
condition,
|
||||
when_true,
|
||||
when_false,
|
||||
} => {
|
||||
self.expression(condition, Some(&ResolvedType::boolean()), next)?;
|
||||
let true_type = self.expression(when_true, expected, next)?;
|
||||
self.expression(when_false, Some(&true_type), next)?;
|
||||
true_type
|
||||
}
|
||||
Expr::Call {
|
||||
definition,
|
||||
arguments,
|
||||
} => {
|
||||
let calculation = self.environment.calculation(*definition).ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_calculation",
|
||||
"Call must reference a calculation definition.",
|
||||
)
|
||||
})?;
|
||||
if arguments.len() != calculation.parameters.len()
|
||||
|| calculation
|
||||
.parameters
|
||||
.iter()
|
||||
.any(|parameter| !arguments.contains_key(¶meter.id))
|
||||
{
|
||||
return Err(self.error("analysis_arguments", "Call arguments must bind every declared parameter exactly once, by its scoped identity."));
|
||||
}
|
||||
// Declaration order, not UUID map order, is the evaluation contract.
|
||||
for parameter in &calculation.parameters {
|
||||
let expected = self
|
||||
.environment
|
||||
.declared_type(¶meter.value_type)
|
||||
.ok_or_else(|| {
|
||||
self.error("analysis_kind", "Parameter kind does not resolve.")
|
||||
})?;
|
||||
self.expression(&arguments[¶meter.id], Some(&expected), next)?;
|
||||
}
|
||||
self.environment
|
||||
.declared_type(&calculation.result)
|
||||
.ok_or_else(|| {
|
||||
self.error("analysis_kind", "Calculation result kind does not resolve.")
|
||||
})?
|
||||
}
|
||||
Expr::Sum { values, initial } => {
|
||||
let initial = self.expression(initial, None, next)?;
|
||||
let numeric = self.numeric(initial.clone())?;
|
||||
self.operation(numeric.unary_result())?;
|
||||
let ResolvedType::Scalar(scalar) = &initial else {
|
||||
unreachable!()
|
||||
};
|
||||
self.expression(values, Some(&ResolvedType::List(scalar.clone())), next)?;
|
||||
self.operation(numeric.additive_result(&numeric, false))?;
|
||||
initial
|
||||
}
|
||||
Expr::Power { base, exponent } => {
|
||||
let base = self.expression(base, None, next)?;
|
||||
self.operation(self.numeric(base)?.power_result(*exponent))?
|
||||
}
|
||||
Expr::AsKind { kind, value } => {
|
||||
let kind = self
|
||||
.environment
|
||||
.kind(*kind)
|
||||
.ok_or_else(|| {
|
||||
self.error(
|
||||
"analysis_kind",
|
||||
"Kind assertion must reference a quantity kind.",
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
let value = self.expression(value, None, next)?;
|
||||
self.operation(self.numeric(value)?.assertion_result(&kind))?
|
||||
}
|
||||
};
|
||||
if expected.is_some_and(|expected| expected != &actual) {
|
||||
return Err(self.error(
|
||||
"analysis_type",
|
||||
format!("Expected {expected:?}; expression has {actual:?}."),
|
||||
));
|
||||
}
|
||||
Ok(actual)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Persistent analysis vocabulary. This is part of Project, not an evaluation model.
|
||||
use crate::Id;
|
||||
use crate::analysis_numeric::{Dimension, Exact};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Only real local definitions are admitted in schema 8's first environment.
|
||||
/// Imported references require the separately implemented pinned package catalog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DefinitionRef {
|
||||
Local(Id),
|
||||
}
|
||||
impl DefinitionRef {
|
||||
pub fn local(self) -> Id {
|
||||
match self {
|
||||
Self::Local(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Qualified by its owning CalculationDefinition; never a global model identity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ParameterId(pub Id);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Definition {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub body: DefinitionBody,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DefinitionBody {
|
||||
QuantityKind(QuantityKindDefinition),
|
||||
Unit(UnitDefinition),
|
||||
Calculation(CalculationDefinition),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct QuantityKindDefinition {
|
||||
pub dimension: Dimension,
|
||||
pub algebra: QuantityAlgebra,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum QuantityAlgebra {
|
||||
Linear,
|
||||
Affine { difference: DefinitionRef },
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UnitDefinition {
|
||||
pub kind: DefinitionRef,
|
||||
pub scale: Exact,
|
||||
pub offset: Exact,
|
||||
pub symbol: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum ScalarType {
|
||||
Boolean,
|
||||
Number,
|
||||
Quantity(DefinitionRef),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum AnalysisType {
|
||||
Scalar(ScalarType),
|
||||
List(ScalarType),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Parameter {
|
||||
pub id: ParameterId,
|
||||
pub name: String,
|
||||
pub value_type: AnalysisType,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CalculationDefinition {
|
||||
pub parameters: Vec<Parameter>,
|
||||
pub result: AnalysisType,
|
||||
pub body: Binding,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Binding {
|
||||
Unbound,
|
||||
Expression(Expr),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum UnaryOp {
|
||||
Negate,
|
||||
Absolute,
|
||||
Not,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BinaryOp {
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Divide,
|
||||
Equal,
|
||||
NotEqual,
|
||||
Less,
|
||||
LessOrEqual,
|
||||
Greater,
|
||||
GreaterOrEqual,
|
||||
And,
|
||||
Or,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Expr {
|
||||
Boolean(bool),
|
||||
Number(Exact),
|
||||
Quantity {
|
||||
magnitude: Exact,
|
||||
unit: DefinitionRef,
|
||||
},
|
||||
Property(Id),
|
||||
Parameter(ParameterId),
|
||||
List(Vec<Expr>),
|
||||
Unary {
|
||||
op: UnaryOp,
|
||||
operand: Box<Expr>,
|
||||
},
|
||||
Binary {
|
||||
op: BinaryOp,
|
||||
left: Box<Expr>,
|
||||
right: Box<Expr>,
|
||||
},
|
||||
If {
|
||||
condition: Box<Expr>,
|
||||
when_true: Box<Expr>,
|
||||
when_false: Box<Expr>,
|
||||
},
|
||||
Call {
|
||||
definition: DefinitionRef,
|
||||
#[serde(deserialize_with = "unique_parameter_map")]
|
||||
arguments: BTreeMap<ParameterId, Expr>,
|
||||
},
|
||||
Sum {
|
||||
values: Box<Expr>,
|
||||
initial: Box<Expr>,
|
||||
},
|
||||
Power {
|
||||
base: Box<Expr>,
|
||||
exponent: i8,
|
||||
},
|
||||
AsKind {
|
||||
kind: DefinitionRef,
|
||||
value: Box<Expr>,
|
||||
},
|
||||
}
|
||||
|
||||
fn unique_parameter_map<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<BTreeMap<ParameterId, Expr>, D::Error> {
|
||||
crate::unique_id_map(deserializer).map(|map| {
|
||||
map.into_iter()
|
||||
.map(|(id, expression)| (ParameterId(id), expression))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum AnalysisSubject {
|
||||
Element(Id),
|
||||
Relation(Id),
|
||||
}
|
||||
impl AnalysisSubject {
|
||||
pub fn id(self) -> Id {
|
||||
match self {
|
||||
Self::Element(id) | Self::Relation(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BaselineBase {
|
||||
ModelBaseline,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum EvaluationContext {
|
||||
ModelBaseline,
|
||||
Case(Id),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum BindingOrigin {
|
||||
ModelBaseline,
|
||||
CaseOverride(Id),
|
||||
}
|
||||
|
||||
/// Located structural/static admission failure, not a false or failed evaluation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[error("{code} at {object}.{field}: {message}")]
|
||||
pub struct AnalysisError {
|
||||
pub object: Id,
|
||||
pub field: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
impl AnalysisError {
|
||||
pub(crate) fn new(
|
||||
object: Id,
|
||||
field: impl Into<String>,
|
||||
code: &str,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
object,
|
||||
field: field.into(),
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ impl<K> NumericType<K> {
|
||||
matches!(self, Self::Named(kind) if kind.is_affine())
|
||||
}
|
||||
|
||||
fn anonymous(dimension: Dimension) -> Self {
|
||||
pub fn anonymous(dimension: Dimension) -> Self {
|
||||
if dimension.is_dimensionless() {
|
||||
Self::Number
|
||||
} else {
|
||||
@@ -161,7 +161,7 @@ impl<K> NumericType<K> {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(self) -> Self {
|
||||
pub fn normalized(self) -> Self {
|
||||
match self {
|
||||
Self::Anonymous(dimension) => Self::anonymous(dimension),
|
||||
other => other,
|
||||
@@ -169,6 +169,93 @@ impl<K> NumericType<K> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> NumericType<K> {
|
||||
/// Pure R6 algebra; does not evaluate coordinates or domain conditions.
|
||||
pub fn additive_result(&self, rhs: &Self, subtract: bool) -> Result<Self, NumericError> {
|
||||
use NumericType::{Anonymous, Named, Number};
|
||||
let (left, right) = (self.clone().normalized(), rhs.clone().normalized());
|
||||
match (&left, &right) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(Anonymous(a), Anonymous(b)) if a == b => Ok(Anonymous(*a)),
|
||||
(Named(a), Named(b)) => match (&a.difference, &b.difference) {
|
||||
(None, None) if a == b => Ok(Named(a.clone())),
|
||||
(Some(difference), Some(_)) if subtract && a == b => {
|
||||
Ok(Named(ResolvedKind::linear(difference.clone(), a.dimension)))
|
||||
}
|
||||
(Some(difference), None) if difference == &b.key && a.dimension == b.dimension => {
|
||||
Ok(Named(a.clone()))
|
||||
}
|
||||
(None, Some(difference))
|
||||
if !subtract && difference == &a.key && a.dimension == b.dimension =>
|
||||
{
|
||||
Ok(Named(b.clone()))
|
||||
}
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
},
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn multiplicative_result(&self, rhs: &Self, divide: bool) -> Result<Self, NumericError> {
|
||||
use NumericType::Number;
|
||||
let (left, right) = (self.clone().normalized(), rhs.clone().normalized());
|
||||
if left.is_affine() || right.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
match (&left, &right) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(_, Number) => Ok(left),
|
||||
(Number, _) if !divide => Ok(right),
|
||||
_ => Ok(Self::anonymous(if divide {
|
||||
left.dimension().checked_quotient(right.dimension())?
|
||||
} else {
|
||||
left.dimension().checked_product(right.dimension())?
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unary_result(&self) -> Result<Self, NumericError> {
|
||||
if self.is_affine() {
|
||||
Err(NumericError::AffineOperation)
|
||||
} else {
|
||||
Ok(self.clone().normalized())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn power_result(&self, exponent: i8) -> Result<Self, NumericError> {
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(match exponent {
|
||||
0 => Self::Number,
|
||||
1 => self.clone().normalized(),
|
||||
_ => Self::anonymous(self.dimension().checked_power(exponent)?),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn comparison_compatible(&self, rhs: &Self) -> Result<(), NumericError> {
|
||||
if self.clone().normalized() == rhs.clone().normalized() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(NumericError::IncompatibleTypes)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assertion_result(&self, target: &ResolvedKind<K>) -> Result<Self, NumericError> {
|
||||
if target.is_affine()
|
||||
|| matches!(self, Self::Named(_))
|
||||
|| self.dimension() != target.dimension
|
||||
{
|
||||
Err(NumericError::KindAssertion)
|
||||
} else {
|
||||
Ok(Self::Named(target.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A canonical rational coordinate with an internal numerical type.
|
||||
///
|
||||
/// Derived Eq compares stored representation. Formal equality/ordering must call
|
||||
@@ -223,7 +310,7 @@ impl<K: Clone + Eq> NumericValue<K> {
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.additive_type(rhs, false)?;
|
||||
let output = self.value_type.additive_result(&rhs.value_type, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.add(&rhs.coordinate, meter)?,
|
||||
@@ -232,44 +319,18 @@ impl<K: Clone + Eq> NumericValue<K> {
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.additive_type(rhs, true)?;
|
||||
let output = self.value_type.additive_result(&rhs.value_type, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.subtract(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn additive_type(&self, rhs: &Self, subtract: bool) -> Result<NumericType<K>, NumericError> {
|
||||
use NumericType::{Anonymous, Named, Number};
|
||||
match (&self.value_type, &rhs.value_type) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(Anonymous(left), Anonymous(right)) if left == right => Ok(Anonymous(*left)),
|
||||
(Named(left), Named(right)) => match (&left.difference, &right.difference) {
|
||||
(None, None) if left == right => Ok(Named(left.clone())),
|
||||
(Some(difference), Some(_)) if subtract && left == right => Ok(Named(
|
||||
ResolvedKind::linear(difference.clone(), left.dimension),
|
||||
)),
|
||||
(Some(difference), None)
|
||||
if difference == &right.key && left.dimension == right.dimension =>
|
||||
{
|
||||
Ok(Named(left.clone()))
|
||||
}
|
||||
(None, Some(difference))
|
||||
if !subtract
|
||||
&& difference == &left.key
|
||||
&& left.dimension == right.dimension =>
|
||||
{
|
||||
Ok(Named(right.clone()))
|
||||
}
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
},
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.multiplicative_type(rhs, false)?;
|
||||
let output = self
|
||||
.value_type
|
||||
.multiplicative_result(&rhs.value_type, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.multiply(&rhs.coordinate, meter)?,
|
||||
@@ -278,82 +339,36 @@ impl<K: Clone + Eq> NumericValue<K> {
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.multiplicative_type(rhs, true)?;
|
||||
let output = self
|
||||
.value_type
|
||||
.multiplicative_result(&rhs.value_type, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.divide(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn multiplicative_type(
|
||||
&self,
|
||||
rhs: &Self,
|
||||
divide: bool,
|
||||
) -> Result<NumericType<K>, NumericError> {
|
||||
use NumericType::Number;
|
||||
if self.value_type.is_affine() || rhs.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
match (&self.value_type, &rhs.value_type) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(_, Number) => Ok(self.value_type.clone()),
|
||||
(Number, _) if !divide => Ok(rhs.value_type.clone()),
|
||||
_ => {
|
||||
let left = self.value_type.dimension();
|
||||
let right = rhs.value_type.dimension();
|
||||
let dimension = if divide {
|
||||
left.checked_quotient(right)?
|
||||
} else {
|
||||
left.checked_product(right)?
|
||||
};
|
||||
Ok(NumericType::anonymous(dimension))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(Self::new(
|
||||
self.value_type.clone(),
|
||||
self.coordinate.negated(meter)?,
|
||||
))
|
||||
let output = self.value_type.unary_result()?;
|
||||
Ok(Self::new(output, self.coordinate.negated(meter)?))
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(Self::new(
|
||||
self.value_type.clone(),
|
||||
self.coordinate.absolute(meter)?,
|
||||
))
|
||||
let output = self.value_type.unary_result()?;
|
||||
Ok(Self::new(output, self.coordinate.absolute(meter)?))
|
||||
}
|
||||
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
let output = match exponent {
|
||||
0 => NumericType::Number,
|
||||
1 => self.value_type.clone(),
|
||||
_ => NumericType::anonymous(self.value_type.dimension().checked_power(exponent)?),
|
||||
};
|
||||
let output = self.value_type.power_result(exponent)?;
|
||||
Ok(Self::new(output, self.coordinate.power(exponent, meter)?))
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type != rhs.value_type {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
self.value_type.comparison_compatible(&rhs.value_type)?;
|
||||
self.coordinate.compare(&rhs.coordinate, meter)
|
||||
}
|
||||
|
||||
@@ -363,16 +378,8 @@ impl<K: Clone + Eq> NumericValue<K> {
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if target.is_affine()
|
||||
|| matches!(self.value_type, NumericType::Named(_))
|
||||
|| self.value_type.dimension() != target.dimension
|
||||
{
|
||||
return Err(NumericError::KindAssertion);
|
||||
}
|
||||
let output = self.value_type.assertion_result(target)?;
|
||||
meter.numeric(NumericOperation::KindAssertion, self.coordinate.copy_cost())?;
|
||||
Ok(Self::new(
|
||||
NumericType::Named(target.clone()),
|
||||
self.coordinate.clone(),
|
||||
))
|
||||
Ok(Self::new(output, self.coordinate.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,8 @@ impl ParseLimits {
|
||||
let graph = project
|
||||
.elements
|
||||
.len()
|
||||
.saturating_add(project.relations.len());
|
||||
.saturating_add(project.relations.len())
|
||||
.saturating_add(project.definitions.len());
|
||||
let records = graph
|
||||
.saturating_add(1)
|
||||
.saturating_add(project.diagrams.len())
|
||||
@@ -79,7 +80,8 @@ impl ParseLimits {
|
||||
.sum::<usize>(),
|
||||
)
|
||||
.saturating_add(project.artifact_links.len())
|
||||
.saturating_add(project.traces.len());
|
||||
.saturating_add(project.traces.len())
|
||||
.saturating_add(crate::analysis::nested_record_count(project));
|
||||
let steps = project
|
||||
.elements
|
||||
.values()
|
||||
@@ -1426,6 +1428,7 @@ impl Parser<'_> {
|
||||
alias,
|
||||
name,
|
||||
description: String::new(),
|
||||
definitions: BTreeMap::new(),
|
||||
elements: BTreeMap::new(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
@@ -1646,6 +1649,9 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
parent = Some(owner);
|
||||
}
|
||||
if kind.is_analysis() {
|
||||
return Err(conversion_error("Analysis records require the source8 authoring implementation.".into()));
|
||||
}
|
||||
let mut initial = if kind == Kind::Port {
|
||||
if !fields.contains_key("contract") {
|
||||
return Err(conversion_error(
|
||||
@@ -1875,13 +1881,15 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
return Ok(migrated);
|
||||
}
|
||||
if matches!(project.schema_version, 4..=6) {
|
||||
if matches!(project.schema_version, 4..=7) {
|
||||
return (if project.schema_version == 4 {
|
||||
crate::persistence::v4::finish(project, limits)
|
||||
} else if project.schema_version == 5 {
|
||||
crate::persistence::migrate_v5(project, limits)
|
||||
} else {
|
||||
} else if project.schema_version == 6 {
|
||||
crate::persistence::migrate_v6(project, limits)
|
||||
} else {
|
||||
crate::persistence::migrate_v7(project, limits)
|
||||
})
|
||||
.map_err(|error| {
|
||||
(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod analysis;
|
||||
pub mod analysis_numeric;
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
|
||||
@@ -75,6 +75,7 @@ impl Project4 {
|
||||
alias: self.alias,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
definitions: BTreeMap::new(),
|
||||
elements: self.elements,
|
||||
relations: self.relations,
|
||||
diagrams: self.diagrams,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Frozen schema5/6/7 top-level envelope. No schema8 definition catalog/defaults.
|
||||
//! Unchanged nested records are shared only after the closed old-kind check.
|
||||
use crate::persistence::FileError;
|
||||
use crate::*;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Project7 {
|
||||
pub schema_version: u32,
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub elements: BTreeMap<Id, Element>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub relations: BTreeMap<Id, Relation>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub diagrams: BTreeMap<Id, Diagram>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub artifact_links: BTreeMap<Id, ArtifactLink>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub traces: BTreeMap<Id, Trace>,
|
||||
}
|
||||
impl Project7 {
|
||||
pub(crate) fn into_current(self) -> Result<Project, FileError> {
|
||||
if !(5..=7).contains(&self.schema_version) {
|
||||
return Err(FileError::Invalid(
|
||||
"Expected frozen schema5, 6 or 7.".into(),
|
||||
));
|
||||
}
|
||||
reject_elements(self.elements.values())?;
|
||||
Ok(Project {
|
||||
schema_version: self.schema_version,
|
||||
id: self.id,
|
||||
alias: self.alias,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
definitions: BTreeMap::new(),
|
||||
elements: self.elements,
|
||||
relations: self.relations,
|
||||
diagrams: self.diagrams,
|
||||
artifact_links: self.artifact_links,
|
||||
traces: self.traces,
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(crate) fn reject_elements<'a>(
|
||||
elements: impl Iterator<Item = &'a Element>,
|
||||
) -> Result<(), FileError> {
|
||||
for element in elements {
|
||||
if element.kind().is_analysis() {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"{:?} requires schema/source 8.",
|
||||
element.kind()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 7;
|
||||
pub const SCHEMA_VERSION: u32 = 8;
|
||||
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -64,9 +64,12 @@ pub enum Kind {
|
||||
Terminate,
|
||||
Event,
|
||||
Constraint,
|
||||
EngineeringProperty,
|
||||
AnalysisCase,
|
||||
AnalysisCheck,
|
||||
}
|
||||
impl Kind {
|
||||
pub const ALL: [Self; 39] = [
|
||||
pub const ALL: [Self; 42] = [
|
||||
Self::Entity,
|
||||
Self::Actor,
|
||||
Self::Role,
|
||||
@@ -106,6 +109,9 @@ impl Kind {
|
||||
Self::Terminate,
|
||||
Self::Event,
|
||||
Self::Constraint,
|
||||
Self::EngineeringProperty,
|
||||
Self::AnalysisCase,
|
||||
Self::AnalysisCheck,
|
||||
];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -148,6 +154,9 @@ impl Kind {
|
||||
Self::Terminate => "Terminate",
|
||||
Self::Event => "Event",
|
||||
Self::Constraint => "Constraint",
|
||||
Self::EngineeringProperty => "Engineering property",
|
||||
Self::AnalysisCase => "Analysis case",
|
||||
Self::AnalysisCheck => "Analysis check",
|
||||
}
|
||||
}
|
||||
pub fn is_performer(self) -> bool {
|
||||
@@ -156,6 +165,9 @@ impl Kind {
|
||||
Self::Entity | Self::Actor | Self::System | Self::SystemActor
|
||||
)
|
||||
}
|
||||
pub fn is_analysis(self) -> bool {
|
||||
matches!(self, Self::EngineeringProperty | Self::AnalysisCase | Self::AnalysisCheck)
|
||||
}
|
||||
pub fn is_work(self) -> bool {
|
||||
matches!(self, Self::Activity | Self::Function)
|
||||
}
|
||||
@@ -652,6 +664,23 @@ pub enum SharedBody {
|
||||
expression: String,
|
||||
constrained: Vec<Id>,
|
||||
},
|
||||
EngineeringProperty {
|
||||
subject: crate::analysis::AnalysisSubject,
|
||||
value_type: crate::analysis::AnalysisType,
|
||||
display_unit: Option<crate::analysis::DefinitionRef>,
|
||||
base: crate::analysis::Binding,
|
||||
},
|
||||
AnalysisCase {
|
||||
subject: Option<crate::analysis::AnalysisSubject>,
|
||||
base: crate::analysis::BaselineBase,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
overrides: BTreeMap<Id, crate::analysis::Binding>,
|
||||
},
|
||||
AnalysisCheck {
|
||||
subject: Option<crate::analysis::AnalysisSubject>,
|
||||
documented_constraint: Option<Id>,
|
||||
predicate: crate::analysis::Binding,
|
||||
},
|
||||
}
|
||||
impl Element {
|
||||
pub fn layer(&self) -> Layer {
|
||||
@@ -752,7 +781,8 @@ impl ElementBody {
|
||||
}
|
||||
}
|
||||
pub fn new(kind: Kind, layer: Layer) -> Option<Self> {
|
||||
if !kind.supports(layer) {
|
||||
// Analysis creation requires explicitly authored types and bindings.
|
||||
if !kind.supports(layer) || kind.is_analysis() {
|
||||
return None;
|
||||
}
|
||||
Some(match kind {
|
||||
@@ -864,6 +894,9 @@ impl SharedBody {
|
||||
Self::Region => Kind::Region,
|
||||
Self::State { kind, .. } => kind.kind(),
|
||||
Self::Constraint { .. } => Kind::Constraint,
|
||||
Self::EngineeringProperty { .. } => Kind::EngineeringProperty,
|
||||
Self::AnalysisCase { .. } => Kind::AnalysisCase,
|
||||
Self::AnalysisCheck { .. } => Kind::AnalysisCheck,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1625,6 +1658,8 @@ pub struct Project {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub definitions: BTreeMap<Id, crate::analysis::Definition>,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub elements: BTreeMap<Id, Element>,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub relations: BTreeMap<Id, Relation>,
|
||||
@@ -1717,6 +1752,7 @@ impl Project {
|
||||
alias: "mission".into(),
|
||||
name: "Untitled mission".into(),
|
||||
description: String::new(),
|
||||
definitions: BTreeMap::new(),
|
||||
elements: BTreeMap::new(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
@@ -1726,6 +1762,7 @@ impl Project {
|
||||
}
|
||||
pub fn identities(&self) -> Vec<(Id, &str)> {
|
||||
let mut out = vec![(self.id, self.alias.as_str())];
|
||||
out.extend(self.definitions.values().map(|v| (v.id, v.alias.as_str())));
|
||||
out.extend(self.elements.values().map(|v| (v.id, v.alias.as_str())));
|
||||
out.extend(self.relations.values().map(|v| (v.id, v.alias.as_str())));
|
||||
out.extend(self.diagrams.values().map(|v| (v.id, v.alias.as_str())));
|
||||
@@ -1783,6 +1820,9 @@ impl Project {
|
||||
if id == self.id {
|
||||
return &self.name;
|
||||
}
|
||||
if let Some(v) = self.definitions.get(&id) {
|
||||
return &v.name;
|
||||
}
|
||||
if let Some(v) = self.elements.get(&id) {
|
||||
return &v.name;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||
pub(crate) mod v3;
|
||||
#[path = "migration_v4.rs"]
|
||||
pub(crate) mod v4;
|
||||
#[path = "migration_v7.rs"]
|
||||
pub(crate) mod v7;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -188,15 +190,16 @@ pub fn decode_with_report_limited(
|
||||
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(7) => DecodeResult {
|
||||
Some(5) => migrate_v5(serde_json::from_slice::<v7::Project7>(bytes)?.into_current()?, limits)?,
|
||||
Some(6) => migrate_v6(serde_json::from_slice::<v7::Project7>(bytes)?.into_current()?, limits)?,
|
||||
Some(7) => migrate_v7(serde_json::from_slice::<v7::Project7>(bytes)?.into_current()?, limits)?,
|
||||
Some(8) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6, 7 or 8.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -348,6 +351,7 @@ pub(crate) fn migrate_v5(
|
||||
if project.schema_version != 5 {
|
||||
return Err(FileError::Invalid("Expected schema_version 5.".into()));
|
||||
}
|
||||
reject_pre8_project(&project)?;
|
||||
check_pre6_relations(project.relations.values())?;
|
||||
check_pre6_elements(project.elements.values())?;
|
||||
for baseline in project
|
||||
@@ -420,6 +424,7 @@ pub(crate) fn migrate_v6(
|
||||
if project.schema_version != 6 {
|
||||
return Err(FileError::Invalid("Expected schema_version 6.".into()));
|
||||
}
|
||||
reject_pre8_project(&project)?;
|
||||
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
@@ -429,6 +434,21 @@ pub(crate) fn migrate_v6(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_v7(
|
||||
mut project: Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
if project.schema_version != 7 { return Err(FileError::Invalid("Expected schema_version 7.".into())); }
|
||||
reject_pre8_project(&project)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
Ok(DecodeResult { project, migration: Some(MigrationReport::data_upgrade(7)) })
|
||||
}
|
||||
fn reject_pre8_project(project: &Project) -> Result<(), FileError> {
|
||||
if !project.definitions.is_empty() { return Err(FileError::Invalid("Definitions require schema/source 8.".into())); }
|
||||
v7::reject_elements(project.elements.values())
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_v1_with_report_limited(
|
||||
old: v1::Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
use crate::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Every live reference in a typed element, including structural ownership.
|
||||
/// Architectural/project references, including analysis subjects/properties.
|
||||
/// Layer-neutral definition references and scoped parameters have separate domains.
|
||||
pub fn element_references(e: &Element) -> Vec<Id> {
|
||||
if e.kind().is_analysis() {
|
||||
let mut references: Vec<_> = crate::analysis::references::element_model_references(e).into_iter().collect();
|
||||
references.extend(e.parent);
|
||||
return references;
|
||||
}
|
||||
let mut copy = e.clone();
|
||||
let mut refs = Vec::new();
|
||||
visit_element_refs(&mut copy, |id| refs.push(*id));
|
||||
@@ -14,6 +20,8 @@ pub fn relation_references(r: &Relation) -> Vec<Id> {
|
||||
visit_relation_refs(&mut copy, |id| refs.push(*id));
|
||||
refs
|
||||
}
|
||||
/// Architectural mutable slots only. Analysis map keys require the fallible
|
||||
/// whole-project remap; use analysis::references for its typed domains.
|
||||
pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
if let Some(parent) = &mut e.parent {
|
||||
visit(parent);
|
||||
@@ -203,7 +211,10 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
crate::analysis::references::check_remap(p, old, new)
|
||||
.map_err(|error| ModelError(error.to_string()))?;
|
||||
crate::analysis::references::remap_project(p, old, new);
|
||||
for e in p.elements.values_mut() {
|
||||
visit_element_refs(e, |id| {
|
||||
if *id == old {
|
||||
@@ -265,6 +276,7 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
||||
@@ -405,6 +417,9 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
.iter()
|
||||
.map(|id| (*id, p.named(*id).to_string()))
|
||||
.collect();
|
||||
// Analysis dependencies remain live: deleting their target rejects the
|
||||
// transaction unless that transaction also explicitly repairs/removes them.
|
||||
p.definitions.retain(|id, _| !removed.contains(id));
|
||||
p.elements.retain(|id, _| !removed.contains(id));
|
||||
p.relations.retain(|id, _| !removed.contains(id));
|
||||
p.diagrams.retain(|id, _| !removed.contains(id));
|
||||
|
||||
@@ -604,6 +604,9 @@ fn catalog_facets(
|
||||
include_linked: bool,
|
||||
) -> Result<Vec<FacetDrift>, String> {
|
||||
let (oa, sa) = (trace.oa, trace.sa);
|
||||
if [oa, sa].iter().any(|id| project.elements.get(id).is_some_and(|element| element.kind().is_analysis())) {
|
||||
return Err("Semantic review of analysis records is not implemented; header-only review is refused.".into());
|
||||
}
|
||||
let mut selectors: BTreeSet<_> = scalar_selectors()
|
||||
.into_iter()
|
||||
.filter(|s| {
|
||||
|
||||
@@ -19,6 +19,21 @@ pub enum Command {
|
||||
},
|
||||
CreateElement(Element),
|
||||
EditElement(Id, Element),
|
||||
CreateDefinition(crate::analysis::Definition),
|
||||
EditDefinition(Id, crate::analysis::Definition),
|
||||
SetAnalysisBase {
|
||||
property: Id,
|
||||
binding: crate::analysis::Binding,
|
||||
},
|
||||
SetAnalysisOverride {
|
||||
case: Id,
|
||||
property: Id,
|
||||
binding: crate::analysis::Binding,
|
||||
},
|
||||
ClearAnalysisOverride {
|
||||
case: Id,
|
||||
property: Id,
|
||||
},
|
||||
CreateRelation(Relation),
|
||||
EditRelation(Id, Relation),
|
||||
CreateDiagram(Diagram),
|
||||
@@ -453,6 +468,47 @@ fn apply_command(
|
||||
}
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateDefinition(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.definitions.insert(value.id, value.clone());
|
||||
Some(value.id)
|
||||
}
|
||||
Command::EditDefinition(old, value) => {
|
||||
if !p.definitions.contains_key(old) { return Err(missing()); }
|
||||
let mut definition = value.clone();
|
||||
definition.id = *old;
|
||||
p.definitions.insert(*old, definition);
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::SetAnalysisBase { property, binding } => {
|
||||
let element = p.elements.get_mut(property).ok_or_else(missing)?;
|
||||
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared_mut() else {
|
||||
return Err(ModelError("SetAnalysisBase requires an engineering property.".into()));
|
||||
};
|
||||
*base = binding.clone();
|
||||
Some(*property)
|
||||
}
|
||||
Command::SetAnalysisOverride { case, property, binding } => {
|
||||
let element = p.elements.get_mut(case).ok_or_else(missing)?;
|
||||
let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared_mut() else {
|
||||
return Err(ModelError("SetAnalysisOverride requires an analysis case.".into()));
|
||||
};
|
||||
overrides.insert(*property, binding.clone());
|
||||
Some(*case)
|
||||
}
|
||||
Command::ClearAnalysisOverride { case, property } => {
|
||||
let target = p.elements.get(property).ok_or_else(missing)?;
|
||||
if target.kind() != Kind::EngineeringProperty { return Err(ModelError("ClearAnalysisOverride requires an engineering property target.".into())); }
|
||||
let layer = target.layer();
|
||||
let element = p.elements.get_mut(case).ok_or_else(missing)?;
|
||||
if element.layer() != layer { return Err(ModelError("Case and property must share a layer.".into())); }
|
||||
let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared_mut() else {
|
||||
return Err(ModelError("ClearAnalysisOverride requires an analysis case.".into()));
|
||||
};
|
||||
overrides.remove(property);
|
||||
Some(*case)
|
||||
}
|
||||
Command::CreateRelation(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.relations.insert(value.id, value.clone());
|
||||
@@ -835,6 +891,10 @@ fn edit_header(
|
||||
assign(&mut p.name, &mut p.description, &mut p.alias);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(v) = p.definitions.get_mut(&id) {
|
||||
assign(&mut v.name, &mut v.description, &mut v.alias);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(v) = p.elements.get_mut(&id) {
|
||||
assign(&mut v.name, &mut v.description, &mut v.alias);
|
||||
return Ok(());
|
||||
@@ -878,8 +938,13 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
return Ok(());
|
||||
}
|
||||
vacant(p, new)?;
|
||||
crate::analysis::references::check_remap(p, old, new)
|
||||
.map_err(|error| ModelError(error.to_string()))?;
|
||||
if p.id == old {
|
||||
p.id = new;
|
||||
} else if let Some(mut v) = p.definitions.remove(&old) {
|
||||
v.id = new;
|
||||
p.definitions.insert(new, v);
|
||||
} else if let Some(mut v) = p.elements.remove(&old) {
|
||||
v.id = new;
|
||||
p.elements.insert(new, v);
|
||||
@@ -904,7 +969,7 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::references::remap_references(p, old, new);
|
||||
crate::references::remap_references(p, old, new)?;
|
||||
Ok(())
|
||||
}
|
||||
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
|
||||
|
||||
@@ -449,6 +449,13 @@ pub fn plan_transition(
|
||||
request: &TransitionRequest,
|
||||
) -> Result<TransitionPlan, ModelError> {
|
||||
validate_candidate(project)?;
|
||||
let is_analysis = |id: &Id| project.elements.get(id).is_some_and(|element| element.kind().is_analysis());
|
||||
if request.sources.iter().any(is_analysis)
|
||||
|| request.mappings.iter().any(|mapping| is_analysis(&mapping.source)
|
||||
|| mapping.target.as_ref().is_some_and(is_analysis)
|
||||
|| mapping.target_kind.is_some_and(Kind::is_analysis)) {
|
||||
return Err(ModelError("Analysis transition semantics are not implemented; copying or reviewing only record headers is refused.".into()));
|
||||
}
|
||||
let mut plan = TransitionPlan {
|
||||
base_fingerprint: project_fingerprint(project),
|
||||
request: request.clone(),
|
||||
@@ -608,6 +615,9 @@ pub fn plan_transition(
|
||||
}
|
||||
}
|
||||
}
|
||||
if selected.iter().any(is_analysis) {
|
||||
return Err(ModelError("Transition dependency closure includes analysis records; explicit analysis transition semantics are required.".into()));
|
||||
}
|
||||
selected.retain(|id| {
|
||||
if omitted.contains(id) {
|
||||
if reported_omissions.insert(*id) { plan.warnings.push(format!("SA-only omission: {} ({id}) has no OA port counterpart; the original is preserved.", project.named(*id))); }
|
||||
|
||||
@@ -180,6 +180,18 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
|
||||
}
|
||||
|
||||
pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
// Bound analysis trees before architectural visitors can clone an element.
|
||||
// Admission itself never enters this public validator recursively.
|
||||
if let Err(failure) = crate::analysis::admit_structure_checked(p, crate::analysis::AdmissionLimits::REFERENCE) {
|
||||
let mut out = Vec::new();
|
||||
error(&mut out, failure.object, &failure.code, &failure.field, failure.message);
|
||||
return out;
|
||||
}
|
||||
validate_structure(p)
|
||||
}
|
||||
|
||||
/// Architecture/header validation without recursively re-entering analysis admission.
|
||||
pub(crate) fn validate_structure(p: &Project) -> Vec<Issue> {
|
||||
let mut out = Vec::new();
|
||||
let coverage = coverage::Index::new(p);
|
||||
let topology_index = crate::topology_context::TopologyIndex::new(p);
|
||||
|
||||
Reference in New Issue
Block a user