From b96ff3935ab3f36b7144566e46559a05a29292a5 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 06:46:52 -0700 Subject: [PATCH] WIP checkpoint schema8 analysis core before user reboot --- crates/oa-core/src/analysis.rs | 56 + crates/oa-core/src/analysis/admission.rs | 821 ++++++++++++ crates/oa-core/src/analysis/references.rs | 246 ++++ crates/oa-core/src/analysis/resolve.rs | 146 +++ crates/oa-core/src/analysis/tests.rs | 1142 +++++++++++++++++ crates/oa-core/src/analysis/typecheck.rs | 277 ++++ crates/oa-core/src/analysis/types.rs | 226 ++++ .../oa-core/src/analysis_numeric/quantity.rs | 203 +-- crates/oa-core/src/language.rs | 16 +- crates/oa-core/src/lib.rs | 1 + crates/oa-core/src/migration_v4.rs | 1 + crates/oa-core/src/migration_v7.rs | 62 + crates/oa-core/src/model.rs | 46 +- crates/oa-core/src/persistence.rs | 28 +- crates/oa-core/src/references.rs | 19 +- crates/oa-core/src/semantic/catalog.rs | 3 + crates/oa-core/src/session.rs | 67 +- crates/oa-core/src/transitions.rs | 10 + crates/oa-core/src/validation.rs | 12 + crates/oa-core/tests/source7_contract.rs | 2 +- ...ease-analysis-core-schema8-wip-20260911.md | 58 + 21 files changed, 3329 insertions(+), 113 deletions(-) create mode 100644 crates/oa-core/src/analysis.rs create mode 100644 crates/oa-core/src/analysis/admission.rs create mode 100644 crates/oa-core/src/analysis/references.rs create mode 100644 crates/oa-core/src/analysis/resolve.rs create mode 100644 crates/oa-core/src/analysis/tests.rs create mode 100644 crates/oa-core/src/analysis/typecheck.rs create mode 100644 crates/oa-core/src/analysis/types.rs create mode 100644 crates/oa-core/src/migration_v7.rs create mode 100644 harness/release-analysis-core-schema8-wip-20260911.md diff --git a/crates/oa-core/src/analysis.rs b/crates/oa-core/src/analysis.rs new file mode 100644 index 00000000..4252d0dd --- /dev/null +++ b/crates/oa-core/src/analysis.rs @@ -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; diff --git a/crates/oa-core/src/analysis/admission.rs b/crates/oa-core/src/analysis/admission.rs new file mode 100644 index 00000000..768a478a --- /dev/null +++ b/crates/oa-core/src/analysis/admission.rs @@ -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, 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, 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, + calls: BTreeSet, +} +impl Info { + fn read( + owner: Id, + field: &str, + binding: &Binding, + budget: &mut Budget, + ) -> Result { + 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, + properties: BTreeMap, + checks: BTreeMap, + cases: BTreeMap>, +} +impl Index { + fn build( + environment: &ResolvedAnalysis<'_>, + budget: &mut Budget, + ) -> Result { + 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 { + 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>, + call_graph: bool, + budget: &mut Budget, +) -> Result<(), AnalysisError> { + let mut degrees = BTreeMap::new(); + let mut users: BTreeMap> = 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, + 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); + } +} diff --git a/crates/oa-core/src/analysis/references.rs b/crates/oa-core/src/analysis/references.rs new file mode 100644 index 00000000..0f7836fe --- /dev/null +++ b/crates/oa-core/src/analysis/references.rs @@ -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 { + 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 { + 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 { + 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); + } + _ => {} + } + } +} diff --git a/crates/oa-core/src/analysis/resolve.rs b/crates/oa-core/src/analysis/resolve.rs new file mode 100644 index 00000000..5e20165c --- /dev/null +++ b/crates/oa-core/src/analysis/resolve.rs @@ -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), +} +#[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) -> 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>, + pub(super) units: BTreeMap>, + 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> { + self.kinds.get(&reference.local()) + } + pub fn unit(&self, reference: DefinitionRef) -> Option<&Unit> { + 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 { + 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, 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, + }) + } +} diff --git a/crates/oa-core/src/analysis/tests.rs b/crates/oa-core/src/analysis/tests.rs new file mode 100644 index 00000000..ec65631f --- /dev/null +++ b/crates/oa-core/src/analysis/tests.rs @@ -0,0 +1,1142 @@ +use super::*; +use crate::analysis_numeric::{ + Dimension, Exact, ExactWire, NumericError, NumericType, NumericValue, ResolvedKind, WorkLimits, + WorkMeter, +}; +use crate::*; +use std::collections::BTreeMap; + +fn id(value: &str) -> Id { + Id::parse_str(value).unwrap() +} +fn reference(value: Id) -> DefinitionRef { + DefinitionRef::Local(value) +} +fn number(value: i64) -> Expr { + Expr::Number(Exact::from_integer(value)) +} +fn expression(value: Expr) -> Binding { + Binding::Expression(value) +} +fn quantity(value: i64, unit: Id) -> Expr { + Expr::Quantity { + magnitude: Exact::from_integer(value), + unit: reference(unit), + } +} +fn scalar_number() -> AnalysisType { + AnalysisType::Scalar(ScalarType::Number) +} +fn duration_type(kind: Id) -> AnalysisType { + AnalysisType::Scalar(ScalarType::Quantity(reference(kind))) +} +fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr { + Expr::Binary { + op, + left: Box::new(left), + right: Box::new(right), + } +} +fn exact(numerator: &str, denominator: &str) -> Exact { + Exact::from_wire( + &ExactWire { + numerator: numerator.into(), + denominator: denominator.into(), + }, + &mut WorkMeter::new(WorkLimits::REFERENCE), + ) + .unwrap() +} +fn definition(id: Id, alias: &str, body: DefinitionBody) -> Definition { + Definition { + id, + alias: alias.into(), + name: alias.into(), + description: String::new(), + body, + } +} +fn shared(id: Id, alias: &str, body: SharedBody) -> Element { + Element { + id, + alias: alias.into(), + name: alias.into(), + description: String::new(), + parent: None, + body: ElementBody::shared_in(Layer::Oa, body), + } +} +fn activity(id: Id, alias: &str, parent: Option) -> Element { + Element { + id, + alias: alias.into(), + name: alias.into(), + description: String::new(), + parent, + body: ElementBody::Oa(OaBody::Activity), + } +} + +/// The independently specified complete 17-UUID response-budget fixture. +/// This uses the real Project; no alternate model, source parser, or evaluator. +struct Fixture { + project: Project, + duration: Id, + second: Id, + millisecond: Id, + combine: Id, + a: ParameterId, + b: ParameterId, + respond: Id, + receive: Id, + dispatch: Id, + receive_time: Id, + dispatch_time: Id, + total: Id, + limit: Id, + check: Id, + degraded: Id, + unmeasured: Id, +} +fn fixture() -> Fixture { + 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"); + let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb")); + let b = ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976")); + 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 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: reference(duration), + scale: Exact::from_integer(1), + offset: Exact::from_integer(0), + symbol: "s".into(), + }), + ), + definition( + millisecond, + "millisecond", + DefinitionBody::Unit(UnitDefinition { + kind: reference(duration), + scale: exact("1", "1000"), + 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(duration), + }, + Parameter { + id: b, + name: "b".into(), + value_type: duration_type(duration), + }, + ], + result: duration_type(duration), + body: expression(binary( + BinaryOp::Add, + Expr::Parameter(a), + Expr::Parameter(b), + )), + }), + ), + ] + .into_iter() + .map(|definition| (definition.id, definition)) + .collect(); + let property = |id, alias: &str, subject, display, base| { + shared( + id, + alias, + SharedBody::EngineeringProperty { + subject: AnalysisSubject::Element(subject), + value_type: duration_type(duration), + display_unit: Some(reference(display)), + base: expression(base), + }, + ) + }; + let case = |id, alias: &str, binding| { + shared( + id, + alias, + SharedBody::AnalysisCase { + subject: None, + base: BaselineBase::ModelBaseline, + overrides: BTreeMap::from([(receive_time, binding)]), + }, + ) + }; + let elements = [ + activity(respond, "respond", None), + activity(receive, "receive", Some(respond)), + activity(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: reference(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(); + let project = 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(), + }; + Fixture { + project, + duration, + second, + millisecond, + combine, + a, + b, + respond, + receive, + dispatch, + receive_time, + dispatch_time, + total, + limit, + check, + degraded, + unmeasured, + } +} +fn reject(project: &Project) -> AnalysisError { + match admit(project, AdmissionLimits::REFERENCE) { + Ok(_) => panic!("Expected analysis rejection"), + Err(error) => error, + } +} +fn base_mut(project: &mut Project, id: Id) -> &mut Binding { + match project.elements.get_mut(&id).unwrap().shared_mut().unwrap() { + SharedBody::EngineeringProperty { base, .. } => base, + _ => panic!(), + } +} +fn calculation_mut(project: &mut Project, id: Id) -> &mut CalculationDefinition { + match &mut project.definitions.get_mut(&id).unwrap().body { + DefinitionBody::Calculation(value) => value, + _ => panic!(), + } +} +fn overrides_mut(project: &mut Project, id: Id) -> &mut BTreeMap { + match project.elements.get_mut(&id).unwrap().shared_mut().unwrap() { + SharedBody::AnalysisCase { overrides, .. } => overrides, + _ => panic!(), + } +} +fn add_calculation(project: &mut Project, n: u128, result: AnalysisType, body: Expr) -> Id { + let id = Id::from_u128(n); + project.definitions.insert( + id, + definition( + id, + &format!("calc_{n}"), + DefinitionBody::Calculation(CalculationDefinition { + parameters: vec![], + result, + body: expression(body), + }), + ), + ); + id +} + +#[test] +fn complete_real_model_roundtrips_and_exposes_explicit_effective_bindings() { + let f = fixture(); + assert_eq!(f.project.identities().len(), 15); // Scoped parameters add the other two identities. + assert!(!f.project.contains(f.a.0)); + assert!(!f.project.contains(f.b.0)); + assert_eq!(f.project.layer_of(f.duration), None); + assert_eq!(f.project.elements[&f.dispatch].parent, Some(f.respond)); + assert!(matches!( + f.project.elements[&f.limit].shared(), + Some(SharedBody::EngineeringProperty { .. }) + )); + let environment = admit(&f.project, AdmissionLimits::REFERENCE).unwrap(); + assert_eq!(environment.admission_usage().stored_expression_nodes, 13); + assert_eq!( + environment.unit(reference(f.second)).unwrap().scale(), + &Exact::from_integer(1) + ); + assert_eq!( + environment.unit(reference(f.millisecond)).unwrap().scale(), + &exact("1", "1000") + ); + let baseline = environment + .effective_binding(f.receive_time, EvaluationContext::ModelBaseline) + .unwrap(); + assert_eq!(baseline.origin, BindingOrigin::ModelBaseline); + assert_eq!(baseline.binding, &expression(quantity(750, f.millisecond))); + let alternative = environment + .effective_binding(f.receive_time, EvaluationContext::Case(f.degraded)) + .unwrap(); + assert_eq!(alternative.origin, BindingOrigin::CaseOverride(f.degraded)); + assert_eq!( + alternative.binding, + &expression(quantity(1250, f.millisecond)) + ); + let missing = environment + .effective_binding(f.receive_time, EvaluationContext::Case(f.unmeasured)) + .unwrap(); + assert_eq!(missing.binding, &Binding::Unbound); + assert_eq!(missing.origin, BindingOrigin::CaseOverride(f.unmeasured)); + // Inherited formula remains the model base; a future evaluator must retain + // Case(degraded) while resolving each Property read within that formula. + let formula = environment + .effective_binding(f.total, EvaluationContext::Case(f.degraded)) + .unwrap(); + assert_eq!(formula.origin, BindingOrigin::ModelBaseline); + assert!(matches!( + formula.binding, + Binding::Expression(Expr::Call { .. }) + )); + let encoded = serde_json::to_vec(&f.project).unwrap(); + let decoded = persistence::decode_with_report(&encoded).unwrap(); + assert_eq!(decoded.project, f.project); + assert!(decoded.migration.is_none()); +} + +#[test] +fn narrow_override_commands_preserve_missing_clear_and_history() { + let f = fixture(); + let mut session = Session::new(f.project.clone()); + session + .execute(Command::ClearAnalysisOverride { + case: f.unmeasured, + property: f.receive_time, + }) + .unwrap(); + assert_eq!(session.revision(), 1); + assert_eq!( + admit(session.project(), AdmissionLimits::REFERENCE) + .unwrap() + .effective_binding(f.receive_time, EvaluationContext::Case(f.unmeasured)) + .unwrap() + .origin, + BindingOrigin::ModelBaseline + ); + let cleared = session.project().clone(); + session + .execute(Command::ClearAnalysisOverride { + case: f.unmeasured, + property: f.receive_time, + }) + .unwrap(); + assert_eq!(session.revision(), 1); // Absent-key clear is an exact no-op. + session + .execute(Command::SetAnalysisOverride { + case: f.unmeasured, + property: f.receive_time, + binding: Binding::Unbound, + }) + .unwrap(); + assert_eq!(session.revision(), 2); + assert_eq!(session.project(), &f.project); + session.undo(); + assert_eq!(session.project(), &cleared); + session.redo(); + assert_eq!(session.project(), &f.project); + let before = session.project().clone(); + assert!( + session + .execute(Command::SetAnalysisBase { + property: f.receive_time, + binding: expression(number(1)) + }) + .is_err() + ); + assert_eq!(session.project(), &before); + assert_eq!(session.revision(), 4); +} + +#[test] +fn global_reidentify_rewrites_typed_domains_but_preserves_scoped_parameters() { + let f = fixture(); + let fresh_property = id("9591f6b0-4ea4-4b6b-9e28-53e128e92904"); + let fresh_kind = id("81b0741b-4e5b-4b1c-a6b5-cf289d1322e1"); + let prepared = prepare_command( + &f.project, + &Command::Batch(vec![ + Command::Reidentify(f.receive_time, fresh_property), + Command::Reidentify(f.duration, fresh_kind), + ]), + ) + .unwrap(); + assert_eq!(prepared.identity_map[&f.receive_time], fresh_property); + assert_eq!(prepared.identity_map[&f.duration], fresh_kind); + assert!(!prepared.project.contains(f.duration)); + assert!(!prepared.project.contains(f.receive_time)); + let environment = admit(&prepared.project, AdmissionLimits::REFERENCE).unwrap(); + let calculation = environment.calculation(reference(f.combine)).unwrap(); + assert_eq!(calculation.parameters[0].id, f.a); + assert_eq!(calculation.parameters[1].id, f.b); + assert_eq!( + calculation.parameters[0].value_type, + duration_type(fresh_kind) + ); + let Some(SharedBody::AnalysisCase { overrides, .. }) = + prepared.project.elements[&f.degraded].shared() + else { + panic!() + }; + assert!(overrides.contains_key(&fresh_property)); + assert!(!overrides.contains_key(&f.receive_time)); + let Binding::Expression(Expr::Call { arguments, .. }) = environment + .effective_binding(f.total, EvaluationContext::ModelBaseline) + .unwrap() + .binding + else { + panic!() + }; + assert_eq!(arguments[&f.a], Expr::Property(fresh_property)); + // A global identity may equal a parameter's scoped UUID without becoming it. + let mut coincident = f.project.clone(); + let scoped_alias = definition( + f.a.0, + "scoped_collision_is_local", + DefinitionBody::QuantityKind(QuantityKindDefinition { + dimension: Dimension::ZERO, + algebra: QuantityAlgebra::Linear, + }), + ); + coincident.definitions.insert(scoped_alias.id, scoped_alias); + let moved = execute_batch( + &coincident, + &[Command::Reidentify(f.a.0, Id::from_u128(9042))], + ) + .unwrap(); + assert_eq!( + admit(&moved, AdmissionLimits::REFERENCE) + .unwrap() + .calculation(reference(f.combine)) + .unwrap() + .parameters[0] + .id, + f.a + ); +} + +#[test] +fn map_key_collision_is_rejected_before_any_reference_mutation() { + let mut f = fixture(); + overrides_mut(&mut f.project, f.degraded) + .insert(f.dispatch_time, expression(quantity(1, f.second))); + let before = f.project.clone(); + assert!( + crate::references::remap_references(&mut f.project, f.receive_time, f.dispatch_time) + .is_err() + ); + assert_eq!(f.project, before); +} + +#[test] +fn deletion_requires_explicit_repairs_in_the_same_atomic_batch() { + let f = fixture(); + assert!(execute_batch(&f.project, &[Command::Delete(f.millisecond)]).is_err()); + assert!(execute_batch(&f.project, &[Command::Delete(f.receive_time)]).is_err()); + assert!(execute_batch(&f.project, &[Command::Delete(f.receive)]).is_err()); + let fixed = execute_batch( + &f.project, + &[ + Command::SetAnalysisBase { + property: f.total, + binding: expression(Expr::Property(f.dispatch_time)), + }, + Command::ClearAnalysisOverride { + case: f.degraded, + property: f.receive_time, + }, + Command::ClearAnalysisOverride { + case: f.unmeasured, + property: f.receive_time, + }, + Command::Delete(f.receive_time), + ], + ) + .unwrap(); + assert!(!fixed.contains(f.receive_time)); + assert!(fixed.contains(f.receive)); + admit(&fixed, AdmissionLimits::REFERENCE).unwrap(); +} + +#[test] +fn every_case_is_checked_and_baseline_edits_cannot_introduce_a_case_only_cycle() { + let mut f = fixture(); + overrides_mut(&mut f.project, f.degraded).remove(&f.receive_time); + overrides_mut(&mut f.project, f.degraded) + .insert(f.dispatch_time, expression(Expr::Property(f.receive_time))); + admit(&f.project, AdmissionLimits::REFERENCE).unwrap(); + let mut session = Session::new(f.project.clone()); + assert!( + session + .execute(Command::SetAnalysisBase { + property: f.receive_time, + binding: expression(Expr::Property(f.dispatch_time)) + }) + .is_err() + ); + assert_eq!(session.project(), &f.project); + assert_eq!(session.revision(), 0); + overrides_mut(&mut f.project, f.degraded) + .insert(f.receive_time, expression(Expr::Property(f.total))); + assert_eq!(reject(&f.project).code, "analysis_cycle"); +} + +#[test] +fn unused_branches_still_reject_wrong_types_references_and_cycles() { + let f = fixture(); + for unused in [ + number(0), + Expr::Property(f.respond), + Expr::Property(f.receive_time), + ] { + let mut candidate = f.project.clone(); + *base_mut(&mut candidate, f.receive_time) = expression(Expr::If { + condition: Box::new(Expr::Boolean(true)), + when_true: Box::new(quantity(750, f.millisecond)), + when_false: Box::new(unused), + }); + assert!(admit(&candidate, AdmissionLimits::REFERENCE).is_err()); + } +} + +#[test] +fn calculations_are_pure_parameters_are_scoped_and_calls_bind_exactly() { + let f = fixture(); + let mut captured = f.project.clone(); + calculation_mut(&mut captured, f.combine).body = expression(Expr::Property(f.receive_time)); + assert_eq!(reject(&captured).code, "analysis_purity"); + let mut duplicate = f.project.clone(); + calculation_mut(&mut duplicate, f.combine).parameters[1].id = f.a; + assert_eq!(reject(&duplicate).code, "analysis_parameter"); + let mut absent = f.project.clone(); + *base_mut(&mut absent, f.total) = expression(Expr::Call { + definition: reference(f.combine), + arguments: BTreeMap::from([(f.a, Expr::Property(f.receive_time))]), + }); + assert_eq!(reject(&absent).code, "analysis_arguments"); + let mut escaped = f.project.clone(); + *base_mut(&mut escaped, f.total) = expression(Expr::Parameter(f.a)); + assert_eq!(reject(&escaped).code, "analysis_parameter"); + let mut renamed = f.project.clone(); + let mut revised = renamed.definitions[&f.combine].clone(); + let DefinitionBody::Calculation(calculation) = &mut revised.body else { + panic!() + }; + calculation.parameters[0].name = "first_duration".into(); + renamed = execute_batch(&renamed, &[Command::EditDefinition(f.combine, revised)]).unwrap(); + admit(&renamed, AdmissionLimits::REFERENCE).unwrap(); +} + +#[test] +fn layers_subject_kinds_and_display_units_are_strict() { + let f = fixture(); + let mut different_layer = f.project.clone(); + let element = different_layer.elements.get_mut(&f.degraded).unwrap(); + element.body = ElementBody::shared_in(Layer::Sa, element.shared().unwrap().clone()); + assert_eq!(reject(&different_layer).code, "analysis_layer"); + let mut wrong_subject = f.project.clone(); + let Some(SharedBody::EngineeringProperty { subject, .. }) = wrong_subject + .elements + .get_mut(&f.receive_time) + .unwrap() + .shared_mut() + else { + panic!() + }; + *subject = AnalysisSubject::Element(f.duration); + assert_eq!(reject(&wrong_subject).code, "analysis_subject"); + let mut wrong_display = f.project.clone(); + let Some(SharedBody::EngineeringProperty { display_unit, .. }) = wrong_display + .elements + .get_mut(&f.receive_time) + .unwrap() + .shared_mut() + else { + panic!() + }; + *display_unit = Some(reference(f.duration)); + assert_eq!(reject(&wrong_display).code, "analysis_unit"); + assert!(ElementBody::new(Kind::EngineeringProperty, Layer::Oa).is_none()); + assert!( + admit(&f.project, AdmissionLimits::REFERENCE) + .unwrap() + .effective_binding(f.total, EvaluationContext::Case(f.check)) + .is_err() + ); +} + +#[test] +fn equal_dimensions_do_not_create_nominal_compatibility() { + let mut f = fixture(); + let other_kind = Id::from_u128(4001); + let other_unit = Id::from_u128(4002); + f.project.definitions.insert( + other_kind, + definition( + other_kind, + "other_duration", + DefinitionBody::QuantityKind(QuantityKindDefinition { + dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(), + algebra: QuantityAlgebra::Linear, + }), + ), + ); + f.project.definitions.insert( + other_unit, + definition( + other_unit, + "other_second", + DefinitionBody::Unit(UnitDefinition { + kind: reference(other_kind), + scale: Exact::from_integer(1), + offset: Exact::from_integer(0), + symbol: "s".into(), + }), + ), + ); + *base_mut(&mut f.project, f.receive_time) = expression(quantity(1, other_unit)); + assert_eq!(reject(&f.project).code, "analysis_type"); +} + +#[test] +fn static_types_do_not_evaluate_domain_conditions_or_check_truth() { + let mut f = fixture(); + add_calculation( + &mut f.project, + 5001, + scalar_number(), + binary(BinaryOp::Divide, number(0), number(0)), + ); + let Some(SharedBody::AnalysisCheck { predicate, .. }) = + f.project.elements.get_mut(&f.check).unwrap().shared_mut() + else { + panic!() + }; + *predicate = expression(Expr::Boolean(false)); + admit(&f.project, AdmissionLimits::REFERENCE).unwrap(); + let zero = NumericValue::::number(Exact::from_integer(0)); + assert_eq!( + NumericType::::Number + .multiplicative_result(&NumericType::Number, true) + .unwrap(), + NumericType::Number + ); + assert_eq!( + zero.divide(&zero, &mut WorkMeter::new(WorkLimits::REFERENCE)), + Err(NumericError::DivisionByZero) + ); + assert_eq!( + NumericType::::Number.power_result(0).unwrap(), + NumericType::Number + ); + assert_eq!( + zero.power(0, &mut WorkMeter::new(WorkLimits::REFERENCE)), + Err(NumericError::ZeroPowerZero) + ); +} + +#[test] +fn anonymous_dimension_algebra_and_affine_rules_share_pure_runtime_helpers() { + let time = Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(); + let interval = ResolvedKind::linear(1u8, time); + let point = ResolvedKind::affine(2u8, &interval).unwrap(); + let named = NumericType::Named(interval.clone()); + let affine = NumericType::Named(point.clone()); + let anonymous = NumericType::Anonymous(time); + assert_eq!( + named.multiplicative_result(&named, true).unwrap(), + NumericType::Number + ); + assert_eq!( + anonymous + .multiplicative_result(&NumericType::Number, false) + .unwrap(), + anonymous + ); + assert!(named.additive_result(&anonymous, false).is_err()); + assert_eq!(affine.additive_result(&affine, true).unwrap(), named); + assert_eq!(affine.additive_result(&named, false).unwrap(), affine); + assert!(affine.additive_result(&affine, false).is_err()); + assert!( + affine + .multiplicative_result(&NumericType::Number, false) + .is_err() + ); + assert!(named.additive_result(&affine, true).is_err()); + assert!(affine.unary_result().is_err()); + assert!(named.assertion_result(&interval).is_err()); + assert_eq!(anonymous.assertion_result(&interval).unwrap(), named); + for (left, right, subtract, expected) in [ + (affine.clone(), affine.clone(), true, named.clone()), + (affine.clone(), named.clone(), false, affine.clone()), + (named.clone(), affine.clone(), false, affine.clone()), + (named.clone(), named.clone(), false, named.clone()), + ] { + assert_eq!(left.additive_result(&right, subtract).unwrap(), expected); + let left = NumericValue::new(left, Exact::from_integer(8)); + let right = NumericValue::new(right, Exact::from_integer(3)); + let mut meter = WorkMeter::new(WorkLimits::REFERENCE); + let result = if subtract { + left.subtract(&right, &mut meter) + } else { + left.add(&right, &mut meter) + } + .unwrap(); + assert_eq!(result.value_type(), &expected); + assert_eq!( + result.coordinate(), + &Exact::from_integer(if subtract { 5 } else { 11 }) + ); + } +} + +#[test] +fn affine_definitions_require_linear_matching_difference_and_valid_units() { + let mut f = fixture(); + let point = Id::from_u128(5200); + f.project.definitions.insert( + point, + definition( + point, + "time_point", + DefinitionBody::QuantityKind(QuantityKindDefinition { + dimension: Dimension::ZERO, + algebra: QuantityAlgebra::Affine { + difference: reference(f.duration), + }, + }), + ), + ); + assert_eq!(reject(&f.project).code, "analysis_kind"); + let DefinitionBody::QuantityKind(kind) = + &mut f.project.definitions.get_mut(&point).unwrap().body + else { + panic!() + }; + kind.dimension = Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(); + admit(&f.project, AdmissionLimits::REFERENCE).unwrap(); + let DefinitionBody::Unit(unit) = &mut f.project.definitions.get_mut(&f.second).unwrap().body + else { + panic!() + }; + unit.offset = Exact::from_integer(1); + assert_eq!(reject(&f.project).code, "analysis_unit"); +} + +#[test] +fn empty_lists_require_context_and_lists_are_flat_bounded_homogeneous() { + let mut f = fixture(); + let good = add_calculation( + &mut f.project, + 6000, + AnalysisType::List(ScalarType::Number), + Expr::List(vec![]), + ); + add_calculation( + &mut f.project, + 6001, + scalar_number(), + Expr::Sum { + values: Box::new(Expr::List(vec![])), + initial: Box::new(number(0)), + }, + ); + admit(&f.project, AdmissionLimits::REFERENCE).unwrap(); + for items in [ + vec![number(0), Expr::Boolean(true)], + vec![Expr::List(vec![])], + vec![number(0); 1025], + ] { + calculation_mut(&mut f.project, good).body = expression(Expr::List(items)); + assert!(admit(&f.project, AdmissionLimits::REFERENCE).is_err()); + } + calculation_mut(&mut f.project, good).body = expression(Expr::List(vec![number(0); 1024])); + admit(&f.project, AdmissionLimits::REFERENCE).unwrap(); +} + +#[test] +fn logical_depth_and_call_depth_have_exact_boundaries() { + let mut f = fixture(); + let mut nested = number(1); + for _ in 1..128 { + nested = Expr::Unary { + op: UnaryOp::Negate, + operand: Box::new(nested), + }; + } + let deep = add_calculation(&mut f.project, 7000, scalar_number(), nested); + admit(&f.project, AdmissionLimits::REFERENCE).unwrap(); + let old = std::mem::replace( + &mut calculation_mut(&mut f.project, deep).body, + Binding::Unbound, + ); + let Binding::Expression(old) = old else { + panic!() + }; + calculation_mut(&mut f.project, deep).body = expression(Expr::Unary { + op: UnaryOp::Negate, + operand: Box::new(old), + }); + assert_eq!(reject(&f.project).code, "analysis_depth"); + // Only 129 nodes here: no hostile unbounded recursive teardown probe. + f.project.definitions.remove(&deep); + let mut previous = add_calculation(&mut f.project, 7100, scalar_number(), number(1)); + for n in 7101..7132 { + previous = add_calculation( + &mut f.project, + n, + scalar_number(), + Expr::Call { + definition: reference(previous), + arguments: BTreeMap::new(), + }, + ); + } + admit(&f.project, AdmissionLimits::REFERENCE).unwrap(); + add_calculation( + &mut f.project, + 7132, + scalar_number(), + Expr::Call { + definition: reference(previous), + arguments: BTreeMap::new(), + }, + ); + assert_eq!(reject(&f.project).code, "analysis_call_depth"); +} + +fn boolean_tree(depth: usize) -> Expr { + if depth == 0 { + Expr::Boolean(true) + } else { + binary( + BinaryOp::Or, + boolean_tree(depth - 1), + boolean_tree(depth - 1), + ) + } +} +#[test] +fn stored_closure_limit_aggregates_distinct_called_definitions() { + let mut f = fixture(); + let boolean = AnalysisType::Scalar(ScalarType::Boolean); + let first = add_calculation(&mut f.project, 7200, boolean.clone(), boolean_tree(14)); // 32,767 stored nodes. + let second = add_calculation(&mut f.project, 7201, boolean, boolean_tree(14)); + admit(&f.project, AdmissionLimits::REFERENCE).unwrap(); // Independent unused definitions each fit. + let Some(SharedBody::AnalysisCheck { predicate, .. }) = + f.project.elements.get_mut(&f.check).unwrap().shared_mut() + else { + panic!() + }; + *predicate = expression(binary( + BinaryOp::And, + Expr::Call { + definition: reference(first), + arguments: BTreeMap::new(), + }, + Expr::Call { + definition: reference(second), + arguments: BTreeMap::new(), + }, + )); + assert_eq!(reject(&f.project).code, "analysis_nodes"); +} + +#[test] +fn static_work_is_lowerable_deterministic_and_aggregated_across_cases() { + let f = fixture(); + let used = admit(&f.project, AdmissionLimits::REFERENCE) + .unwrap() + .admission_usage() + .static_events; + let exact_limit = AdmissionLimits { + max_static_events: used, + }; + let admitted = admit(&f.project, exact_limit).unwrap(); + assert_eq!(admitted.admission_limits(), exact_limit); + assert_eq!(admitted.admission_usage().static_events, used); + assert!( + admit( + &f.project, + AdmissionLimits { + max_static_events: used - 1 + } + ) + .is_err() + ); + assert!( + admit( + &f.project, + AdmissionLimits { + max_static_events: 0 + } + ) + .is_err() + ); + assert!( + admit( + &f.project, + AdmissionLimits { + max_static_events: 1_000_001 + } + ) + .is_err() + ); + let mut more = f.project.clone(); + for n in 8000..8010 { + more.elements.insert( + Id::from_u128(n), + shared( + Id::from_u128(n), + &format!("case_{n}"), + SharedBody::AnalysisCase { + subject: None, + base: BaselineBase::ModelBaseline, + overrides: BTreeMap::new(), + }, + ), + ); + } + assert!(admit(&more, exact_limit).is_err()); + let first = admit(&more, AdmissionLimits::REFERENCE) + .unwrap() + .admission_usage(); + let second = admit(&more, AdmissionLimits::REFERENCE) + .unwrap() + .admission_usage(); + assert_eq!(first, second); + assert!(first.static_events > used); +} + +#[test] +fn existing_transport_record_limits_count_definitions_parameters_overrides_and_nodes() { + let f = fixture(); + // 14 graph objects, project header, 2 parameters, 2 override rows, 13 Expr nodes. + let limits = language::ParseLimits { + max_source_bytes: usize::MAX, + max_graph_objects: 14, + max_records: 32, + max_step_references: usize::MAX, + }; + limits.check_model(&f.project).unwrap(); + assert!( + language::ParseLimits { + max_records: 31, + ..limits + } + .check_model(&f.project) + .is_err() + ); + assert!( + language::ParseLimits { + max_graph_objects: 13, + ..limits + } + .check_model(&f.project) + .is_err() + ); +} + +#[test] +fn wire_contract_rejects_unknown_imports_duplicates_missing_fields_and_noncanonical_exact() { + let f = fixture(); + assert!(serde_json::from_str::(r#"{"Imported":{"package":"test"}}"#).is_err()); + let argument = serde_json::to_string(&quantity(1, f.second)).unwrap(); + let duplicate = format!( + r#"{{"Call":{{"definition":{{"Local":"{}"}},"arguments":{{"{}":{},"{}":{}}}}}}}"#, + f.combine, f.a.0, argument, f.a.0, argument + ); + assert!(serde_json::from_str::(&duplicate).is_err()); + let case = format!( + r#"{{"AnalysisCase":{{"subject":null,"base":"ModelBaseline","overrides":{{"{}":"Unbound","{}":"Unbound"}}}}}}"#, + f.receive_time, f.receive_time + ); + assert!(serde_json::from_str::(&case).is_err()); + for wire in [ + r#"{"Number":{"numerator":"02","denominator":"1"}}"#, + r#"{"Number":{"numerator":"2","denominator":"2"}}"#, + r#"{"Number":{"numerator":"1","denominator":"-1"}}"#, + r#"{"Number":{"numerator":"-0","denominator":"1"}}"#, + ] { + assert!(serde_json::from_str::(wire).is_err()); + } + let literal = number(-1); + let unary = Expr::Unary { + op: UnaryOp::Negate, + operand: Box::new(number(1)), + }; + assert_ne!(literal, unary); + for node in [literal, unary] { + assert_eq!( + serde_json::from_slice::(&serde_json::to_vec(&node).unwrap()).unwrap(), + node + ); + } + let mut missing = serde_json::to_value(&f.project).unwrap(); + missing.as_object_mut().unwrap().remove("definitions"); + assert!(persistence::decode(&serde_json::to_vec(&missing).unwrap()).is_err()); + let mut malformed = serde_json::to_value(&f.project).unwrap(); + malformed["elements"][f.receive_time.to_string()]["body"]["Oa"]["Shared"]["EngineeringProperty"].as_object_mut().unwrap().remove("base"); + assert!(persistence::decode(&serde_json::to_vec(&malformed).unwrap()).is_err()); +} + +#[test] +fn frozen_schema7_upgrades_without_interpreting_descriptive_metadata() { + let mut old = Project::empty(); + old.id = Id::from_u128(9000); + old.alias = "old_metadata".into(); + let data = Id::from_u128(9001); + let constraint = Id::from_u128(9002); + old.elements.insert( + data, + shared( + data, + "old_duration", + SharedBody::DataType { + definition: DataTypeDefinition::Real { + minimum: None, + maximum: None, + }, + default: None, + unit: "ms, manually documented".into(), + }, + ), + ); + old.elements.insert( + constraint, + shared( + constraint, + "old_rule", + SharedBody::Constraint { + expression: "duration < 2 s; prose remains prose".into(), + constrained: vec![data], + }, + ), + ); + let expected = old.clone(); + for version in [5, 6, 7] { + let mut value = serde_json::to_value(&old).unwrap(); + value["schema_version"] = version.into(); + value.as_object_mut().unwrap().remove("definitions"); + let decoded = + persistence::decode_with_report(&serde_json::to_vec(&value).unwrap()).unwrap(); + assert_eq!(decoded.project, expected); + assert_eq!(decoded.migration.unwrap().from_schema, version); + assert!(decoded.project.definitions.is_empty()); + } + let f = fixture(); + let mut value = serde_json::to_value(&f.project).unwrap(); + value["schema_version"] = 7.into(); + value.as_object_mut().unwrap().remove("definitions"); + assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err()); + let mut empty_catalog = serde_json::to_value(&old).unwrap(); + empty_catalog["schema_version"] = 7.into(); + assert!(persistence::decode(&serde_json::to_vec(&empty_catalog).unwrap()).is_err()); +} + +#[test] +fn unsupported_transition_and_header_only_review_fail_explicitly() { + let f = fixture(); + let request = transitions::TransitionRequest::new( + transitions::TransitionDirection::OaToSa, + vec![f.receive_time], + ); + assert!(transitions::plan_transition(&f.project, &request).is_err()); + let trace = Trace { + id: Id::from_u128(9200), + alias: "analysis_trace".into(), + name: "analysis trace".into(), + description: String::new(), + oa: f.receive_time, + sa: f.dispatch_time, + kind: TraceKind::Manual, + baseline: None, + }; + assert!(crate::semantic::semantic_facets(&f.project, &trace, &BTreeMap::new()).is_err()); +} diff --git a/crates/oa-core/src/analysis/typecheck.rs b/crates/oa-core/src/analysis/typecheck.rs new file mode 100644 index 00000000..28351bf7 --- /dev/null +++ b/crates/oa-core/src/analysis/typecheck.rs @@ -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, + parameters: &BTreeMap, + 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, + parameters: &'a BTreeMap, + budget: &'b mut Budget, +} +impl Checker<'_, '_, '_> { + fn error(&self, code: &str, message: impl Into) -> AnalysisError { + AnalysisError::new(self.owner, self.field, code, message) + } + fn numeric(&self, value: ResolvedType) -> Result, 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, NumericError>, + ) -> Result { + 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 { + 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) + } +} diff --git a/crates/oa-core/src/analysis/types.rs b/crates/oa-core/src/analysis/types.rs new file mode 100644 index 00000000..a84d1516 --- /dev/null +++ b/crates/oa-core/src/analysis/types.rs @@ -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, + 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), + Unary { + op: UnaryOp, + operand: Box, + }, + Binary { + op: BinaryOp, + left: Box, + right: Box, + }, + If { + condition: Box, + when_true: Box, + when_false: Box, + }, + Call { + definition: DefinitionRef, + #[serde(deserialize_with = "unique_parameter_map")] + arguments: BTreeMap, + }, + Sum { + values: Box, + initial: Box, + }, + Power { + base: Box, + exponent: i8, + }, + AsKind { + kind: DefinitionRef, + value: Box, + }, +} + +fn unique_parameter_map<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, 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, + code: &str, + message: impl Into, + ) -> Self { + Self { + object, + field: field.into(), + code: code.into(), + message: message.into(), + } + } +} diff --git a/crates/oa-core/src/analysis_numeric/quantity.rs b/crates/oa-core/src/analysis_numeric/quantity.rs index 7213e5ba..69c60ed1 100644 --- a/crates/oa-core/src/analysis_numeric/quantity.rs +++ b/crates/oa-core/src/analysis_numeric/quantity.rs @@ -153,7 +153,7 @@ impl NumericType { 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 NumericType { } } - fn normalized(self) -> Self { + pub fn normalized(self) -> Self { match self { Self::Anonymous(dimension) => Self::anonymous(dimension), other => other, @@ -169,6 +169,93 @@ impl NumericType { } } +impl NumericType { + /// Pure R6 algebra; does not evaluate coordinates or domain conditions. + pub fn additive_result(&self, rhs: &Self, subtract: bool) -> Result { + 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 { + 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 { + if self.is_affine() { + Err(NumericError::AffineOperation) + } else { + Ok(self.clone().normalized()) + } + } + + pub fn power_result(&self, exponent: i8) -> Result { + 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) -> Result { + 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 NumericValue { pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result { 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 NumericValue { pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result { 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, 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 { 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 NumericValue { pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result { 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, 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 { 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 { 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 { 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 { 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 NumericValue { meter: &mut WorkMeter, ) -> Result { 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())) } } diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index 37d42e7f..46e7bf63 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -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::(), ) .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| { ( diff --git a/crates/oa-core/src/lib.rs b/crates/oa-core/src/lib.rs index 7934fd65..48c7afe0 100644 --- a/crates/oa-core/src/lib.rs +++ b/crates/oa-core/src/lib.rs @@ -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; diff --git a/crates/oa-core/src/migration_v4.rs b/crates/oa-core/src/migration_v4.rs index 6127f724..56be3297 100644 --- a/crates/oa-core/src/migration_v4.rs +++ b/crates/oa-core/src/migration_v4.rs @@ -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, diff --git a/crates/oa-core/src/migration_v7.rs b/crates/oa-core/src/migration_v7.rs new file mode 100644 index 00000000..90c44bdf --- /dev/null +++ b/crates/oa-core/src/migration_v7.rs @@ -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, + #[serde(deserialize_with = "crate::unique_id_map")] + pub relations: BTreeMap, + #[serde(deserialize_with = "crate::unique_id_map")] + pub diagrams: BTreeMap, + #[serde(deserialize_with = "crate::unique_id_map")] + pub artifact_links: BTreeMap, + #[serde(deserialize_with = "crate::unique_id_map")] + pub traces: BTreeMap, +} +impl Project7 { + pub(crate) fn into_current(self) -> Result { + 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, +) -> Result<(), FileError> { + for element in elements { + if element.kind().is_analysis() { + return Err(FileError::Invalid(format!( + "{:?} requires schema/source 8.", + element.kind() + ))); + } + } + Ok(()) +} diff --git a/crates/oa-core/src/model.rs b/crates/oa-core/src/model.rs index 041639a6..44fe23f8 100644 --- a/crates/oa-core/src/model.rs +++ b/crates/oa-core/src/model.rs @@ -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, }, + EngineeringProperty { + subject: crate::analysis::AnalysisSubject, + value_type: crate::analysis::AnalysisType, + display_unit: Option, + base: crate::analysis::Binding, + }, + AnalysisCase { + subject: Option, + base: crate::analysis::BaselineBase, + #[serde(deserialize_with = "unique_id_map")] + overrides: BTreeMap, + }, + AnalysisCheck { + subject: Option, + documented_constraint: Option, + 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 { - 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, + #[serde(deserialize_with = "unique_id_map")] pub elements: BTreeMap, #[serde(deserialize_with = "unique_id_map")] pub relations: BTreeMap, @@ -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; } diff --git a/crates/oa-core/src/persistence.rs b/crates/oa-core/src/persistence.rs index 39f496d7..a612037c 100644 --- a/crates/oa-core/src/persistence.rs +++ b/crates/oa-core/src/persistence.rs @@ -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::(bytes)?.into_current()?, limits)?, + Some(6) => migrate_v6(serde_json::from_slice::(bytes)?.into_current()?, limits)?, + Some(7) => migrate_v7(serde_json::from_slice::(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 { + 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, diff --git a/crates/oa-core/src/references.rs b/crates/oa-core/src/references.rs index 470337f6..df7a3e0c 100644 --- a/crates/oa-core/src/references.rs +++ b/crates/oa-core/src/references.rs @@ -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 { + 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 { 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 { @@ -405,6 +417,9 @@ fn remove_objects(p: &mut Project, removed: BTreeSet) { .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)); diff --git a/crates/oa-core/src/semantic/catalog.rs b/crates/oa-core/src/semantic/catalog.rs index 1e1f44e4..2b50ad36 100644 --- a/crates/oa-core/src/semantic/catalog.rs +++ b/crates/oa-core/src/semantic/catalog.rs @@ -604,6 +604,9 @@ fn catalog_facets( include_linked: bool, ) -> Result, 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| { diff --git a/crates/oa-core/src/session.rs b/crates/oa-core/src/session.rs index 0e740810..eef711ea 100644 --- a/crates/oa-core/src/session.rs +++ b/crates/oa-core/src/session.rs @@ -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> { diff --git a/crates/oa-core/src/transitions.rs b/crates/oa-core/src/transitions.rs index 2abc00c2..4594fe7a 100644 --- a/crates/oa-core/src/transitions.rs +++ b/crates/oa-core/src/transitions.rs @@ -449,6 +449,13 @@ pub fn plan_transition( request: &TransitionRequest, ) -> Result { 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))); } diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs index 3a121067..8b96c88c 100644 --- a/crates/oa-core/src/validation.rs +++ b/crates/oa-core/src/validation.rs @@ -180,6 +180,18 @@ fn check_ids(out: &mut Vec, owner: Id, field: &str, ids: &[Id]) { } pub fn validate(p: &Project) -> Vec { + // 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 { let mut out = Vec::new(); let coverage = coverage::Index::new(p); let topology_index = crate::topology_context::TopologyIndex::new(p); diff --git a/crates/oa-core/tests/source7_contract.rs b/crates/oa-core/tests/source7_contract.rs index d33a2ea7..62e5e647 100644 --- a/crates/oa-core/tests/source7_contract.rs +++ b/crates/oa-core/tests/source7_contract.rs @@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() { assert_eq!(session.project(), &after); let fresh = Id::new_v4(); let mut reidentified = after.clone(); - references::remap_references(&mut reidentified, f.r1, fresh); + references::remap_references(&mut reidentified, f.r1, fresh).unwrap(); let mut e = reidentified.elements.remove(&f.r1).unwrap(); e.id = fresh; reidentified.elements.insert(fresh, e); diff --git a/harness/release-analysis-core-schema8-wip-20260911.md b/harness/release-analysis-core-schema8-wip-20260911.md new file mode 100644 index 00000000..e846f3c4 --- /dev/null +++ b/harness/release-analysis-core-schema8-wip-20260911.md @@ -0,0 +1,58 @@ +# Schema8 core WIP reboot handoff + +Codex-956atf /root/requirements_delta, 2026-09-11. **Frozen immediately at the user's reboot checkpoint; uncompiled work, not a runnable product. Do not resume without root follow-up after reboot.** + +Worktree: `C:/Users/User/Documents/Git/ARCADIA-Studio/.local/analysis-core-worktree-20260911`, branch `codex/analysis-core-20260911`, base `7d14bd0dd1cf4a2293c00c185573fd4a7fc364ae`. All edits in this assignment are in that isolated worktree. Root retains commits, pushes, builds, integration and the original checkout. No manifests, lockfile, source8 grammar, evaluator, server/CLI/UI, generated schemas, ledgers or prior frozen reports were edited. + +## Coherent implementation present + +- Schema8 `Project.definitions` is required. Local quantity-kind, unit and pure calculation definitions share global UUID/alias/header identity; `ParameterId` is scoped to its calculation. `DefinitionRef::Local` is the only admitted reference variant. Imported packages are not simulated. +- Three explicit `SharedBody` records implement property-owned bases, subjectless or subject-bearing one-level cases, and shared checks. `Binding::{Unbound, Expression}` has no persisted Clear/default-case state. The closed expression/type vocabulary is in `analysis/types.rs`. +- `analysis::admit(&Project, AdmissionLimits)` returns the resolved kind/unit/calculation environment, accepted limits and usage. `effective_binding(property, ModelBaseline|Case(id))` returns the selected binding and its origin; it does not evaluate. Private bounded `KindHandle` contains project and definition UUIDs and is valid within one admitted environment. +- Static checking uses pure `NumericType` result helpers also used by `NumericValue`. It checks every stored branch, pure parameter scope, exact call arguments, declared/display types, same-layer subjects/property reads/case targets, affine kinds/units, flat homogeneous lists, acyclic calculation/effective-property dependencies, logical depth128, call depth32 and stored closure65,536. Checks may be Boolean false; supported runtime domain failures such as 0/0 do not become typing errors. +- Root ratified the provisional static ceiling1,000,000 events, explicitly lowerable and separate from runtime work. One meter spans the document and every effective case; it latches on exhaustion. Final ceiling acceptance still needs a measured gate. This meter is analysis work accounting, not a bound on all existing architectural validation or JSON allocation. +- Commands add Create/EditDefinition, SetAnalysisBase, SetAnalysisOverride and ClearAnalysisOverride. General element creation/editing remains the actual shared command path. Reidentification rewrites property/local-definition references and case keys, preserves scoped parameters, and rejects colliding keys before mutation. Deletion retains live analysis dependencies, so the same transaction must repair or remove them. Existing Session history/provenance is reused. +- Frozen schema5/6/7 JSON envelopes migrate with an empty catalog and reject new fields/kinds. Existing descriptive units/constraints remain strings. `language.rs` contains only old-source compatibility/refusal and record-count glue. Explicit guards refuse header-only semantic review or transition of analysis records. + +## Evidence and remaining risks + +**22 tests are prepared, zero were compiled or executed by this worker:**21 actual-Project tests and one static-meter test. The independently specified complete17-UUID response-budget fixture covers shallow JSON reopen, base/case/Unbound selection, commands/history, identity domains, atomic deletion repair, case-only cycles, unused branches, parameter purity, types/units, R6/runtime agreement, lists, logical/call/closure boundaries, aggregate static work, transport record counts, closed wire input, old-schema metadata preservation and unsupported review/transition refusal. The expected mixed-unit evaluation totals are not claimed; there is no evaluator in this lease. + +Standalone rustfmt parsed/formatted the new modules, migration_v7 and changed numeric quantity file. A read-only `git diff --check` before the stop request was clean. Neither is a compile/test gate. Root reported the earlier numeric foundation at7d14bd0 passed26 tests; that result does not cover this WIP or its modified numeric helper code. + +The default serde_json recursion guard is deliberately unchanged. Some programmatically admitted depth128 trees cannot reopen through the current decoder. Pretty serialization can exceed64MiB while semantic node limits still fit. Header `Value` allocation and per-Exact String/BigInt decoding precede aggregate static admission. Clone/Serialize/Debug/Drop and command cloning need the coherent bounded-codec/lifecycle gate. See the independent decoder review `harness/release-analysis-decoder-review-20260911.md` in the original checkout (Gitea#257). **Do not publish a runnable schema8 product before codec/output closure.** + +Source8 parsing/formatting/update/history parity is pending: the model/schema header is8 while the existing source parser still accepts only3..7. Existing full source regression tests therefore cannot be represented as passing. Old synthetic tests that serialize a current Project after merely changing its schema number may need to remove the new catalog field to represent a real frozen old envelope. Generic factory creation refuses analysis kinds because required semantics cannot be defaulted. + +Compiler/borrow/exhaustive-match checks remain outstanding. Static review was interrupted: the private typechecker binding helper has8 arguments and may trigger strict Clippy's argument-count lint; grouping its located binding context was the next cleanup under consideration, not applied. Review the static event precharge coverage of the call-argument arity/key scan before treating its weights as final. Existing architectural validators still have their own scaling costs. No claim of full decoder safety, source parity, frozen export, package compatibility execution, approximate mathematics or finished release capability is made. + +## Exact edited paths + +All paths below are relative to the isolated worktree, not the original checkout. This report is the only additional edited file beyond this20-file inventory. + +| Path | SHA256 at stop | +|---|---| +| `crates/oa-core/src/analysis.rs` | `7C197D80CD6C5A17345E9ABD20107CF9EE1887A77596144A8FAADB3253A9664B` | +| `crates/oa-core/src/analysis/admission.rs` | `60661ADEBFBDFA2A04A354A106D3DB3F81821EF4A50786A6CF611988666FDB85` | +| `crates/oa-core/src/analysis/references.rs` | `FE50EDC049B9900D91CADE3F58D15ACA4101211C39BB090317F548259D634529` | +| `crates/oa-core/src/analysis/resolve.rs` | `67AAA97DA8190DEDE71F0E2F98F1884A1DA38596B4B2DC6F8D61FF4377BDC80F` | +| `crates/oa-core/src/analysis/typecheck.rs` | `23F13E0EF971E794210FB3886178E1145661E4BC668A50B753F7BAAA9591035E` | +| `crates/oa-core/src/analysis/types.rs` | `CFD03E2B01D34AE6979403458D9861B7B1252383A2313CB56133AB64167F72B2` | +| `crates/oa-core/src/analysis/tests.rs` | `7137A47E994E629D7F349D023F32C10FD0563261678E59878A23E3BF6A101F05` | +| `crates/oa-core/src/analysis_numeric/quantity.rs` | `B9F23A9485F8BD913722023C35FB9D8917C9F160078655B3A5C52C8F7F12AB94` | +| `crates/oa-core/src/language.rs` | `B65098321ECF79E4DC1182A7FAB7BAFEEC0A238BB56153E81782F04FAB838558` | +| `crates/oa-core/src/lib.rs` | `C9BF3462A57A465B6CC7ECD7866E0D331B35F299E89D9C917E0A4587BB1DCD89` | +| `crates/oa-core/src/migration_v4.rs` | `BF4056DA11AC4F27627320742539CC2408ED42334106F3068C432FF70C62DB25` | +| `crates/oa-core/src/migration_v7.rs` | `474F4CEBDD343A7EECDF243149BBCBF08E12EAA05B8532BF2593705287017B30` | +| `crates/oa-core/src/model.rs` | `1C06BCC4AA7ABFEF89876C3A001171C742558D664612CF36152AC0AA52BC7BAF` | +| `crates/oa-core/src/persistence.rs` | `30B05FA464C49629080F3E20C6D3DA5226858A99B7C4C9554356847F8A5975EE` | +| `crates/oa-core/src/references.rs` | `63CD8655F67D919CC8F6B796F4CEC42D7B020AA37880E50F19190AAB8B0029A3` | +| `crates/oa-core/src/semantic/catalog.rs` | `47D74ACC4862139BA835DB3D6B769411E27860A99426A7F03EDEA26F44F7F7B3` | +| `crates/oa-core/src/session.rs` | `06115011BC86F0C95BBBE6D14BDAEDD7B83A66C0104DB186900B0F047B0F9C43` | +| `crates/oa-core/src/transitions.rs` | `127E429BCA4B2EA9A189ECE7E7B6DFD05A30B5FDEE98B2D10288A6F7C17B5931` | +| `crates/oa-core/src/validation.rs` | `E4C61F5ABB22AE3E595E16E93472E4364CDE4DB9064EC6DF826228C3D5962E42` | +| `crates/oa-core/tests/source7_contract.rs` | `0702D3BD0173DEF4F67770458E4AF2B23F213FD5938EE70E3AE0042897EF5F1E` | + +## Next authorized gate after reboot + +Root first preserves/pushes this WIP and confirms the exact checkout. After root explicitly resumes work, the proposed narrow root-owned command from this worktree is `cargo test --locked -p oa-core --lib analysis:: -- --test-threads=1`, using the established toolchain and root-selected target directory. Fix actual compiler/test findings, rerun modified numeric tests, then strict core Clippy; only then continue independent review and coherent bounded codec/source8/evaluator integration. This worker has not run that command and will remain stopped.