fix(analysis): bound type-checker continuations and admission work
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
//! 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};
|
||||
@@ -6,6 +7,10 @@ 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)]
|
||||
@@ -122,6 +127,7 @@ fn resolve_definitions(
|
||||
("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,
|
||||
@@ -184,6 +190,7 @@ fn resolve_definitions(
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
budget.charge(*id, "symbol", unit.symbol.len() as u64)?;
|
||||
if !crate::validation::valid_text(&unit.symbol) {
|
||||
return Err(AnalysisError::new(
|
||||
*id,
|
||||
@@ -240,11 +247,7 @@ impl Info {
|
||||
"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);
|
||||
}
|
||||
let children = match node {
|
||||
Expr::List(items) => {
|
||||
if items.len() > MAX_LIST_LENGTH {
|
||||
return Err(AnalysisError::new(
|
||||
@@ -254,6 +257,45 @@ impl Info {
|
||||
"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);
|
||||
}
|
||||
@@ -276,14 +318,6 @@ impl Info {
|
||||
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);
|
||||
@@ -297,14 +331,6 @@ impl Info {
|
||||
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)
|
||||
}
|
||||
@@ -371,15 +397,18 @@ fn subject(
|
||||
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) && project.layer_of(id) == Some(layer)
|
||||
project.relations.contains_key(&id)
|
||||
&& relation_layer(project, id, owner, budget)? == Some(layer)
|
||||
}
|
||||
};
|
||||
if valid {
|
||||
@@ -393,6 +422,30 @@ fn subject(
|
||||
))
|
||||
}
|
||||
}
|
||||
/// 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,
|
||||
@@ -420,6 +473,7 @@ fn check_bindings(
|
||||
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,
|
||||
@@ -441,12 +495,14 @@ fn check_bindings(
|
||||
let expected = declared(environment, *id, "result", &calculation.result)?;
|
||||
super::typecheck::binding(
|
||||
environment,
|
||||
*id,
|
||||
"body",
|
||||
BindingContext {
|
||||
owner: *id,
|
||||
field: "body",
|
||||
layer: None,
|
||||
parameters: ¶meters,
|
||||
},
|
||||
&calculation.body,
|
||||
&expected,
|
||||
None,
|
||||
¶meters,
|
||||
budget,
|
||||
)?;
|
||||
}
|
||||
@@ -461,7 +517,7 @@ fn check_bindings(
|
||||
display_unit,
|
||||
base,
|
||||
}) => {
|
||||
subject(environment, *id, layer, *target)?;
|
||||
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(|| {
|
||||
@@ -490,12 +546,14 @@ fn check_bindings(
|
||||
}
|
||||
super::typecheck::binding(
|
||||
environment,
|
||||
*id,
|
||||
"base",
|
||||
BindingContext {
|
||||
owner: *id,
|
||||
field: "base",
|
||||
layer: Some(layer),
|
||||
parameters: &no_parameters,
|
||||
},
|
||||
base,
|
||||
&expected,
|
||||
Some(layer),
|
||||
&no_parameters,
|
||||
budget,
|
||||
)?;
|
||||
}
|
||||
@@ -505,7 +563,7 @@ fn check_bindings(
|
||||
..
|
||||
}) => {
|
||||
if let Some(target) = target {
|
||||
subject(environment, *id, layer, *target)?;
|
||||
subject(environment, *id, layer, *target, budget)?;
|
||||
}
|
||||
for (property, binding) in overrides {
|
||||
budget.charge(*id, "overrides", 1)?;
|
||||
@@ -537,12 +595,14 @@ fn check_bindings(
|
||||
let expected = declared(environment, *id, "overrides", value_type)?;
|
||||
super::typecheck::binding(
|
||||
environment,
|
||||
*id,
|
||||
"overrides",
|
||||
BindingContext {
|
||||
owner: *id,
|
||||
field: "overrides",
|
||||
layer: Some(layer),
|
||||
parameters: &no_parameters,
|
||||
},
|
||||
binding,
|
||||
&expected,
|
||||
Some(layer),
|
||||
&no_parameters,
|
||||
budget,
|
||||
)?;
|
||||
}
|
||||
@@ -553,7 +613,7 @@ fn check_bindings(
|
||||
predicate,
|
||||
}) => {
|
||||
if let Some(target) = target {
|
||||
subject(environment, *id, layer, *target)?;
|
||||
subject(environment, *id, layer, *target, budget)?;
|
||||
}
|
||||
if let Some(constraint) = documented_constraint
|
||||
&& !environment
|
||||
@@ -573,12 +633,14 @@ fn check_bindings(
|
||||
}
|
||||
super::typecheck::binding(
|
||||
environment,
|
||||
*id,
|
||||
"predicate",
|
||||
BindingContext {
|
||||
owner: *id,
|
||||
field: "predicate",
|
||||
layer: Some(layer),
|
||||
parameters: &no_parameters,
|
||||
},
|
||||
predicate,
|
||||
&ResolvedType::boolean(),
|
||||
Some(layer),
|
||||
&no_parameters,
|
||||
budget,
|
||||
)?;
|
||||
}
|
||||
@@ -665,7 +727,11 @@ fn closure(
|
||||
budget: &mut Budget,
|
||||
) -> Result<(), AnalysisError> {
|
||||
let mut nodes = base_nodes;
|
||||
let mut pending: Vec<_> = calls.into_iter().collect();
|
||||
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)?;
|
||||
@@ -730,7 +796,7 @@ fn check_graphs(
|
||||
let mut contexts = vec![EvaluationContext::ModelBaseline];
|
||||
for id in index.cases.keys() {
|
||||
budget.charge(*id, "context", 1)?;
|
||||
if project.layer_of(*id) == Some(layer) {
|
||||
if project.elements[id].layer() == layer {
|
||||
contexts.push(EvaluationContext::Case(*id));
|
||||
}
|
||||
}
|
||||
@@ -744,7 +810,7 @@ fn check_graphs(
|
||||
let mut definitions = BTreeSet::new();
|
||||
for (id, base) in &index.properties {
|
||||
budget.charge(owner, "effective_properties", 1)?;
|
||||
if project.layer_of(*id) != Some(layer) {
|
||||
if project.elements[id].layer() != layer {
|
||||
continue;
|
||||
}
|
||||
let effective = match context {
|
||||
@@ -769,7 +835,7 @@ fn check_graphs(
|
||||
}
|
||||
for (id, info) in &index.checks {
|
||||
budget.charge(owner, "effective_checks", 1)?;
|
||||
if project.layer_of(*id) == Some(layer) {
|
||||
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(
|
||||
@@ -793,6 +859,14 @@ fn check_graphs(
|
||||
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();
|
||||
@@ -818,4 +892,187 @@ mod meter_tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +325,32 @@ fn add_calculation(project: &mut Project, n: u128, result: AnalysisType, body: E
|
||||
id
|
||||
}
|
||||
|
||||
fn add_identity_calculation(
|
||||
project: &mut Project,
|
||||
n: u128,
|
||||
value_type: AnalysisType,
|
||||
) -> (Id, ParameterId) {
|
||||
let id = Id::from_u128(n);
|
||||
let parameter = ParameterId(Id::from_u128(n + 10_000));
|
||||
project.definitions.insert(
|
||||
id,
|
||||
definition(
|
||||
id,
|
||||
&format!("identity_{n}"),
|
||||
DefinitionBody::Calculation(CalculationDefinition {
|
||||
parameters: vec![Parameter {
|
||||
id: parameter,
|
||||
name: "value".into(),
|
||||
value_type: value_type.clone(),
|
||||
}],
|
||||
result: value_type,
|
||||
body: expression(Expr::Parameter(parameter)),
|
||||
}),
|
||||
),
|
||||
);
|
||||
(id, parameter)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_real_model_roundtrips_and_exposes_explicit_effective_bindings() {
|
||||
let f = fixture();
|
||||
@@ -611,6 +637,34 @@ fn calculations_are_pure_parameters_are_scoped_and_calls_bind_exactly() {
|
||||
calculation.parameters[0].name = "first_duration".into();
|
||||
renamed = execute_batch(&renamed, &[Command::EditDefinition(f.combine, revised)]).unwrap();
|
||||
admit(&renamed, AdmissionLimits::REFERENCE).unwrap();
|
||||
|
||||
// Distinct errors make argument order observable. Parameter a sorts before
|
||||
// b by UUID; reversing declaration order must reverse the first error.
|
||||
let mut ordered = f.project.clone();
|
||||
*base_mut(&mut ordered, f.total) = expression(Expr::Call {
|
||||
definition: reference(f.combine),
|
||||
arguments: BTreeMap::from([
|
||||
(f.a, Expr::Parameter(f.a)),
|
||||
(f.b, Expr::Property(f.respond)),
|
||||
]),
|
||||
});
|
||||
assert_eq!(reject(&ordered).code, "analysis_parameter");
|
||||
calculation_mut(&mut ordered, f.combine)
|
||||
.parameters
|
||||
.swap(0, 1);
|
||||
assert_eq!(reject(&ordered).code, "analysis_property");
|
||||
*base_mut(&mut ordered, f.total) = expression(binary(
|
||||
BinaryOp::Add,
|
||||
Expr::Property(f.respond),
|
||||
Expr::Parameter(f.a),
|
||||
));
|
||||
assert_eq!(reject(&ordered).code, "analysis_property");
|
||||
*base_mut(&mut ordered, f.total) = expression(binary(
|
||||
BinaryOp::Add,
|
||||
Expr::Parameter(f.a),
|
||||
Expr::Property(f.respond),
|
||||
));
|
||||
assert_eq!(reject(&ordered).code, "analysis_parameter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -851,6 +905,7 @@ fn logical_depth_and_call_depth_have_exact_boundaries() {
|
||||
};
|
||||
}
|
||||
let deep = add_calculation(&mut f.project, 7000, scalar_number(), nested);
|
||||
eprintln!("analysis depth gate: admitting 128 unary nodes");
|
||||
admit(&f.project, AdmissionLimits::REFERENCE).unwrap();
|
||||
let old = std::mem::replace(
|
||||
&mut calculation_mut(&mut f.project, deep).body,
|
||||
@@ -863,9 +918,74 @@ fn logical_depth_and_call_depth_have_exact_boundaries() {
|
||||
op: UnaryOp::Negate,
|
||||
operand: Box::new(old),
|
||||
});
|
||||
eprintln!("analysis depth gate: rejecting 129 unary nodes");
|
||||
assert_eq!(reject(&f.project).code, "analysis_depth");
|
||||
// Only 129 nodes here: no hostile unbounded recursive teardown probe.
|
||||
f.project.definitions.remove(&deep);
|
||||
|
||||
let (identity, parameter) = add_identity_calculation(&mut f.project, 7001, scalar_number());
|
||||
let mut mixed = number(1);
|
||||
for level in 1..128 {
|
||||
mixed = match level % 5 {
|
||||
0 => Expr::Unary {
|
||||
op: UnaryOp::Absolute,
|
||||
operand: Box::new(mixed),
|
||||
},
|
||||
1 => binary(BinaryOp::Add, mixed, number(0)),
|
||||
2 => Expr::If {
|
||||
condition: Box::new(Expr::Boolean(true)),
|
||||
when_true: Box::new(mixed),
|
||||
when_false: Box::new(number(0)),
|
||||
},
|
||||
3 => Expr::Call {
|
||||
definition: reference(identity),
|
||||
arguments: BTreeMap::from([(parameter, mixed)]),
|
||||
},
|
||||
_ => Expr::Power {
|
||||
base: Box::new(mixed),
|
||||
exponent: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
let mixed_id = add_calculation(&mut f.project, 7002, scalar_number(), mixed);
|
||||
eprintln!("analysis depth gate: admitting mixed Binary/If/Call/Power/Unary depth 128");
|
||||
admit(&f.project, AdmissionLimits::REFERENCE).unwrap();
|
||||
let Binding::Expression(mixed) = std::mem::replace(
|
||||
&mut calculation_mut(&mut f.project, mixed_id).body,
|
||||
Binding::Unbound,
|
||||
) else {
|
||||
panic!()
|
||||
};
|
||||
calculation_mut(&mut f.project, mixed_id).body = expression(Expr::Unary {
|
||||
op: UnaryOp::Negate,
|
||||
operand: Box::new(mixed),
|
||||
});
|
||||
eprintln!("analysis depth gate: rejecting mixed depth 129");
|
||||
assert_eq!(reject(&f.project).code, "analysis_depth");
|
||||
f.project.definitions.remove(&mixed_id);
|
||||
|
||||
let list_type = AnalysisType::List(ScalarType::Number);
|
||||
let (list_identity, list_parameter) =
|
||||
add_identity_calculation(&mut f.project, 7003, list_type.clone());
|
||||
let mut list = Expr::List(vec![]);
|
||||
for level in 1..128 {
|
||||
list = if level % 2 == 0 {
|
||||
Expr::If {
|
||||
condition: Box::new(Expr::Boolean(false)),
|
||||
when_true: Box::new(list),
|
||||
when_false: Box::new(Expr::List(vec![])),
|
||||
}
|
||||
} else {
|
||||
Expr::Call {
|
||||
definition: reference(list_identity),
|
||||
arguments: BTreeMap::from([(list_parameter, list)]),
|
||||
}
|
||||
};
|
||||
}
|
||||
add_calculation(&mut f.project, 7004, list_type, list);
|
||||
eprintln!("analysis depth gate: admitting contextual empty lists through If/Call depth 128");
|
||||
admit(&f.project, AdmissionLimits::REFERENCE).unwrap();
|
||||
|
||||
let mut previous = add_calculation(&mut f.project, 7100, scalar_number(), number(1));
|
||||
for n in 7101..7132 {
|
||||
previous = add_calculation(
|
||||
@@ -878,6 +998,7 @@ fn logical_depth_and_call_depth_have_exact_boundaries() {
|
||||
},
|
||||
);
|
||||
}
|
||||
eprintln!("analysis depth gate: admitting 32-definition call graph");
|
||||
admit(&f.project, AdmissionLimits::REFERENCE).unwrap();
|
||||
add_calculation(
|
||||
&mut f.project,
|
||||
@@ -888,6 +1009,7 @@ fn logical_depth_and_call_depth_have_exact_boundaries() {
|
||||
arguments: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
eprintln!("analysis depth gate: rejecting 33-definition call graph");
|
||||
assert_eq!(reject(&f.project).code, "analysis_call_depth");
|
||||
}
|
||||
|
||||
@@ -992,6 +1114,36 @@ fn static_work_is_lowerable_deterministic_and_aggregated_across_cases() {
|
||||
.admission_usage();
|
||||
assert_eq!(first, second);
|
||||
assert!(first.static_events > used);
|
||||
let mut described = f.project.clone();
|
||||
described
|
||||
.definitions
|
||||
.get_mut(&f.duration)
|
||||
.unwrap()
|
||||
.description = "x".repeat(1024);
|
||||
assert_eq!(
|
||||
admit(&described, AdmissionLimits::REFERENCE)
|
||||
.unwrap()
|
||||
.admission_usage()
|
||||
.static_events,
|
||||
used + 1024,
|
||||
);
|
||||
// Duration is the first definition in UUID order. The forbidden final byte
|
||||
// must not be inspected before its complete text span has been charged.
|
||||
described
|
||||
.definitions
|
||||
.get_mut(&f.duration)
|
||||
.unwrap()
|
||||
.description = format!("{}\0", "x".repeat(1023));
|
||||
let text_limit = AdmissionLimits {
|
||||
max_static_events: 2 + described.definitions[&f.duration].name.len() as u64 + 1023,
|
||||
};
|
||||
let failure = match admit(&described, text_limit) {
|
||||
Ok(_) => panic!("Text inspection must be precharged"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(failure.code, "analysis_limit");
|
||||
assert_eq!(failure.object, f.duration);
|
||||
assert_eq!(failure.field, "description");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
//! Static expression checking shares numeric operator algebra with execution.
|
||||
use super::admission::Budget;
|
||||
use super::*;
|
||||
use crate::analysis_numeric::{NumericError, NumericType};
|
||||
use crate::analysis_numeric::{NumericError, NumericType, ResolvedKind};
|
||||
use crate::{Id, Layer, SharedBody};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) struct BindingContext<'a> {
|
||||
pub owner: Id,
|
||||
pub field: &'a str,
|
||||
pub layer: Option<Layer>,
|
||||
pub parameters: &'a BTreeMap<ParameterId, ResolvedType>,
|
||||
}
|
||||
|
||||
pub(super) fn binding(
|
||||
environment: &ResolvedAnalysis<'_>,
|
||||
owner: Id,
|
||||
field: &str,
|
||||
context: BindingContext<'_>,
|
||||
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,
|
||||
owner: context.owner,
|
||||
field: context.field,
|
||||
layer: context.layer,
|
||||
parameters: context.parameters,
|
||||
budget,
|
||||
};
|
||||
checker.expression(expression, Some(expected), 1)?;
|
||||
checker.expression(expression, Some(expected))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -37,7 +41,42 @@ struct Checker<'a, 'p, 'b> {
|
||||
parameters: &'a BTreeMap<ParameterId, ResolvedType>,
|
||||
budget: &'b mut Budget,
|
||||
}
|
||||
impl Checker<'_, '_, '_> {
|
||||
|
||||
/// One suspended expression per logical depth. No Expr is owned or cloned by
|
||||
/// a frame, and every stored type/nominal handle has fixed, nonrecursive size.
|
||||
struct Frame<'e, 'p> {
|
||||
expression: &'e Expr,
|
||||
expected: Option<ResolvedType>,
|
||||
state: State<'p>,
|
||||
}
|
||||
|
||||
enum State<'p> {
|
||||
Enter,
|
||||
Unary,
|
||||
BinaryLeft,
|
||||
BinaryRight(ResolvedType),
|
||||
IfCondition,
|
||||
IfTrue,
|
||||
IfFalse(ResolvedType),
|
||||
List {
|
||||
next: usize,
|
||||
},
|
||||
Call {
|
||||
calculation: &'p CalculationDefinition,
|
||||
next: usize,
|
||||
},
|
||||
SumInitial,
|
||||
SumValues(ResolvedType),
|
||||
Power,
|
||||
AsKind(ResolvedKind<KindHandle>),
|
||||
}
|
||||
|
||||
enum Step<'e> {
|
||||
Child(&'e Expr, Option<ResolvedType>),
|
||||
Complete(ResolvedType),
|
||||
}
|
||||
|
||||
impl<'p> Checker<'_, 'p, '_> {
|
||||
fn error(&self, code: &str, message: impl Into<String>) -> AnalysisError {
|
||||
AnalysisError::new(self.owner, self.field, code, message)
|
||||
}
|
||||
@@ -55,21 +94,165 @@ impl Checker<'_, '_, '_> {
|
||||
.map(ResolvedType::numeric)
|
||||
.map_err(|error| self.error("analysis_type", error.to_string()))
|
||||
}
|
||||
|
||||
/// Explicit continuations replace Rust recursion. Width never expands the
|
||||
/// frame stack: lists and calls schedule only their next ordered child.
|
||||
/// At most 128 frames and one returned scalar/list type are retained. The
|
||||
/// per-node/per-argument meter charges remain identical to recursive entry;
|
||||
/// continuation steps do constant bounded work within those charges.
|
||||
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 mut frames = Vec::with_capacity(MAX_EXPRESSION_DEPTH);
|
||||
frames.push(Frame {
|
||||
expression,
|
||||
expected: expected.cloned(),
|
||||
state: State::Enter,
|
||||
});
|
||||
let mut returned = None;
|
||||
loop {
|
||||
let frame = frames.last_mut().expect("active expression frame");
|
||||
match self.advance(frame, returned.take())? {
|
||||
Step::Child(expression, expected) => {
|
||||
if frames.len() == MAX_EXPRESSION_DEPTH {
|
||||
// Preserve entry charging/error precedence without ever
|
||||
// allocating the rejected 129th frame.
|
||||
self.budget.charge(self.owner, self.field, 1)?;
|
||||
return Err(self.error(
|
||||
"analysis_depth",
|
||||
"Expression exceeds the logical depth limit.",
|
||||
));
|
||||
}
|
||||
frames.push(Frame {
|
||||
expression,
|
||||
expected,
|
||||
state: State::Enter,
|
||||
});
|
||||
}
|
||||
Step::Complete(actual) => {
|
||||
let frame = frames.pop().expect("completed expression frame");
|
||||
let expected = frame.expected.as_ref();
|
||||
if expected.is_some_and(|expected| expected != &actual) {
|
||||
return Err(self.error(
|
||||
"analysis_type",
|
||||
format!("Expected {expected:?}; expression has {actual:?}."),
|
||||
));
|
||||
}
|
||||
if frames.is_empty() {
|
||||
return Ok(actual);
|
||||
}
|
||||
returned = Some(actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
let next = depth + 1;
|
||||
let actual = match expression {
|
||||
}
|
||||
|
||||
fn advance<'e>(
|
||||
&mut self,
|
||||
frame: &mut Frame<'e, 'p>,
|
||||
returned: Option<ResolvedType>,
|
||||
) -> Result<Step<'e>, AnalysisError> {
|
||||
let state = std::mem::replace(&mut frame.state, State::Enter);
|
||||
if let State::Enter = state {
|
||||
self.budget.charge(self.owner, self.field, 1)?;
|
||||
return self.enter(frame);
|
||||
}
|
||||
let child = returned.expect("continuation follows a completed child");
|
||||
match state {
|
||||
State::Enter => unreachable!("handled before consuming child"),
|
||||
State::Unary => {
|
||||
let Expr::Unary { op, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
Ok(Step::Complete(match op {
|
||||
UnaryOp::Not => ResolvedType::boolean(),
|
||||
UnaryOp::Negate | UnaryOp::Absolute => {
|
||||
self.operation(self.numeric(child)?.unary_result())?
|
||||
}
|
||||
}))
|
||||
}
|
||||
State::BinaryLeft => {
|
||||
let Expr::Binary { right, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::BinaryRight(child);
|
||||
Ok(Step::Child(right, None))
|
||||
}
|
||||
State::BinaryRight(left) => {
|
||||
let Expr::Binary { op, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
Ok(Step::Complete(self.binary(*op, left, child)?))
|
||||
}
|
||||
State::IfCondition => {
|
||||
let Expr::If { when_true, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::IfTrue;
|
||||
Ok(Step::Child(when_true, frame.expected.clone()))
|
||||
}
|
||||
State::IfTrue => {
|
||||
let Expr::If { when_false, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::IfFalse(child.clone());
|
||||
Ok(Step::Child(when_false, Some(child)))
|
||||
}
|
||||
State::IfFalse(value) => Ok(Step::Complete(value)),
|
||||
State::List { next } => {
|
||||
let ResolvedType::Scalar(scalar) = child else {
|
||||
return Err(self.error(
|
||||
"analysis_list",
|
||||
"Lists are flat; nested lists are not admitted.",
|
||||
));
|
||||
};
|
||||
let Expr::List(items) = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
if let Some(item) = items.get(next) {
|
||||
frame.state = State::List { next: next + 1 };
|
||||
Ok(Step::Child(item, Some(ResolvedType::Scalar(scalar))))
|
||||
} else {
|
||||
Ok(Step::Complete(ResolvedType::List(scalar)))
|
||||
}
|
||||
}
|
||||
State::Call { calculation, next } => self.call_argument(frame, calculation, next),
|
||||
State::SumInitial => {
|
||||
let numeric = self.numeric(child.clone())?;
|
||||
self.operation(numeric.unary_result())?;
|
||||
let ResolvedType::Scalar(scalar) = &child else {
|
||||
unreachable!()
|
||||
};
|
||||
let expected = ResolvedType::List(scalar.clone());
|
||||
let Expr::Sum { values, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::SumValues(child);
|
||||
Ok(Step::Child(values, Some(expected)))
|
||||
}
|
||||
State::SumValues(initial) => {
|
||||
let numeric = self.numeric(initial.clone())?;
|
||||
self.operation(numeric.additive_result(&numeric, false))?;
|
||||
Ok(Step::Complete(initial))
|
||||
}
|
||||
State::Power => {
|
||||
let Expr::Power { exponent, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
Ok(Step::Complete(
|
||||
self.operation(self.numeric(child)?.power_result(*exponent))?,
|
||||
))
|
||||
}
|
||||
State::AsKind(kind) => Ok(Step::Complete(
|
||||
self.operation(self.numeric(child)?.assertion_result(&kind))?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn enter<'e>(&mut self, frame: &mut Frame<'e, 'p>) -> Result<Step<'e>, AnalysisError> {
|
||||
let actual = match frame.expression {
|
||||
Expr::Boolean(_) => ResolvedType::boolean(),
|
||||
Expr::Number(_) => ResolvedType::numeric(NumericType::Number),
|
||||
Expr::Quantity { unit, .. } => {
|
||||
@@ -121,21 +304,13 @@ impl Checker<'_, '_, '_> {
|
||||
self.error("analysis_list", "List exceeds the flat-list length limit.")
|
||||
);
|
||||
}
|
||||
let mut scalar = match expected {
|
||||
let scalar = match &frame.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);
|
||||
if let Some(item) = items.first() {
|
||||
frame.state = State::List { next: 1 };
|
||||
return Ok(Step::Child(item, scalar.map(ResolvedType::Scalar)));
|
||||
}
|
||||
ResolvedType::List(scalar.ok_or_else(|| {
|
||||
self.error(
|
||||
@@ -144,63 +319,18 @@ impl Checker<'_, '_, '_> {
|
||||
)
|
||||
})?)
|
||||
}
|
||||
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::Unary { op, operand } => {
|
||||
frame.state = State::Unary;
|
||||
let expected = (*op == UnaryOp::Not).then(ResolvedType::boolean);
|
||||
return Ok(Step::Child(operand, expected));
|
||||
}
|
||||
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::Binary { left, .. } => {
|
||||
frame.state = State::BinaryLeft;
|
||||
return Ok(Step::Child(left, None));
|
||||
}
|
||||
Expr::If { condition, .. } => {
|
||||
frame.state = State::IfCondition;
|
||||
return Ok(Step::Child(condition, Some(ResolvedType::boolean())));
|
||||
}
|
||||
Expr::Call {
|
||||
definition,
|
||||
@@ -212,44 +342,28 @@ impl Checker<'_, '_, '_> {
|
||||
"Call must reference a calculation definition.",
|
||||
)
|
||||
})?;
|
||||
if arguments.len() != calculation.parameters.len()
|
||||
|| calculation
|
||||
.parameters
|
||||
.iter()
|
||||
.any(|parameter| !arguments.contains_key(¶meter.id))
|
||||
if arguments.len() != calculation.parameters.len() {
|
||||
return Err(self.error("analysis_arguments", "Call arguments must bind every declared parameter exactly once, by its scoped identity."));
|
||||
}
|
||||
// Charge the complete key scan before any parameter lookup.
|
||||
self.budget
|
||||
.charge(self.owner, self.field, calculation.parameters.len() as u64)?;
|
||||
if 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.")
|
||||
})?
|
||||
return self.call_argument(frame, calculation, 0);
|
||||
}
|
||||
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::Sum { initial, .. } => {
|
||||
frame.state = State::SumInitial;
|
||||
return Ok(Step::Child(initial, None));
|
||||
}
|
||||
Expr::Power { base, exponent } => {
|
||||
let base = self.expression(base, None, next)?;
|
||||
self.operation(self.numeric(base)?.power_result(*exponent))?
|
||||
Expr::Power { base, .. } => {
|
||||
frame.state = State::Power;
|
||||
return Ok(Step::Child(base, None));
|
||||
}
|
||||
Expr::AsKind { kind, value } => {
|
||||
let kind = self
|
||||
@@ -262,16 +376,83 @@ impl Checker<'_, '_, '_> {
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
let value = self.expression(value, None, next)?;
|
||||
self.operation(self.numeric(value)?.assertion_result(&kind))?
|
||||
frame.state = State::AsKind(kind);
|
||||
return Ok(Step::Child(value, None));
|
||||
}
|
||||
};
|
||||
if expected.is_some_and(|expected| expected != &actual) {
|
||||
return Err(self.error(
|
||||
"analysis_type",
|
||||
format!("Expected {expected:?}; expression has {actual:?}."),
|
||||
));
|
||||
Ok(Step::Complete(actual))
|
||||
}
|
||||
|
||||
/// Arguments follow parameter declaration order, not UUID map order. Retain
|
||||
/// the resolved calculation by reference and schedule one argument at a time.
|
||||
fn call_argument<'e>(
|
||||
&mut self,
|
||||
frame: &mut Frame<'e, 'p>,
|
||||
calculation: &'p CalculationDefinition,
|
||||
next: usize,
|
||||
) -> Result<Step<'e>, AnalysisError> {
|
||||
if let Some(parameter) = calculation.parameters.get(next) {
|
||||
self.budget.charge(self.owner, self.field, 1)?;
|
||||
let expected = self
|
||||
.environment
|
||||
.declared_type(¶meter.value_type)
|
||||
.ok_or_else(|| self.error("analysis_kind", "Parameter kind does not resolve."))?;
|
||||
let Expr::Call { arguments, .. } = frame.expression else {
|
||||
unreachable!()
|
||||
};
|
||||
frame.state = State::Call {
|
||||
calculation,
|
||||
next: next + 1,
|
||||
};
|
||||
Ok(Step::Child(&arguments[¶meter.id], Some(expected)))
|
||||
} else {
|
||||
let result = self
|
||||
.environment
|
||||
.declared_type(&calculation.result)
|
||||
.ok_or_else(|| {
|
||||
self.error("analysis_kind", "Calculation result kind does not resolve.")
|
||||
})?;
|
||||
Ok(Step::Complete(result))
|
||||
}
|
||||
}
|
||||
|
||||
fn binary(
|
||||
&self,
|
||||
op: BinaryOp,
|
||||
left: ResolvedType,
|
||||
right: ResolvedType,
|
||||
) -> Result<ResolvedType, AnalysisError> {
|
||||
match op {
|
||||
BinaryOp::And | BinaryOp::Or => {
|
||||
if left != ResolvedType::boolean() || right != ResolvedType::boolean() {
|
||||
return Err(self.error("analysis_type", "Boolean operands are required."));
|
||||
}
|
||||
Ok(ResolvedType::boolean())
|
||||
}
|
||||
BinaryOp::Equal | BinaryOp::NotEqual
|
||||
if left == ResolvedType::boolean() && right == ResolvedType::boolean() =>
|
||||
{
|
||||
Ok(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()))?;
|
||||
Ok(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),
|
||||
),
|
||||
}
|
||||
Ok(actual)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,82 @@
|
||||
# Schema8 core WIP reboot handoff
|
||||
# Schema8 core source handoff
|
||||
|
||||
## Current root execution and review — 2026-09-11
|
||||
|
||||
Root completed the corrected required depth128/129 and call32/33 regression
|
||||
(1/1), the complete `analysis::` filter (28/28:25 analysis plus3 topology),
|
||||
numeric26/26, and strict core all-target Clippy with `-D warnings` (exit0,3m37s).
|
||||
The default Windows test stack and required limits were unchanged. Independent
|
||||
`analysis_core_review` found no material new correctness/resource defect in the
|
||||
iterative checker/admission correction; [Gitea342](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-342)
|
||||
records its conclusion. Source hashes match the correction freeze below.
|
||||
|
||||
Root's durable `harness/release-analysis-core-iterative-gate-20260911.md` and
|
||||
`harness/release-analysis-core-iterative-review-20260911.md` record execution and
|
||||
independent scope. Immutable root `.local/analysis-core-iterative-gate-20260911/`
|
||||
contains274-input manifest SHA256
|
||||
`28D6CEAE5C52765F5DB5CB411D7E42EDAB91359C9E351663315BC4B7F1FF2B7A`
|
||||
and corrected test executable SHA256
|
||||
`2B4EE8BA63B20415B352E084314CC7504E778C093598B9564C6EA4F56BC921C9`.
|
||||
Clippy log SHA256:
|
||||
`6FE529BBA6A761FEDACB1A3933BB86E4FB644D8CC192F57FB0F45EFA5B9ABFFF`.
|
||||
|
||||
This supersedes the awaiting-rerun status below, preserving the first actual
|
||||
failure. It does not close full core integration, bounded codec/lifecycle/output,
|
||||
source8, evaluator, UI/REST/CLI parity, libraries or release acceptance. The source
|
||||
remains isolated from root's runnable schema7 application.
|
||||
|
||||
## Historical stack-overflow correction source freeze — 2026-09-11
|
||||
|
||||
Codex-956atf `/root/analysis_core_resume`, renewed debugging lease after root's actual first Windows gate. The same dedicated worktree/branch remains at HEAD `b96ff3935ab3f36b7144566e46559a05a29292a5` plus uncommitted corrections. **The first candidate failed; this replacement is frozen source awaiting root's rerun.** No compiler was run by the worker.
|
||||
|
||||
Root's original `cargo test --locked -p oa-core --lib analysis:: -- --test-threads=1` compiled warning-free in3m34s, discovered28 tests, logged16 named passes, then aborted at `analysis::tests::logical_depth_and_call_depth_have_exact_boundaries` with Windows `STATUS_STACK_OVERFLOW` / `0xc00000fd`. Preserve the original root `.local/logs/analysis-core-first-20260911.log` (SHA256 `84C6B63039472363412B66C7A24DE825A846518725EC9F7436C252603AEF54FC`) and root-reported binary `target/debug/deps/oa_core-a7a95d666500007c.exe` (SHA256 `548850D3606C9B4A58859677E86A1C1813EB6E71785C529F771E04125E7A33AF`). Gitea issue1 comment305 records the actual failure. Neither compile success nor the16 earlier passes establish a full gate pass.
|
||||
|
||||
The test-count discrepancy is resolved: Rust's substring filter `analysis::` selects25 schema8 tests **plus three existing `topology_context::analysis::tests`**, declared through `topology_context/analysis.rs` into `analysis_tests.rs`. The earlier prepared25 was the schema8-module count, not the full filter count; the saved22 would likewise have selected25 with those topology tests. The current corrected filter still expects28, with no ignored/removed or additional test functions.
|
||||
|
||||
The large recursive `Checker::expression` was the concrete source-level suspect; no profiler/frame-size measurement established the exact abort instruction. It is now an explicit continuation machine with at most128 heap frames and one returned type. Each frame borrows its Expr and any calculation definition and owns only fixed-size resolved types/handles. Lists and calls schedule one ordered child at a time. The rejected129th frame is never allocated, while its entry event is still charged to preserve failure precedence. Rust call depth no longer scales with Expr depth, and typechecker-frame destruction does not destroy the borrowed Expr tree.
|
||||
|
||||
The continuation states retain left-before-right operands, condition-before-both-branches, Sum initial-before-values, AsKind target resolution-before-operand, declaration-order call arguments, contextual empty-list typing, branch type equality and existing node/key/argument meter precharges. No numeric operator semantics, fixed KindHandle, lowerable1,000,000-event ceiling, accepted128-depth bound or stored schema was changed. No stack-size override, release-only workaround or ignored test was introduced.
|
||||
|
||||
The original failing test now includes diagnostic phase markers and unary128/129, mixed Binary/If/Call/Power/Unary128/129, contextual empty-list If/Call128 and definition-call32/33 boundaries. The existing parameter-purity test now makes call declaration order versus UUID order and binary left-to-right error precedence observable through distinct invalid-reference errors. The already compiled meter tests retain their exact event expectations.
|
||||
|
||||
| Path | SHA256 at correction freeze |
|
||||
|---|---|
|
||||
| `crates/oa-core/src/analysis/typecheck.rs` | `831F14346BBAE83FBDEEB744F9F29460FAB13A8315645FAA6CE94D0B60B1516F` |
|
||||
| `crates/oa-core/src/analysis/tests.rs` | `9FB30B28E305CB00E7C39204912F8311E96AA92D0334CE4A73FB08FADDFE83C5` |
|
||||
| `crates/oa-core/src/analysis/admission.rs` (unchanged since first candidate) | `5F85EAA868AC43C4819C98556281D8226F9C0E2A4B11E5C97CF24D855D2127AE` |
|
||||
|
||||
Standalone installed rustfmt2024 check and `git diff --check` pass for the correction. Root's next commands from this worktree, using its established toolchain/cache:
|
||||
|
||||
1. `cargo test --locked -p oa-core --lib analysis::tests::logical_depth_and_call_depth_have_exact_boundaries -- --exact --test-threads=1 --nocapture`
|
||||
2. `cargo test --locked -p oa-core --lib analysis:: -- --test-threads=1` (28 expected).
|
||||
3. `cargo test --locked -p oa-core --lib analysis_numeric::tests -- --test-threads=1` (26 expected).
|
||||
4. `cargo clippy --locked -p oa-core --all-targets -- -D warnings`.
|
||||
|
||||
Admission's structural Expr/reference walks and dependency/case graphs are already iterative. Inspected architectural index construction borrows analysis records and clones only ordinary step-ID vectors; its analysis-reference branch avoids cloning Expr-bearing elements. This identifies no further recursive Expr traversal in the immediate admission path, but is not execution or stack/RSS evidence. Derived Expr Clone/Debug/equality/Serialize/Deserialize/Drop, partial decode teardown, command cloning, output expansion and full codec/lifecycle/source parity remain the separately documented production risks. Default Serde recursion rejection remains unchanged. Root owns execution, follow-up findings, integration and release publication; there is no complete schema8 transport or release claim.
|
||||
|
||||
## Historical first resumed source freeze — 2026-09-11
|
||||
|
||||
Codex-956atf `/root/analysis_core_resume`, resumed by root after the user returned. **Source frozen for root's first compilation gate; still uncompiled staging.** The dedicated worktree remains `C:/Users/User/Documents/Git/ARCADIA-Studio/.local/analysis-core-worktree-20260911`, branch `codex/analysis-core-20260911`, HEAD `b96ff3935ab3f36b7144566e46559a05a29292a5`. No commit was made by this worker. The reboot record below is retained as historical evidence; this section supersedes its stopped/pending-static-review status only.
|
||||
|
||||
The resumed delta changes three Rust files plus this report:
|
||||
|
||||
| Path | SHA256 at resumed freeze |
|
||||
|---|---|
|
||||
| `crates/oa-core/src/analysis/admission.rs` | `5F85EAA868AC43C4819C98556281D8226F9C0E2A4B11E5C97CF24D855D2127AE` |
|
||||
| `crates/oa-core/src/analysis/typecheck.rs` | `1AC3A92E97B886F4BFAC31BA22C182347131D0A82BEDBF6B2F2073D02AD5A6E4` |
|
||||
| `crates/oa-core/src/analysis/tests.rs` | `9EC9C12C618600039BC2DC0A9B9BF6DC5378FD4D8749E21EDEC699EEE06AB0E2` |
|
||||
|
||||
- `admission.rs` precharges expression child slots before queue growth, rejects visited-plus-pending closure overflow before allocation, meters initial closure queue entries, text spans and scoped parameter names, and charges each relation-subject traversal hop. Known indexed element layers use direct map lookup. This closes analysis-specific unmetered work; it does not bound the existing architectural validator, wire decoding or recursive lifecycle operations.
|
||||
- `typecheck.rs` groups owner/field/layer/parameters in `BindingContext`, removing the eight-argument helper. Call arity remains an O(1) check; the complete scoped-key scan is precharged before lookups, then declaration-order argument lookup/type work is separately charged. Actual parameter identity and all-branch checking are preserved.
|
||||
- Three new meter tests exercise 1,024-wide List/Call precharge, a 64-parameter malformed/valid Call at exact work boundaries, and 64 relation hops with terminal element/missing/cyclic endpoints. The existing real-Project aggregate-limit test also checks text-byte accounting and exhaustion before invalid text inspection. These relation-chain fixtures deliberately test malformed input before architectural admission, not newly accepted architectural relations.
|
||||
|
||||
**Expected focused count is now25: the saved22 plus three new meter tests (21 actual-Project tests and four meter tests total).** Command from this worktree: `cargo test --locked -p oa-core --lib analysis:: -- --test-threads=1`. Root owns the toolchain/cache configuration and all execution. Follow with the changed numeric-helper regression filter `analysis_numeric::tests`, then strict core Clippy; the earlier26 numeric passes apply to7d14bd0, not this schema8 candidate.
|
||||
|
||||
Read-only review covered existing persistent-enum/Project constructors, command/reference call sites and the analysis lifetime/borrow paths. Standalone installed `rustfmt --edition 2024 --check` passes for all three changed Rust files; `git diff --check` is clean. **No Cargo/rustc, test, Clippy, native, Linux or memory/stack measurement was run by this worker.** No root-checkout files, manifests, lockfiles, ledgers, fixed KindHandle, numeric semantics or other worktree-owned source changed during resume.
|
||||
|
||||
The lowerable aggregate1,000,000 static-event ceiling, nominal/exact arithmetic, baseline/case/unused-branch checks and scoped parameters remain settled. Default serde_json recursion rejection stays enabled. Schema8 codec/allocation/output/lifecycle closure, source8, evaluator, package environment, capture, API/CLI/UI and historical/transition parity remain pending; this is not a runnable release or complete product transport. Gitea issue1 comment291 records the resume lease; root owns integration/publication and any subsequent compiler-driven corrections.
|
||||
|
||||
## Historical reboot freeze
|
||||
|
||||
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.**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user