fix(analysis): bound type-checker continuations and admission work

This commit is contained in:
Codex
2026-09-11 08:09:03 -07:00
parent b96ff3935a
commit cdebb49d89
4 changed files with 843 additions and 175 deletions
+302 -45
View File
@@ -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(&current) {
return Ok(Some(element.layer()));
}
let Some(relation) = project.relations.get(&current) 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(&parameter.name) || !names.insert(&parameter.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: &parameters,
},
&calculation.body,
&expected,
None,
&parameters,
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);
}
}