WIP checkpoint schema8 analysis core before user reboot

This commit is contained in:
Codex
2026-09-11 06:46:52 -07:00
parent 7d14bd0dd1
commit b96ff3935a
21 changed files with 3329 additions and 113 deletions
+821
View File
@@ -0,0 +1,821 @@
//! Bounded static admission for the actual Project catalog and analysis records.
use super::*;
use crate::analysis_numeric::{ResolvedKind, Unit};
use crate::{Id, Kind, Layer, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
/// Provisional ceiling; final acceptance requires the root-owned measured gate.
/// This is separate from runtime work limits and must be recorded by consumers.
pub const MAX_STATIC_EVENTS: u64 = 1_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdmissionLimits {
pub max_static_events: u64,
}
impl AdmissionLimits {
pub const REFERENCE: Self = Self {
max_static_events: MAX_STATIC_EVENTS,
};
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AdmissionUsage {
pub static_events: u64,
pub stored_expression_nodes: usize,
}
pub(super) struct Budget {
limit: u64,
pub(super) usage: AdmissionUsage,
exhausted: bool,
}
impl Budget {
pub(super) fn charge(
&mut self,
owner: Id,
field: &str,
events: u64,
) -> Result<(), AnalysisError> {
if self.exhausted || events > self.limit.saturating_sub(self.usage.static_events) {
self.exhausted = true;
return Err(AnalysisError::new(
owner,
field,
"analysis_limit",
"Static admission work limit exhausted.",
));
}
self.usage.static_events += events;
Ok(())
}
}
/// Admits a complete Project; this neither evaluates nor chooses a case.
/// Caller lowering is explicit. Passing a ceiling above the profile is an error.
pub fn admit(
project: &Project,
limits: AdmissionLimits,
) -> Result<ResolvedAnalysis<'_>, AnalysisError> {
let environment = admit_structure_checked(project, limits)?;
if let Some(issue) = crate::validation::validate_structure(project)
.into_iter()
.find(|issue| issue.severity == crate::validation::Severity::Error)
{
return Err(AnalysisError::new(
issue.element.unwrap_or(project.id),
issue.field.unwrap_or_default(),
&issue.code,
issue.message,
));
}
Ok(environment)
}
pub(crate) fn admit_structure_checked(
project: &Project,
limits: AdmissionLimits,
) -> Result<ResolvedAnalysis<'_>, AnalysisError> {
if limits.max_static_events > MAX_STATIC_EVENTS {
return Err(AnalysisError::new(
project.id,
"analysis_limits",
"analysis_limit",
"Static event limit exceeds the provisional reference profile.",
));
}
let mut budget = Budget {
limit: limits.max_static_events,
usage: AdmissionUsage::default(),
exhausted: false,
};
budget.charge(project.id, "analysis", 1)?;
let mut environment = ResolvedAnalysis {
project,
kinds: BTreeMap::new(),
units: BTreeMap::new(),
limits,
usage: AdmissionUsage::default(),
};
resolve_definitions(&mut environment, &mut budget)?;
let index = Index::build(&environment, &mut budget)?;
check_bindings(&environment, &mut budget)?;
check_graphs(&environment, &index, &mut budget)?;
environment.usage = budget.usage;
Ok(environment)
}
fn resolve_definitions(
environment: &mut ResolvedAnalysis<'_>,
budget: &mut Budget,
) -> Result<(), AnalysisError> {
let project = environment.project;
for (id, definition) in &project.definitions {
budget.charge(*id, "definition", 1)?;
if id != &definition.id {
return Err(AnalysisError::new(
*id,
"id",
"identity_mismatch",
"Definition map key must equal its identity.",
));
}
for (field, value) in [
("name", &definition.name),
("description", &definition.description),
] {
if !crate::validation::valid_text(value) {
return Err(AnalysisError::new(
*id,
field,
"invalid_text",
"Definition text contains a forbidden character.",
));
}
}
if let DefinitionBody::QuantityKind(kind) = &definition.body
&& kind.algebra == QuantityAlgebra::Linear
{
environment.kinds.insert(
*id,
ResolvedKind::linear(KindHandle::local(project.id, *id), kind.dimension),
);
}
}
for (id, definition) in &project.definitions {
budget.charge(*id, "definition", 1)?;
if let DefinitionBody::QuantityKind(QuantityKindDefinition {
dimension,
algebra: QuantityAlgebra::Affine { difference },
}) = &definition.body
{
let difference = environment.kind(*difference).ok_or_else(|| {
AnalysisError::new(
*id,
"difference",
"analysis_kind",
"Affine difference must reference a linear quantity kind.",
)
})?;
if difference.is_affine() || difference.dimension() != *dimension {
return Err(AnalysisError::new(
*id,
"difference",
"analysis_kind",
"Affine point and linear difference kinds must have identical dimensions.",
));
}
let resolved = ResolvedKind::affine(KindHandle::local(project.id, *id), difference)
.map_err(|error| {
AnalysisError::new(*id, "difference", "analysis_kind", error.to_string())
})?;
environment.kinds.insert(*id, resolved);
}
}
for (id, definition) in &project.definitions {
budget.charge(*id, "definition", 1)?;
if let DefinitionBody::Unit(unit) = &definition.body {
let kind = environment
.kind(unit.kind)
.ok_or_else(|| {
AnalysisError::new(
*id,
"kind",
"analysis_kind",
"Unit must reference a quantity kind.",
)
})?
.clone();
if !crate::validation::valid_text(&unit.symbol) {
return Err(AnalysisError::new(
*id,
"symbol",
"invalid_text",
"Unit symbol contains a forbidden character.",
));
}
let resolved =
Unit::new(kind, unit.scale.clone(), unit.offset.clone()).map_err(|error| {
AnalysisError::new(*id, "unit", "analysis_unit", error.to_string())
})?;
environment.units.insert(*id, resolved);
}
}
Ok(())
}
#[derive(Default)]
struct Info {
nodes: usize,
properties: BTreeSet<Id>,
calls: BTreeSet<Id>,
}
impl Info {
fn read(
owner: Id,
field: &str,
binding: &Binding,
budget: &mut Budget,
) -> Result<Self, AnalysisError> {
let mut info = Self::default();
let Binding::Expression(expression) = binding else {
return Ok(info);
};
let mut pending = vec![(expression, 1usize)];
while let Some((node, depth)) = pending.pop() {
budget.charge(owner, field, 1)?;
if depth > MAX_EXPRESSION_DEPTH {
return Err(AnalysisError::new(
owner,
field,
"analysis_depth",
"Expression exceeds logical depth 128.",
));
}
info.nodes += 1;
budget.usage.stored_expression_nodes += 1;
if info.nodes > MAX_CLOSURE_NODES {
return Err(AnalysisError::new(
owner,
field,
"analysis_nodes",
"Expression exceeds the stored closure node limit.",
));
}
let mut push = |child| pending.push((child, depth + 1));
match node {
Expr::Property(id) => {
info.properties.insert(*id);
}
Expr::List(items) => {
if items.len() > MAX_LIST_LENGTH {
return Err(AnalysisError::new(
owner,
field,
"analysis_list",
"List exceeds 1024 entries.",
));
}
for child in items.iter().rev() {
push(child);
}
}
Expr::Unary { operand, .. } => push(operand),
Expr::Binary { left, right, .. } => {
push(right);
push(left);
}
Expr::If {
condition,
when_true,
when_false,
} => {
push(when_false);
push(when_true);
push(condition);
}
Expr::Call {
definition,
arguments,
} => {
if arguments.len() > MAX_CLOSURE_NODES {
return Err(AnalysisError::new(
owner,
field,
"analysis_nodes",
"Call argument count exceeds the stored closure limit.",
));
}
info.calls.insert(definition.local());
for child in arguments.values().rev() {
push(child);
}
}
Expr::Sum { values, initial } => {
push(values);
push(initial);
}
Expr::Power { base, .. } => push(base),
Expr::AsKind { value, .. } => push(value),
_ => {}
}
if pending.len() > MAX_CLOSURE_NODES {
return Err(AnalysisError::new(
owner,
field,
"analysis_nodes",
"Pending expression nodes exceed the stored closure limit.",
));
}
}
Ok(info)
}
}
#[derive(Default)]
struct Index {
calculations: BTreeMap<Id, Info>,
properties: BTreeMap<Id, Info>,
checks: BTreeMap<Id, Info>,
cases: BTreeMap<Id, BTreeMap<Id, Info>>,
}
impl Index {
fn build(
environment: &ResolvedAnalysis<'_>,
budget: &mut Budget,
) -> Result<Self, AnalysisError> {
let mut index = Self::default();
for (id, definition) in &environment.project.definitions {
budget.charge(*id, "definition", 1)?;
if let DefinitionBody::Calculation(calculation) = &definition.body {
index
.calculations
.insert(*id, Info::read(*id, "body", &calculation.body, budget)?);
}
}
for (id, element) in &environment.project.elements {
budget.charge(*id, "element", 1)?;
if element.kind().is_analysis() && element.parent.is_some() {
return Err(AnalysisError::new(
*id,
"parent",
"analysis_parent",
"Analysis records are layer roots; subject is an explicit reference.",
));
}
match element.shared() {
Some(SharedBody::EngineeringProperty { base, .. }) => {
index
.properties
.insert(*id, Info::read(*id, "base", base, budget)?);
}
Some(SharedBody::AnalysisCheck { predicate, .. }) => {
index
.checks
.insert(*id, Info::read(*id, "predicate", predicate, budget)?);
}
Some(SharedBody::AnalysisCase { overrides, .. }) => {
let mut cases = BTreeMap::new();
for (property, binding) in overrides {
budget.charge(*id, "overrides", 1)?;
cases.insert(*property, Info::read(*id, "overrides", binding, budget)?);
}
index.cases.insert(*id, cases);
}
_ => {}
}
}
Ok(index)
}
}
fn subject(
environment: &ResolvedAnalysis<'_>,
owner: Id,
layer: Layer,
value: AnalysisSubject,
) -> Result<(), AnalysisError> {
let project = environment.project;
let valid = match value {
AnalysisSubject::Element(id) => project
.elements
.get(&id)
.is_some_and(|element| element.layer() == layer && !element.kind().is_analysis()),
AnalysisSubject::Relation(id) => {
project.relations.contains_key(&id) && project.layer_of(id) == Some(layer)
}
};
if valid {
Ok(())
} else {
Err(AnalysisError::new(
owner,
"subject",
"analysis_subject",
"Subject must be an existing architectural element or relation in the same layer.",
))
}
}
fn declared(
environment: &ResolvedAnalysis<'_>,
owner: Id,
field: &str,
value: &AnalysisType,
) -> Result<ResolvedType, AnalysisError> {
environment.declared_type(value).ok_or_else(|| {
AnalysisError::new(
owner,
field,
"analysis_kind",
"Declared quantity kind does not resolve to a quantity-kind definition.",
)
})
}
fn check_bindings(
environment: &ResolvedAnalysis<'_>,
budget: &mut Budget,
) -> Result<(), AnalysisError> {
let no_parameters = BTreeMap::new();
for (id, definition) in &environment.project.definitions {
budget.charge(*id, "definition", 1)?;
if let DefinitionBody::Calculation(calculation) = &definition.body {
let mut parameters = BTreeMap::new();
let mut names = BTreeSet::new();
for parameter in &calculation.parameters {
budget.charge(*id, "parameters", 1)?;
if !crate::valid_alias(&parameter.name) || !names.insert(&parameter.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", &parameter.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,
&parameters,
budget,
)?;
}
}
for (id, element) in &environment.project.elements {
budget.charge(*id, "element", 1)?;
let layer = element.layer();
match element.shared() {
Some(SharedBody::EngineeringProperty {
subject: target,
value_type,
display_unit,
base,
}) => {
subject(environment, *id, layer, *target)?;
let expected = declared(environment, *id, "value_type", value_type)?;
if let Some(reference) = display_unit {
let unit = environment.unit(*reference).ok_or_else(|| {
AnalysisError::new(
*id,
"display_unit",
"analysis_unit",
"Display unit must reference a unit definition.",
)
})?;
let scalar = match &expected {
ResolvedType::Scalar(value) | ResolvedType::List(value) => value,
};
if scalar
!= &ResolvedScalar::Numeric(crate::analysis_numeric::NumericType::Named(
unit.kind().clone(),
))
{
return Err(AnalysisError::new(
*id,
"display_unit",
"analysis_unit",
"Display unit must have the property's declared nominal quantity kind.",
));
}
}
super::typecheck::binding(
environment,
*id,
"base",
base,
&expected,
Some(layer),
&no_parameters,
budget,
)?;
}
Some(SharedBody::AnalysisCase {
subject: target,
overrides,
..
}) => {
if let Some(target) = target {
subject(environment, *id, layer, *target)?;
}
for (property, binding) in overrides {
budget.charge(*id, "overrides", 1)?;
let target = environment.project.elements.get(property).ok_or_else(|| {
AnalysisError::new(
*id,
"overrides",
"analysis_property",
"Override target does not exist.",
)
})?;
let Some(SharedBody::EngineeringProperty { value_type, .. }) = target.shared()
else {
return Err(AnalysisError::new(
*id,
"overrides",
"analysis_property",
"Override target must be an engineering property.",
));
};
if target.layer() != layer {
return Err(AnalysisError::new(
*id,
"overrides",
"analysis_layer",
"Case and overridden property must share a layer.",
));
}
let expected = declared(environment, *id, "overrides", value_type)?;
super::typecheck::binding(
environment,
*id,
"overrides",
binding,
&expected,
Some(layer),
&no_parameters,
budget,
)?;
}
}
Some(SharedBody::AnalysisCheck {
subject: target,
documented_constraint,
predicate,
}) => {
if let Some(target) = target {
subject(environment, *id, layer, *target)?;
}
if let Some(constraint) = documented_constraint
&& !environment
.project
.elements
.get(constraint)
.is_some_and(|target| {
target.kind() == Kind::Constraint && target.layer() == layer
})
{
return Err(AnalysisError::new(
*id,
"documented_constraint",
"analysis_constraint",
"Check documentation must reference a constraint in the same layer.",
));
}
super::typecheck::binding(
environment,
*id,
"predicate",
predicate,
&ResolvedType::boolean(),
Some(layer),
&no_parameters,
budget,
)?;
}
_ => {}
}
}
Ok(())
}
/// Iterative graph admission; neither model depth nor call depth grows this stack.
fn acyclic(
owner: Id,
field: &str,
graph: &BTreeMap<Id, BTreeSet<Id>>,
call_graph: bool,
budget: &mut Budget,
) -> Result<(), AnalysisError> {
let mut degrees = BTreeMap::new();
let mut users: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
let mut heights = BTreeMap::new();
let mut ready = BTreeSet::new();
for (id, dependencies) in graph {
budget.charge(owner, field, 1)?;
degrees.insert(*id, dependencies.len());
heights.insert(*id, 1usize);
if dependencies.is_empty() {
ready.insert(*id);
}
for dependency in dependencies {
budget.charge(owner, field, 1)?;
if !graph.contains_key(dependency) {
return Err(AnalysisError::new(
owner,
field,
"analysis_reference",
"Dependency is outside its typed graph.",
));
}
users.entry(*dependency).or_default().push(*id);
}
}
let mut visited = 0usize;
while let Some(id) = ready.pop_first() {
budget.charge(owner, field, 1)?;
visited += 1;
let height = heights[&id];
if call_graph && height > MAX_CALL_DEPTH {
return Err(AnalysisError::new(
owner,
field,
"analysis_call_depth",
"Calculation call graph exceeds 32 active definition calls.",
));
}
if let Some(dependents) = users.get(&id) {
for dependent in dependents {
budget.charge(owner, field, 1)?;
let degree = degrees.get_mut(dependent).expect("indexed graph node");
*degree -= 1;
let current = heights.get_mut(dependent).expect("indexed graph node");
*current = (*current).max(height + 1);
if *degree == 0 {
ready.insert(*dependent);
}
}
}
}
if visited != graph.len() {
return Err(AnalysisError::new(
owner,
field,
"analysis_cycle",
"Stored dependencies contain a cycle, including statically unused branches.",
));
}
Ok(())
}
fn closure(
owner: Id,
base_nodes: usize,
calls: impl IntoIterator<Item = Id>,
index: &Index,
budget: &mut Budget,
) -> Result<(), AnalysisError> {
let mut nodes = base_nodes;
let mut pending: Vec<_> = calls.into_iter().collect();
let mut seen = BTreeSet::new();
while let Some(id) = pending.pop() {
budget.charge(owner, "closure", 1)?;
if !seen.insert(id) {
continue;
}
let info = index.calculations.get(&id).ok_or_else(|| {
AnalysisError::new(
owner,
"closure",
"analysis_calculation",
"Calculation dependency does not resolve.",
)
})?;
nodes = nodes.checked_add(info.nodes).ok_or_else(|| {
AnalysisError::new(
owner,
"closure",
"analysis_nodes",
"Stored node count overflow.",
)
})?;
if nodes > MAX_CLOSURE_NODES {
return Err(AnalysisError::new(
owner,
"closure",
"analysis_nodes",
"Stored effective closure exceeds 65,536 expression nodes.",
));
}
for dependency in &info.calls {
budget.charge(owner, "closure", 1)?;
pending.push(*dependency);
}
}
if nodes > MAX_CLOSURE_NODES {
return Err(AnalysisError::new(
owner,
"closure",
"analysis_nodes",
"Stored effective closure exceeds 65,536 expression nodes.",
));
}
Ok(())
}
fn check_graphs(
environment: &ResolvedAnalysis<'_>,
index: &Index,
budget: &mut Budget,
) -> Result<(), AnalysisError> {
let project = environment.project;
let mut calls = BTreeMap::new();
for (id, info) in &index.calculations {
budget.charge(*id, "calls", 1 + info.calls.len() as u64)?;
calls.insert(*id, info.calls.clone());
}
acyclic(project.id, "calculations", &calls, true, budget)?;
for id in index.calculations.keys() {
closure(*id, 0, [*id], index, budget)?;
}
for layer in [Layer::Oa, Layer::Sa] {
let mut contexts = vec![EvaluationContext::ModelBaseline];
for id in index.cases.keys() {
budget.charge(*id, "context", 1)?;
if project.layer_of(*id) == Some(layer) {
contexts.push(EvaluationContext::Case(*id));
}
}
for context in contexts {
let owner = match context {
EvaluationContext::ModelBaseline => project.id,
EvaluationContext::Case(id) => id,
};
let mut graph = BTreeMap::new();
let mut nodes = 0usize;
let mut definitions = BTreeSet::new();
for (id, base) in &index.properties {
budget.charge(owner, "effective_properties", 1)?;
if project.layer_of(*id) != Some(layer) {
continue;
}
let effective = match context {
EvaluationContext::ModelBaseline => base,
EvaluationContext::Case(case) => index.cases[&case].get(id).unwrap_or(base),
};
budget.charge(
owner,
"effective_properties",
(effective.properties.len() + effective.calls.len()) as u64,
)?;
nodes = nodes.checked_add(effective.nodes).ok_or_else(|| {
AnalysisError::new(
owner,
"closure",
"analysis_nodes",
"Stored node count overflow.",
)
})?;
graph.insert(*id, effective.properties.clone());
definitions.extend(&effective.calls);
}
for (id, info) in &index.checks {
budget.charge(owner, "effective_checks", 1)?;
if project.layer_of(*id) == Some(layer) {
budget.charge(owner, "effective_checks", info.calls.len() as u64)?;
nodes = nodes.checked_add(info.nodes).ok_or_else(|| {
AnalysisError::new(
owner,
"closure",
"analysis_nodes",
"Stored node count overflow.",
)
})?;
definitions.extend(&info.calls);
}
}
acyclic(owner, "effective_properties", &graph, false, budget)?;
closure(owner, nodes, definitions, index, budget)?;
}
}
Ok(())
}
#[cfg(test)]
mod meter_tests {
use super::*;
#[test]
fn static_meter_exact_boundary_zero_charge_and_exhaustion_latch() {
let owner = Id::nil();
let mut budget = Budget {
limit: 3,
usage: AdmissionUsage::default(),
exhausted: false,
};
budget.charge(owner, "test", 0).unwrap();
budget.charge(owner, "test", 3).unwrap();
budget.charge(owner, "test", 0).unwrap();
assert_eq!(budget.usage.static_events, 3);
assert!(budget.charge(owner, "test", 1).is_err());
assert!(budget.charge(owner, "test", 0).is_err());
assert!(budget.charge(owner, "test", u64::MAX).is_err());
assert_eq!(budget.usage.static_events, 3);
let mut zero = Budget {
limit: 0,
usage: AdmissionUsage::default(),
exhausted: false,
};
zero.charge(owner, "test", 0).unwrap();
assert!(zero.charge(owner, "test", 1).is_err());
assert!(zero.exhausted);
}
}
+246
View File
@@ -0,0 +1,246 @@
//! Typed live reference domains; parameter IDs and opaque historical captures are separate.
use super::*;
use crate::{Element, Id, Project, SharedBody};
use std::collections::BTreeSet;
pub fn expression_properties(expression: &Expr) -> BTreeSet<Id> {
let mut found = BTreeSet::new();
walk(expression, &mut |node| {
if let Expr::Property(id) = node {
found.insert(*id);
}
});
found
}
pub fn expression_definitions(expression: &Expr) -> BTreeSet<Id> {
let mut found = BTreeSet::new();
walk(expression, &mut |node| match node {
Expr::Quantity { unit, .. } => {
found.insert(unit.local());
}
Expr::Call { definition, .. } => {
found.insert(definition.local());
}
Expr::AsKind { kind, .. } => {
found.insert(kind.local());
}
_ => {}
});
found
}
pub(crate) fn walk<'a>(expression: &'a Expr, visit: &mut impl FnMut(&'a Expr)) {
let mut pending = vec![expression];
while let Some(node) = pending.pop() {
visit(node);
match node {
Expr::List(items) => pending.extend(items.iter().rev()),
Expr::Unary { operand, .. } => pending.push(operand),
Expr::Binary { left, right, .. } => {
pending.push(right);
pending.push(left);
}
Expr::If {
condition,
when_true,
when_false,
} => {
pending.push(when_false);
pending.push(when_true);
pending.push(condition);
}
Expr::Call { arguments, .. } => pending.extend(arguments.values().rev()),
Expr::Sum { values, initial } => {
pending.push(values);
pending.push(initial);
}
Expr::Power { base, .. } => pending.push(base),
Expr::AsKind { value, .. } => pending.push(value),
_ => {}
}
}
}
fn walk_mut(expression: &mut Expr, visit: &mut impl FnMut(&mut Expr)) {
let mut pending = vec![expression];
while let Some(node) = pending.pop() {
visit(node);
match node {
Expr::List(items) => pending.extend(items.iter_mut()),
Expr::Unary { operand, .. } => pending.push(operand),
Expr::Binary { left, right, .. } => {
pending.push(left);
pending.push(right);
}
Expr::If {
condition,
when_true,
when_false,
} => {
pending.push(condition);
pending.push(when_true);
pending.push(when_false);
}
Expr::Call { arguments, .. } => pending.extend(arguments.values_mut()),
Expr::Sum { values, initial } => {
pending.push(values);
pending.push(initial);
}
Expr::Power { base, .. } => pending.push(base),
Expr::AsKind { value, .. } => pending.push(value),
_ => {}
}
}
}
pub fn element_model_references(element: &Element) -> BTreeSet<Id> {
let mut result = BTreeSet::new();
let mut binding = |value: &Binding| {
if let Binding::Expression(expression) = value {
result.extend(expression_properties(expression));
}
};
match element.shared() {
Some(SharedBody::EngineeringProperty { base, .. }) => binding(base),
Some(SharedBody::AnalysisCase { overrides, .. }) => {
for value in overrides.values() {
binding(value);
}
}
Some(SharedBody::AnalysisCheck { predicate, .. }) => binding(predicate),
_ => {}
}
match element.shared() {
Some(SharedBody::EngineeringProperty { subject, .. }) => {
result.insert(subject.id());
}
Some(SharedBody::AnalysisCase {
subject, overrides, ..
}) => {
result.extend(subject.map(AnalysisSubject::id));
result.extend(overrides.keys());
}
Some(SharedBody::AnalysisCheck {
subject,
documented_constraint,
..
}) => {
result.extend(subject.map(AnalysisSubject::id));
result.extend(documented_constraint);
}
_ => {}
}
result
}
fn remap_ref(reference: &mut DefinitionRef, old: Id, new: Id) {
let DefinitionRef::Local(id) = reference;
if *id == old {
*id = new;
}
}
fn remap_type(value: &mut AnalysisType, old: Id, new: Id) {
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
if let ScalarType::Quantity(reference) = scalar {
remap_ref(reference, old, new);
}
}
fn remap_binding(value: &mut Binding, old: Id, new: Id) {
if let Binding::Expression(expression) = value {
walk_mut(expression, &mut |node| match node {
Expr::Property(id) if *id == old => *id = new,
Expr::Quantity { unit, .. } => remap_ref(unit, old, new),
Expr::Call { definition, .. } => remap_ref(definition, old, new),
Expr::AsKind { kind, .. } => remap_ref(kind, old, new),
_ => {} // Parameter nodes and argument keys are scoped, never global.
});
}
}
fn remap_subject(subject: &mut AnalysisSubject, old: Id, new: Id) {
let (AnalysisSubject::Element(id) | AnalysisSubject::Relation(id)) = subject;
if *id == old {
*id = new;
}
}
/// Check before any mutation so a map-key collision never silently discards a binding.
pub(crate) fn check_remap(project: &Project, old: Id, new: Id) -> Result<(), AnalysisError> {
if old != new {
for element in project.elements.values() {
if let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared()
&& overrides.contains_key(&old)
&& overrides.contains_key(&new)
{
return Err(AnalysisError::new(
element.id,
"overrides",
"analysis_reference_collision",
"Reidentification would merge two distinct override keys.",
));
}
}
}
Ok(())
}
pub(crate) fn remap_project(project: &mut Project, old: Id, new: Id) {
for element in project.elements.values_mut() {
match element.shared_mut() {
Some(SharedBody::EngineeringProperty {
subject,
value_type,
display_unit,
base,
}) => {
remap_subject(subject, old, new);
remap_type(value_type, old, new);
if let Some(reference) = display_unit {
remap_ref(reference, old, new);
}
remap_binding(base, old, new);
}
Some(SharedBody::AnalysisCase {
subject, overrides, ..
}) => {
if let Some(subject) = subject {
remap_subject(subject, old, new);
}
if old != new
&& let Some(value) = overrides.remove(&old)
{
overrides.insert(new, value);
}
for value in overrides.values_mut() {
remap_binding(value, old, new);
}
}
Some(SharedBody::AnalysisCheck {
subject,
documented_constraint,
predicate,
}) => {
if let Some(subject) = subject {
remap_subject(subject, old, new);
}
if *documented_constraint == Some(old) {
*documented_constraint = Some(new);
}
remap_binding(predicate, old, new);
}
_ => {}
}
}
for definition in project.definitions.values_mut() {
match &mut definition.body {
DefinitionBody::QuantityKind(QuantityKindDefinition {
algebra: QuantityAlgebra::Affine { difference },
..
}) => remap_ref(difference, old, new),
DefinitionBody::Unit(unit) => remap_ref(&mut unit.kind, old, new),
DefinitionBody::Calculation(calculation) => {
for parameter in &mut calculation.parameters {
remap_type(&mut parameter.value_type, old, new);
}
remap_type(&mut calculation.result, old, new);
remap_binding(&mut calculation.body, old, new);
}
_ => {}
}
}
}
+146
View File
@@ -0,0 +1,146 @@
use super::*;
use crate::analysis_numeric::{NumericType, ResolvedKind, Unit};
use crate::{Id, Project, SharedBody};
use std::collections::BTreeMap;
/// Bounded local nominal origin; no rooted graph is cloned during arithmetic.
/// Full ResolvedKind equality additionally includes dimension/algebra/difference.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct KindHandle {
model: Id,
definition: Id,
}
impl KindHandle {
pub fn definition(self) -> Id {
self.definition
}
pub(crate) fn local(model: Id, definition: Id) -> Self {
Self { model, definition }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedScalar {
Boolean,
Numeric(NumericType<KindHandle>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedType {
Scalar(ResolvedScalar),
List(ResolvedScalar),
}
impl ResolvedType {
pub(crate) fn boolean() -> Self {
Self::Scalar(ResolvedScalar::Boolean)
}
pub(crate) fn numeric(value: NumericType<KindHandle>) -> Self {
Self::Scalar(ResolvedScalar::Numeric(value.normalized()))
}
}
#[derive(Debug, Clone, Copy)]
pub struct EffectiveBinding<'a> {
pub binding: &'a Binding,
pub origin: BindingOrigin,
}
/// Constructed only by admission; exact source/capture provenance stays with its owner.
/// Values from different accepted environments must not be mixed by an evaluator.
pub struct ResolvedAnalysis<'p> {
pub(super) project: &'p Project,
pub(super) kinds: BTreeMap<Id, ResolvedKind<KindHandle>>,
pub(super) units: BTreeMap<Id, Unit<KindHandle>>,
pub(super) limits: AdmissionLimits,
pub(super) usage: AdmissionUsage,
}
impl<'p> ResolvedAnalysis<'p> {
pub fn project(&self) -> &'p Project {
self.project
}
pub fn admission_usage(&self) -> AdmissionUsage {
self.usage
}
pub fn admission_limits(&self) -> AdmissionLimits {
self.limits
}
pub fn definition(&self, reference: DefinitionRef) -> Option<&'p Definition> {
self.project.definitions.get(&reference.local())
}
pub fn kind(&self, reference: DefinitionRef) -> Option<&ResolvedKind<KindHandle>> {
self.kinds.get(&reference.local())
}
pub fn unit(&self, reference: DefinitionRef) -> Option<&Unit<KindHandle>> {
self.units.get(&reference.local())
}
pub fn calculation(&self, reference: DefinitionRef) -> Option<&'p CalculationDefinition> {
match &self.definition(reference)?.body {
DefinitionBody::Calculation(value) => Some(value),
_ => None,
}
}
pub fn declared_type(&self, value: &AnalysisType) -> Option<ResolvedType> {
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
let scalar = match scalar {
ScalarType::Boolean => ResolvedScalar::Boolean,
ScalarType::Number => ResolvedScalar::Numeric(NumericType::Number),
ScalarType::Quantity(reference) => {
ResolvedScalar::Numeric(NumericType::Named(self.kind(*reference)?.clone()))
}
};
Some(match value {
AnalysisType::Scalar(_) => ResolvedType::Scalar(scalar),
AnalysisType::List(_) => ResolvedType::List(scalar),
})
}
pub fn effective_binding(
&self,
property: Id,
context: EvaluationContext,
) -> Result<EffectiveBinding<'p>, AnalysisError> {
let element = self.project.elements.get(&property).ok_or_else(|| {
AnalysisError::new(
property,
"context",
"analysis_property",
"Property does not exist.",
)
})?;
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared() else {
return Err(AnalysisError::new(
property,
"context",
"analysis_property",
"Target is not an engineering property.",
));
};
if let EvaluationContext::Case(id) = context {
let case = self.project.elements.get(&id).ok_or_else(|| {
AnalysisError::new(id, "context", "analysis_case", "Case does not exist.")
})?;
let Some(SharedBody::AnalysisCase { overrides, .. }) = case.shared() else {
return Err(AnalysisError::new(
id,
"context",
"analysis_case",
"Context is not an analysis case.",
));
};
if case.layer() != element.layer() {
return Err(AnalysisError::new(
id,
"context",
"analysis_layer",
"Case and property must share a layer.",
));
}
if let Some(binding) = overrides.get(&property) {
return Ok(EffectiveBinding {
binding,
origin: BindingOrigin::CaseOverride(id),
});
}
}
Ok(EffectiveBinding {
binding: base,
origin: BindingOrigin::ModelBaseline,
})
}
}
File diff suppressed because it is too large Load Diff
+277
View File
@@ -0,0 +1,277 @@
//! Static expression checking shares numeric operator algebra with execution.
use super::admission::Budget;
use super::*;
use crate::analysis_numeric::{NumericError, NumericType};
use crate::{Id, Layer, SharedBody};
use std::collections::BTreeMap;
pub(super) fn binding(
environment: &ResolvedAnalysis<'_>,
owner: Id,
field: &str,
value: &Binding,
expected: &ResolvedType,
layer: Option<Layer>,
parameters: &BTreeMap<ParameterId, ResolvedType>,
budget: &mut Budget,
) -> Result<(), AnalysisError> {
if let Binding::Expression(expression) = value {
let mut checker = Checker {
environment,
owner,
field,
layer,
parameters,
budget,
};
checker.expression(expression, Some(expected), 1)?;
}
Ok(())
}
struct Checker<'a, 'p, 'b> {
environment: &'a ResolvedAnalysis<'p>,
owner: Id,
field: &'a str,
layer: Option<Layer>,
parameters: &'a BTreeMap<ParameterId, ResolvedType>,
budget: &'b mut Budget,
}
impl Checker<'_, '_, '_> {
fn error(&self, code: &str, message: impl Into<String>) -> AnalysisError {
AnalysisError::new(self.owner, self.field, code, message)
}
fn numeric(&self, value: ResolvedType) -> Result<NumericType<KindHandle>, AnalysisError> {
match value {
ResolvedType::Scalar(ResolvedScalar::Numeric(value)) => Ok(value),
_ => Err(self.error("analysis_type", "A scalar numeric operand is required.")),
}
}
fn operation(
&self,
result: Result<NumericType<KindHandle>, NumericError>,
) -> Result<ResolvedType, AnalysisError> {
result
.map(ResolvedType::numeric)
.map_err(|error| self.error("analysis_type", error.to_string()))
}
fn expression(
&mut self,
expression: &Expr,
expected: Option<&ResolvedType>,
depth: usize,
) -> Result<ResolvedType, AnalysisError> {
self.budget.charge(self.owner, self.field, 1)?;
if depth > MAX_EXPRESSION_DEPTH {
return Err(self.error(
"analysis_depth",
"Expression exceeds the logical depth limit.",
));
}
let next = depth + 1;
let actual = match expression {
Expr::Boolean(_) => ResolvedType::boolean(),
Expr::Number(_) => ResolvedType::numeric(NumericType::Number),
Expr::Quantity { unit, .. } => {
let unit = self.environment.unit(*unit).ok_or_else(|| {
self.error(
"analysis_unit",
"Quantity literal must reference an existing unit definition.",
)
})?;
ResolvedType::numeric(NumericType::Named(unit.kind().clone()))
}
Expr::Property(id) => {
let layer = self.layer.ok_or_else(|| {
self.error(
"analysis_purity",
"Reusable calculations cannot capture project properties.",
)
})?;
let element =
self.environment.project.elements.get(id).ok_or_else(|| {
self.error("analysis_property", "Property does not exist.")
})?;
let Some(SharedBody::EngineeringProperty { value_type, .. }) = element.shared()
else {
return Err(self.error(
"analysis_property",
"Reference must target an engineering property.",
));
};
if element.layer() != layer {
return Err(self.error(
"analysis_layer",
"Property references must stay in the same layer.",
));
}
self.environment.declared_type(value_type).ok_or_else(|| {
self.error("analysis_kind", "Declared property kind does not resolve.")
})?
}
Expr::Parameter(id) => self.parameters.get(id).cloned().ok_or_else(|| {
self.error(
"analysis_parameter",
"Parameter does not belong to this calculation definition.",
)
})?,
Expr::List(items) => {
if items.len() > MAX_LIST_LENGTH {
return Err(
self.error("analysis_list", "List exceeds the flat-list length limit.")
);
}
let mut scalar = match expected {
Some(ResolvedType::List(value)) => Some(value.clone()),
_ => None,
};
for item in items {
let wanted = scalar.clone().map(ResolvedType::Scalar);
let ResolvedType::Scalar(found) =
self.expression(item, wanted.as_ref(), next)?
else {
return Err(self.error(
"analysis_list",
"Lists are flat; nested lists are not admitted.",
));
};
scalar = Some(found);
}
ResolvedType::List(scalar.ok_or_else(|| {
self.error(
"analysis_list",
"An empty list requires an explicit surrounding list type.",
)
})?)
}
Expr::Unary { op, operand } => match op {
UnaryOp::Not => {
self.expression(operand, Some(&ResolvedType::boolean()), next)?;
ResolvedType::boolean()
}
UnaryOp::Negate | UnaryOp::Absolute => {
let operand = self.expression(operand, None, next)?;
self.operation(self.numeric(operand)?.unary_result())?
}
},
Expr::Binary { op, left, right } => {
let left = self.expression(left, None, next)?;
let right = self.expression(right, None, next)?;
match op {
BinaryOp::And | BinaryOp::Or => {
if left != ResolvedType::boolean() || right != ResolvedType::boolean() {
return Err(
self.error("analysis_type", "Boolean operands are required.")
);
}
ResolvedType::boolean()
}
BinaryOp::Equal | BinaryOp::NotEqual
if left == ResolvedType::boolean() && right == ResolvedType::boolean() =>
{
ResolvedType::boolean()
}
BinaryOp::Equal
| BinaryOp::NotEqual
| BinaryOp::Less
| BinaryOp::LessOrEqual
| BinaryOp::Greater
| BinaryOp::GreaterOrEqual => {
self.numeric(left)?
.comparison_compatible(&self.numeric(right)?)
.map_err(|error| self.error("analysis_type", error.to_string()))?;
ResolvedType::boolean()
}
BinaryOp::Add | BinaryOp::Subtract => self.operation(
self.numeric(left)?
.additive_result(&self.numeric(right)?, *op == BinaryOp::Subtract),
)?,
BinaryOp::Multiply | BinaryOp::Divide => self.operation(
self.numeric(left)?
.multiplicative_result(&self.numeric(right)?, *op == BinaryOp::Divide),
)?,
}
}
Expr::If {
condition,
when_true,
when_false,
} => {
self.expression(condition, Some(&ResolvedType::boolean()), next)?;
let true_type = self.expression(when_true, expected, next)?;
self.expression(when_false, Some(&true_type), next)?;
true_type
}
Expr::Call {
definition,
arguments,
} => {
let calculation = self.environment.calculation(*definition).ok_or_else(|| {
self.error(
"analysis_calculation",
"Call must reference a calculation definition.",
)
})?;
if arguments.len() != calculation.parameters.len()
|| calculation
.parameters
.iter()
.any(|parameter| !arguments.contains_key(&parameter.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(&parameter.value_type)
.ok_or_else(|| {
self.error("analysis_kind", "Parameter kind does not resolve.")
})?;
self.expression(&arguments[&parameter.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)
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Persistent analysis vocabulary. This is part of Project, not an evaluation model.
use crate::Id;
use crate::analysis_numeric::{Dimension, Exact};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Only real local definitions are admitted in schema 8's first environment.
/// Imported references require the separately implemented pinned package catalog.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DefinitionRef {
Local(Id),
}
impl DefinitionRef {
pub fn local(self) -> Id {
match self {
Self::Local(id) => id,
}
}
}
/// Qualified by its owning CalculationDefinition; never a global model identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ParameterId(pub Id);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Definition {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
pub body: DefinitionBody,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DefinitionBody {
QuantityKind(QuantityKindDefinition),
Unit(UnitDefinition),
Calculation(CalculationDefinition),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QuantityKindDefinition {
pub dimension: Dimension,
pub algebra: QuantityAlgebra,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum QuantityAlgebra {
Linear,
Affine { difference: DefinitionRef },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UnitDefinition {
pub kind: DefinitionRef,
pub scale: Exact,
pub offset: Exact,
pub symbol: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ScalarType {
Boolean,
Number,
Quantity(DefinitionRef),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum AnalysisType {
Scalar(ScalarType),
List(ScalarType),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Parameter {
pub id: ParameterId,
pub name: String,
pub value_type: AnalysisType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CalculationDefinition {
pub parameters: Vec<Parameter>,
pub result: AnalysisType,
pub body: Binding,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Binding {
Unbound,
Expression(Expr),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum UnaryOp {
Negate,
Absolute,
Not,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BinaryOp {
Add,
Subtract,
Multiply,
Divide,
Equal,
NotEqual,
Less,
LessOrEqual,
Greater,
GreaterOrEqual,
And,
Or,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Expr {
Boolean(bool),
Number(Exact),
Quantity {
magnitude: Exact,
unit: DefinitionRef,
},
Property(Id),
Parameter(ParameterId),
List(Vec<Expr>),
Unary {
op: UnaryOp,
operand: Box<Expr>,
},
Binary {
op: BinaryOp,
left: Box<Expr>,
right: Box<Expr>,
},
If {
condition: Box<Expr>,
when_true: Box<Expr>,
when_false: Box<Expr>,
},
Call {
definition: DefinitionRef,
#[serde(deserialize_with = "unique_parameter_map")]
arguments: BTreeMap<ParameterId, Expr>,
},
Sum {
values: Box<Expr>,
initial: Box<Expr>,
},
Power {
base: Box<Expr>,
exponent: i8,
},
AsKind {
kind: DefinitionRef,
value: Box<Expr>,
},
}
fn unique_parameter_map<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<ParameterId, Expr>, D::Error> {
crate::unique_id_map(deserializer).map(|map| {
map.into_iter()
.map(|(id, expression)| (ParameterId(id), expression))
.collect()
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum AnalysisSubject {
Element(Id),
Relation(Id),
}
impl AnalysisSubject {
pub fn id(self) -> Id {
match self {
Self::Element(id) | Self::Relation(id) => id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BaselineBase {
ModelBaseline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum EvaluationContext {
ModelBaseline,
Case(Id),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum BindingOrigin {
ModelBaseline,
CaseOverride(Id),
}
/// Located structural/static admission failure, not a false or failed evaluation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(deny_unknown_fields)]
#[error("{code} at {object}.{field}: {message}")]
pub struct AnalysisError {
pub object: Id,
pub field: String,
pub code: String,
pub message: String,
}
impl AnalysisError {
pub(crate) fn new(
object: Id,
field: impl Into<String>,
code: &str,
message: impl Into<String>,
) -> Self {
Self {
object,
field: field.into(),
code: code.into(),
message: message.into(),
}
}
}