1079 lines
39 KiB
Rust
1079 lines
39 KiB
Rust
//! Bounded static admission for the actual Project catalog and analysis records.
|
|
use super::typecheck::BindingContext;
|
|
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.
|
|
/// Events cover analysis record/node visits, queued child slots, scanned text
|
|
/// bytes, parameter lookups and dependency/relation hops. Fixed-size type and
|
|
/// exact-number operations have bounded cost within their containing event;
|
|
/// this does not meter the existing architectural validator or wire allocation.
|
|
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),
|
|
] {
|
|
budget.charge(*id, field, value.len() as u64)?;
|
|
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();
|
|
budget.charge(*id, "symbol", unit.symbol.len() as u64)?;
|
|
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 children = match node {
|
|
Expr::List(items) => {
|
|
if items.len() > MAX_LIST_LENGTH {
|
|
return Err(AnalysisError::new(
|
|
owner,
|
|
field,
|
|
"analysis_list",
|
|
"List exceeds 1024 entries.",
|
|
));
|
|
}
|
|
items.len()
|
|
}
|
|
Expr::Call { arguments, .. } => {
|
|
if arguments.len() > MAX_CLOSURE_NODES {
|
|
return Err(AnalysisError::new(
|
|
owner,
|
|
field,
|
|
"analysis_nodes",
|
|
"Call argument count exceeds the stored closure limit.",
|
|
));
|
|
}
|
|
arguments.len()
|
|
}
|
|
Expr::Unary { .. } | Expr::Power { .. } | Expr::AsKind { .. } => 1,
|
|
Expr::Binary { .. } | Expr::Sum { .. } => 2,
|
|
Expr::If { .. } => 3,
|
|
_ => 0,
|
|
};
|
|
if info
|
|
.nodes
|
|
.saturating_add(pending.len())
|
|
.saturating_add(children)
|
|
> MAX_CLOSURE_NODES
|
|
{
|
|
return Err(AnalysisError::new(
|
|
owner,
|
|
field,
|
|
"analysis_nodes",
|
|
"Visited and pending expression nodes exceed the stored closure limit.",
|
|
));
|
|
}
|
|
// Precharge variable-width iteration and allocation before enqueue.
|
|
budget.charge(owner, field, children as u64)?;
|
|
let mut push = |child| pending.push((child, depth + 1));
|
|
match node {
|
|
Expr::Property(id) => {
|
|
info.properties.insert(*id);
|
|
}
|
|
Expr::List(items) => {
|
|
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,
|
|
} => {
|
|
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),
|
|
_ => {}
|
|
}
|
|
}
|
|
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,
|
|
budget: &mut Budget,
|
|
) -> Result<(), AnalysisError> {
|
|
let project = environment.project;
|
|
budget.charge(owner, "subject", 1)?;
|
|
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)
|
|
&& relation_layer(project, id, owner, budget)? == 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.",
|
|
))
|
|
}
|
|
}
|
|
/// Match Project::layer_of without unmetered relation-chain traversal before
|
|
/// architectural validation has had a chance to reject malformed endpoints.
|
|
fn relation_layer(
|
|
project: &Project,
|
|
id: Id,
|
|
owner: Id,
|
|
budget: &mut Budget,
|
|
) -> Result<Option<Layer>, AnalysisError> {
|
|
let mut current = id;
|
|
let mut seen = BTreeSet::new();
|
|
loop {
|
|
budget.charge(owner, "subject", 1)?;
|
|
if !seen.insert(current) {
|
|
return Ok(None);
|
|
}
|
|
if let Some(element) = project.elements.get(¤t) {
|
|
return Ok(Some(element.layer()));
|
|
}
|
|
let Some(relation) = project.relations.get(¤t) else {
|
|
return Ok(None);
|
|
};
|
|
current = relation.source();
|
|
}
|
|
}
|
|
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)?;
|
|
budget.charge(*id, "parameters", parameter.name.len() as u64)?;
|
|
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,
|
|
BindingContext {
|
|
owner: *id,
|
|
field: "body",
|
|
layer: None,
|
|
parameters: ¶meters,
|
|
},
|
|
&calculation.body,
|
|
&expected,
|
|
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, budget)?;
|
|
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,
|
|
BindingContext {
|
|
owner: *id,
|
|
field: "base",
|
|
layer: Some(layer),
|
|
parameters: &no_parameters,
|
|
},
|
|
base,
|
|
&expected,
|
|
budget,
|
|
)?;
|
|
}
|
|
Some(SharedBody::AnalysisCase {
|
|
subject: target,
|
|
overrides,
|
|
..
|
|
}) => {
|
|
if let Some(target) = target {
|
|
subject(environment, *id, layer, *target, budget)?;
|
|
}
|
|
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,
|
|
BindingContext {
|
|
owner: *id,
|
|
field: "overrides",
|
|
layer: Some(layer),
|
|
parameters: &no_parameters,
|
|
},
|
|
binding,
|
|
&expected,
|
|
budget,
|
|
)?;
|
|
}
|
|
}
|
|
Some(SharedBody::AnalysisCheck {
|
|
subject: target,
|
|
documented_constraint,
|
|
predicate,
|
|
}) => {
|
|
if let Some(target) = target {
|
|
subject(environment, *id, layer, *target, budget)?;
|
|
}
|
|
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,
|
|
BindingContext {
|
|
owner: *id,
|
|
field: "predicate",
|
|
layer: Some(layer),
|
|
parameters: &no_parameters,
|
|
},
|
|
predicate,
|
|
&ResolvedType::boolean(),
|
|
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::new();
|
|
for id in calls {
|
|
budget.charge(owner, "closure", 1)?;
|
|
pending.push(id);
|
|
}
|
|
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.elements[id].layer() == 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.elements[id].layer() != 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.elements[id].layer() == 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::*;
|
|
|
|
fn budget(limit: u64) -> Budget {
|
|
Budget {
|
|
limit,
|
|
usage: AdmissionUsage::default(),
|
|
exhausted: false,
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[test]
|
|
fn wide_expression_children_are_charged_before_they_are_queued() {
|
|
let owner = Id::nil();
|
|
let width = 1024;
|
|
for value in [
|
|
Binding::Expression(Expr::List(vec![Expr::Boolean(true); width])),
|
|
Binding::Expression(Expr::Call {
|
|
definition: DefinitionRef::Local(Id::from_u128(1)),
|
|
arguments: (0..width)
|
|
.map(|id| (ParameterId(Id::from_u128(id as u128)), Expr::Boolean(true)))
|
|
.collect(),
|
|
}),
|
|
] {
|
|
let mut short = budget(width as u64);
|
|
let failure = match Info::read(owner, "probe", &value, &mut short) {
|
|
Ok(_) => panic!("Child queue must be precharged"),
|
|
Err(error) => error,
|
|
};
|
|
assert_eq!(failure.code, "analysis_limit");
|
|
assert_eq!(short.usage.static_events, 1);
|
|
assert_eq!(short.usage.stored_expression_nodes, 1);
|
|
assert!(short.exhausted);
|
|
let mut exact = budget((2 * width + 1) as u64);
|
|
let info = Info::read(owner, "probe", &value, &mut exact).unwrap();
|
|
assert_eq!(info.nodes, width + 1);
|
|
assert_eq!(exact.usage.static_events, (2 * width + 1) as u64);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn call_key_scan_and_argument_lookup_have_separate_precharges() {
|
|
let mut project = Project::empty();
|
|
let calculation = Id::from_u128(1);
|
|
let parameters: Vec<_> = (0..64)
|
|
.map(|n| Parameter {
|
|
id: ParameterId(Id::from_u128(100 + n)),
|
|
name: format!("argument_{n}"),
|
|
value_type: AnalysisType::Scalar(ScalarType::Boolean),
|
|
})
|
|
.collect();
|
|
let mut arguments: BTreeMap<_, _> = parameters
|
|
.iter()
|
|
.map(|parameter| (parameter.id, Expr::Boolean(true)))
|
|
.collect();
|
|
project.definitions.insert(
|
|
calculation,
|
|
Definition {
|
|
id: calculation,
|
|
alias: "call_probe".into(),
|
|
name: "Call probe".into(),
|
|
description: String::new(),
|
|
body: DefinitionBody::Calculation(CalculationDefinition {
|
|
parameters,
|
|
result: AnalysisType::Scalar(ScalarType::Boolean),
|
|
body: Binding::Unbound,
|
|
}),
|
|
},
|
|
);
|
|
let environment = admit(&project, AdmissionLimits::REFERENCE).unwrap();
|
|
let missing = ParameterId(Id::from_u128(163));
|
|
arguments.remove(&missing);
|
|
arguments.insert(ParameterId(Id::from_u128(999)), Expr::Boolean(true));
|
|
let mut value = Binding::Expression(Expr::Call {
|
|
definition: DefinitionRef::Local(calculation),
|
|
arguments,
|
|
});
|
|
let no_parameters = BTreeMap::new();
|
|
let check = |value: &Binding, budget: &mut Budget| {
|
|
super::super::typecheck::binding(
|
|
&environment,
|
|
BindingContext {
|
|
owner: project.id,
|
|
field: "probe",
|
|
layer: None,
|
|
parameters: &no_parameters,
|
|
},
|
|
value,
|
|
&ResolvedType::boolean(),
|
|
budget,
|
|
)
|
|
};
|
|
let mut short = budget(64);
|
|
assert_eq!(
|
|
check(&value, &mut short).unwrap_err().code,
|
|
"analysis_limit"
|
|
);
|
|
assert_eq!(short.usage.static_events, 1);
|
|
let mut keys_only = budget(65);
|
|
assert_eq!(
|
|
check(&value, &mut keys_only).unwrap_err().code,
|
|
"analysis_arguments"
|
|
);
|
|
assert_eq!(keys_only.usage.static_events, 65);
|
|
let Binding::Expression(Expr::Call { arguments, .. }) = &mut value else {
|
|
panic!()
|
|
};
|
|
arguments.remove(&ParameterId(Id::from_u128(999)));
|
|
arguments.insert(missing, Expr::Boolean(true));
|
|
let mut exact = budget(193); // One Call + 64 keys + 64 lookups + 64 leaf visits.
|
|
check(&value, &mut exact).unwrap();
|
|
assert_eq!(exact.usage.static_events, 193);
|
|
let mut one_short = budget(192);
|
|
assert_eq!(
|
|
check(&value, &mut one_short).unwrap_err().code,
|
|
"analysis_limit"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn relation_subject_resolution_charges_each_hop_and_bounds_cycles() {
|
|
let mut project = Project::empty();
|
|
let element = Id::from_u128(2000);
|
|
project.elements.insert(
|
|
element,
|
|
crate::Element {
|
|
id: element,
|
|
alias: "subject".into(),
|
|
name: "Subject".into(),
|
|
description: String::new(),
|
|
parent: None,
|
|
body: crate::ElementBody::Oa(crate::OaBody::Activity),
|
|
},
|
|
);
|
|
let first = Id::from_u128(3000);
|
|
let last = Id::from_u128(3063);
|
|
// These deliberately malformed relation endpoints reach analysis before
|
|
// architecture admission; no valid architectural chain is claimed.
|
|
for n in 3000..3064 {
|
|
let id = Id::from_u128(n);
|
|
project.relations.insert(
|
|
id,
|
|
crate::Relation {
|
|
id,
|
|
alias: format!("relation_{n}"),
|
|
name: "Probe".into(),
|
|
description: String::new(),
|
|
body: crate::RelationBody::Allocation {
|
|
source: if id == last {
|
|
element
|
|
} else {
|
|
Id::from_u128(n + 1)
|
|
},
|
|
target: element,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
let mut short = budget(64);
|
|
assert_eq!(
|
|
relation_layer(&project, first, project.id, &mut short)
|
|
.unwrap_err()
|
|
.code,
|
|
"analysis_limit"
|
|
);
|
|
assert_eq!(short.usage.static_events, 64);
|
|
let mut exact = budget(65);
|
|
assert_eq!(
|
|
relation_layer(&project, first, project.id, &mut exact).unwrap(),
|
|
Some(Layer::Oa)
|
|
);
|
|
assert_eq!(exact.usage.static_events, 65);
|
|
project.relations.get_mut(&last).unwrap().body = crate::RelationBody::Allocation {
|
|
source: first,
|
|
target: element,
|
|
};
|
|
let mut cycle = budget(65);
|
|
assert_eq!(
|
|
relation_layer(&project, first, project.id, &mut cycle).unwrap(),
|
|
None
|
|
);
|
|
assert_eq!(cycle.usage.static_events, 65);
|
|
project.relations.get_mut(&last).unwrap().body = crate::RelationBody::Allocation {
|
|
source: Id::from_u128(9999),
|
|
target: element,
|
|
};
|
|
let mut missing = budget(65);
|
|
assert_eq!(
|
|
relation_layer(&project, first, project.id, &mut missing).unwrap(),
|
|
None
|
|
);
|
|
assert_eq!(missing.usage.static_events, 65);
|
|
}
|
|
}
|