///|
/// Severity used by the cross-layer model quality audit.
pub(all) enum QualityLevel {
Pass
Notice
Warning
Error
} derive(Debug, Eq)
///|
/// A concrete, actionable quality finding.
pub(all) struct QualityFinding {
code : String
level : QualityLevel
message : String
entity_id : Int?
measured : Double
threshold : Double
repair_hint : String
} derive(Debug)
///|
/// Thresholds for the model quality audit.
pub(all) struct QualityPolicy {
maximum_orphans : Int
minimum_named_product_ratio : Double
minimum_guid_ratio : Double
maximum_duplicate_ids : Int
maximum_unknown_entity_ratio : Double
require_units : Bool
require_spatial_structure : Bool
require_geometry_for_products : Bool
} derive(Debug)
///|
/// A scorecard combining structural, semantic, unit and geometry signals.
pub(all) struct ModelQualityReport {
findings : Array[QualityFinding]
score : Int
entity_count : Int
product_count : Int
orphan_count : Int
duplicate_id_count : Int
named_product_ratio : Double
guid_ratio : Double
unknown_entity_ratio : Double
} derive(Debug)
///|
pub fn default_quality_policy() -> QualityPolicy {
{
maximum_orphans: 0,
minimum_named_product_ratio: 0.50,
minimum_guid_ratio: 0.80,
maximum_duplicate_ids: 0,
maximum_unknown_entity_ratio: 0.45,
require_units: false,
require_spatial_structure: false,
require_geometry_for_products: false,
}
}
///|
pub fn QualityPolicy::allow_orphans(
self : QualityPolicy,
maximum_orphans : Int,
) -> QualityPolicy {
{
..self,
maximum_orphans: if maximum_orphans < 0 {
0
} else {
maximum_orphans
},
}
}
///|
pub fn QualityPolicy::with_name_ratio(
self : QualityPolicy,
minimum_named_product_ratio : Double,
) -> QualityPolicy {
{
..self,
minimum_named_product_ratio: if minimum_named_product_ratio < 0.0 {
0.0
} else if minimum_named_product_ratio > 1.0 {
1.0
} else {
minimum_named_product_ratio
},
}
}
///|
pub fn QualityPolicy::with_guid_ratio(
self : QualityPolicy,
minimum_guid_ratio : Double,
) -> QualityPolicy {
{
..self,
minimum_guid_ratio: if minimum_guid_ratio < 0.0 {
0.0
} else if minimum_guid_ratio > 1.0 {
1.0
} else {
minimum_guid_ratio
},
}
}
///|
pub fn QualityPolicy::with_unknown_ratio(
self : QualityPolicy,
maximum_unknown_entity_ratio : Double,
) -> QualityPolicy {
{
..self,
maximum_unknown_entity_ratio: if maximum_unknown_entity_ratio < 0.0 {
0.0
} else if maximum_unknown_entity_ratio > 1.0 {
1.0
} else {
maximum_unknown_entity_ratio
},
}
}
///|
pub fn QualityPolicy::requiring_units(
self : QualityPolicy,
required : Bool,
) -> QualityPolicy {
{ ..self, require_units: required }
}
///|
pub fn QualityPolicy::requiring_spatial_structure(
self : QualityPolicy,
required : Bool,
) -> QualityPolicy {
{ ..self, require_spatial_structure: required }
}
///|
pub fn QualityPolicy::requiring_geometry(
self : QualityPolicy,
required : Bool,
) -> QualityPolicy {
{ ..self, require_geometry_for_products: required }
}
///|
fn quality_level_name(level : QualityLevel) -> String {
match level {
Pass => "pass"
Notice => "notice"
Warning => "warning"
Error => "error"
}
}
///|
fn quality_schema_name(schema : SchemaVersion) -> String {
match schema {
IFC2X3 => "IFC2X3"
IFC4 => "IFC4"
IFC4X3 => "IFC4X3"
UnknownSchema(name) => name
}
}
///|
fn quality_score_delta(level : QualityLevel) -> Int {
match level {
Pass => 0
Notice => 2
Warning => 8
Error => 20
}
}
///|
fn quality_finding(
code : String,
level : QualityLevel,
message : String,
entity_id : Int?,
measured : Double,
threshold : Double,
repair_hint : String,
) -> QualityFinding {
{ code, level, message, entity_id, measured, threshold, repair_hint }
}
///|
fn quality_add(
findings : Array[QualityFinding],
finding : QualityFinding,
) -> Unit {
findings.push(finding)
}
///|
fn quality_duplicate_id_count(model : Model) -> Int {
let counts : Map[Int, Int] = Map([])
for entity in model.entities {
counts.update_or_default(entity.id, 1, count => count + 1)
}
let mut duplicates = 0
for _, count in counts {
if count > 1 {
duplicates += count - 1
}
}
duplicates
}
///|
fn quality_named_ratio(products : Array[Product]) -> Double {
if products.length() == 0 {
1.0
} else {
products
.fold(init=0, (count, product) => {
match product.name {
Some(name) if name.trim().length() > 0 => count + 1
_ => count
}
})
.to_double() /
products.length().to_double()
}
}
///|
fn quality_guid_ratio(products : Array[Product]) -> Double {
if products.length() == 0 {
1.0
} else {
products
.fold(init=0, (count, product) => {
match product.global_id {
Some(guid) if validate_ifc_guid(guid).valid => count + 1
_ => count
}
})
.to_double() /
products.length().to_double()
}
}
///|
fn quality_unknown_ratio(profile : ModelProfile) -> Double {
if profile.entity_count == 0 {
0.0
} else {
profile.unknown_entity_count.to_double() / profile.entity_count.to_double()
}
}
///|
fn quality_has_spatial(index : ModelIndex) -> Bool {
index.ids_named("IFCPROJECT").length() > 0 &&
(
index.ids_named("IFCSITE").length() > 0 ||
index.ids_named("IFCBUILDING").length() > 0 ||
index.ids_named("IFCBUILDINGSTOREY").length() > 0
)
}
///|
fn quality_has_geometry(index : ModelIndex, product : Product) -> Bool {
geometries_for_product(index, product).any(geometry => {
geometry.points.length() > 0 ||
geometry.triangle_count > 0 ||
geometry.depth is Some(_) ||
geometry.profile_width is Some(_)
})
}
///|
fn quality_add_ratio_finding(
findings : Array[QualityFinding],
code : String,
label : String,
ratio : Double,
minimum : Double,
hint : String,
) -> Unit {
if ratio < minimum {
quality_add(
findings,
quality_finding(
code,
Warning,
label +
" ratio " +
ratio.to_string() +
" is below " +
minimum.to_string(),
None,
ratio,
minimum,
hint,
),
)
} else {
quality_add(
findings,
quality_finding(
code,
Pass,
label + " ratio meets the configured threshold",
None,
ratio,
minimum,
"no action required",
),
)
}
}
///|
fn quality_check_entities(
model : Model,
policy : QualityPolicy,
findings : Array[QualityFinding],
) -> Int {
let duplicate_count = quality_duplicate_id_count(model)
if duplicate_count > policy.maximum_duplicate_ids {
quality_add(
findings,
quality_finding(
"STRUCT-DUPLICATE-ID",
Error,
"duplicate STEP entity ids detected: " + duplicate_count.to_string(),
None,
duplicate_count.to_double(),
policy.maximum_duplicate_ids.to_double(),
"deduplicate ids before indexing or exporting the model",
),
)
} else {
quality_add(
findings,
quality_finding(
"STRUCT-UNIQUE-ID",
Pass,
"all STEP entity ids are unique",
None,
duplicate_count.to_double(),
policy.maximum_duplicate_ids.to_double(),
"no action required",
),
)
}
let empty_names = model.entities.fold(init=0, (count, entity) => {
if entity.name.trim().length() == 0 {
count + 1
} else {
count
}
})
if empty_names > 0 {
quality_add(
findings,
quality_finding(
"STRUCT-EMPTY-TYPE",
Error,
"entities with empty type names: " + empty_names.to_string(),
None,
empty_names.to_double(),
0.0,
"preserve the original entity type when constructing custom entities",
),
)
} else {
quality_add(
findings,
quality_finding(
"STRUCT-TYPE-NAMES",
Pass,
"all entities have a type name",
None,
0.0,
0.0,
"no action required",
),
)
}
duplicate_count
}
///|
fn quality_check_references(
model : Model,
policy : QualityPolicy,
findings : Array[QualityFinding],
) -> Int {
let orphan_ids = model_orphan_reference_ids(model)
let orphan_count = orphan_ids.length()
if orphan_count > policy.maximum_orphans {
quality_add(
findings,
quality_finding(
"REF-ORPHAN",
Warning,
"references target missing entities: " + orphan_count.to_string(),
None,
orphan_count.to_double(),
policy.maximum_orphans.to_double(),
"check deleted entities and preserve referenced definitions in the export",
),
)
} else {
quality_add(
findings,
quality_finding(
"REF-CLOSED",
Pass,
"reference graph is closed",
None,
orphan_count.to_double(),
policy.maximum_orphans.to_double(),
"no action required",
),
)
}
orphan_count
}
///|
fn quality_check_schema(
model : Model,
findings : Array[QualityFinding],
) -> Unit {
let schema = schema_version(model)
match schema {
IFC2X3 | IFC4 | IFC4X3 =>
quality_add(
findings,
quality_finding(
"SCHEMA-SUPPORTED",
Pass,
"recognized IFC schema " + quality_schema_name(schema),
None,
1.0,
1.0,
"no action required",
),
)
UnknownSchema(name) =>
quality_add(
findings,
quality_finding(
"SCHEMA-UNKNOWN",
Notice,
"schema identifier is not in the supported registry: " + name,
None,
0.0,
1.0,
"keep the header schema and select an explicit compatibility profile",
),
)
}
}
///|
fn quality_check_spatial(
index : ModelIndex,
policy : QualityPolicy,
findings : Array[QualityFinding],
) -> Unit {
let present = quality_has_spatial(index)
if policy.require_spatial_structure && !present {
quality_add(
findings,
quality_finding(
"SPATIAL-MISSING",
Error,
"required project/site/building spatial structure is missing",
None,
0.0,
1.0,
"provide an IfcProject rooted spatial hierarchy",
),
)
} else if present {
quality_add(
findings,
quality_finding(
"SPATIAL-PRESENT",
Pass,
"project spatial structure is present",
None,
1.0,
if policy.require_spatial_structure {
1.0
} else {
0.0
},
"no action required",
),
)
} else {
quality_add(
findings,
quality_finding(
"SPATIAL-OPTIONAL",
Notice,
"spatial hierarchy is absent but not required by policy",
None,
0.0,
0.0,
"add spatial structure when the model is intended for coordination",
),
)
}
}
///|
fn quality_check_units(
model : Model,
policy : QualityPolicy,
findings : Array[QualityFinding],
) -> Unit {
let report = unit_audit(model)
if policy.require_units && report.row_count() == 0 {
quality_add(
findings,
quality_finding(
"UNIT-MISSING",
Error,
"no IFC unit declarations found",
None,
0.0,
1.0,
"declare IfcUnitAssignment and preserve measurement semantics",
),
)
} else if report.is_consistent() {
quality_add(
findings,
quality_finding(
"UNIT-CONSISTENT",
Pass,
report.headline(),
None,
report.row_count().to_double(),
0.0,
"no action required",
),
)
} else {
quality_add(
findings,
quality_finding(
"UNIT-INCONSISTENT",
Warning,
report.headline(),
None,
report.unknown_count.to_double() +
report.duplicate_kind_count.to_double(),
0.0,
"normalize unit aliases and retain one declaration per dimensional kind",
),
)
}
}
///|
fn quality_check_products(
model : Model,
policy : QualityPolicy,
findings : Array[QualityFinding],
) -> (Double, Double) {
let products = model.products()
let named_ratio = quality_named_ratio(products)
let guid_ratio = quality_guid_ratio(products)
quality_add_ratio_finding(
findings,
"PRODUCT-NAMES",
"named product",
named_ratio,
policy.minimum_named_product_ratio,
"populate IfcRoot.Name for coordination and schedule exports",
)
quality_add_ratio_finding(
findings,
"PRODUCT-GUIDS",
"valid global id",
guid_ratio,
policy.minimum_guid_ratio,
"preserve valid 22-character IFC compressed GUIDs",
)
(named_ratio, guid_ratio)
}
///|
fn quality_check_geometry(
model : Model,
policy : QualityPolicy,
findings : Array[QualityFinding],
) -> Unit raise ParseError {
let index = model.index()
let products = model.products()
let represented = products.fold(init=0, (count, product) => {
if quality_has_geometry(index, product) {
count + 1
} else {
count
}
})
let ratio = if products.length() == 0 {
1.0
} else {
represented.to_double() / products.length().to_double()
}
if policy.require_geometry_for_products && represented < products.length() {
quality_add(
findings,
quality_finding(
"GEOMETRY-INCOMPLETE",
Warning,
"products with usable geometry: " +
represented.to_string() +
"/" +
products.length().to_string(),
None,
ratio,
1.0,
"check representation links and retain supported solid or mesh entities",
),
)
} else {
quality_add(
findings,
quality_finding(
"GEOMETRY-COVERAGE",
if ratio == 1.0 {
Pass
} else {
Notice
},
"geometry coverage ratio: " + ratio.to_string(),
None,
ratio,
if policy.require_geometry_for_products {
1.0
} else {
0.0
},
"inspect geometry inventory for products without representation",
),
)
}
ignore(geometry_inventory(model))
}
///|
/// Run the complete quality audit with deterministic finding order.
pub fn model_quality_audit(
model : Model,
policy? : QualityPolicy = default_quality_policy(),
) -> ModelQualityReport raise ParseError {
let findings : Array[QualityFinding] = []
quality_check_schema(model, findings)
let duplicate_count = quality_check_entities(model, policy, findings)
let orphan_count = quality_check_references(model, policy, findings)
let index = model.index()
quality_check_spatial(index, policy, findings)
quality_check_units(model, policy, findings)
let (named_ratio, guid_ratio) = quality_check_products(
model, policy, findings,
)
quality_check_geometry(model, policy, findings)
let profile = model_profile(model)
let unknown_ratio = quality_unknown_ratio(profile)
quality_add_ratio_finding(
findings,
"SCHEMA-KNOWN",
"known entity",
1.0 - unknown_ratio,
1.0 - policy.maximum_unknown_entity_ratio,
"register or preserve extension entities before schema-specific processing",
)
let penalty = findings.fold(init=0, (sum, finding) => {
sum + quality_score_delta(finding.level)
})
let score = if penalty >= 100 { 0 } else { 100 - penalty }
{
findings,
score,
entity_count: model.entities.length(),
product_count: model.products().length(),
orphan_count,
duplicate_id_count: duplicate_count,
named_product_ratio: named_ratio,
guid_ratio,
unknown_entity_ratio: unknown_ratio,
}
}
///|
pub fn ModelQualityReport::is_healthy(self : ModelQualityReport) -> Bool {
self.findings.all(finding => {
finding.level != Error && finding.level != Warning
})
}
///|
pub fn ModelQualityReport::has_errors(self : ModelQualityReport) -> Bool {
self.findings.any(finding => finding.level == Error)
}
///|
pub fn ModelQualityReport::finding_count(self : ModelQualityReport) -> Int {
self.findings.length()
}
///|
pub fn ModelQualityReport::count_level(
self : ModelQualityReport,
level : QualityLevel,
) -> Int {
self.findings.fold(init=0, (count, finding) => {
if finding.level == level {
count + 1
} else {
count
}
})
}
///|
pub fn ModelQualityReport::findings_at(
self : ModelQualityReport,
level : QualityLevel,
) -> Array[QualityFinding] {
self.findings.filter(finding => finding.level == level)
}
///|
pub fn ModelQualityReport::finding_code(
self : ModelQualityReport,
code : String,
) -> QualityFinding? {
for finding in self.findings {
if finding.code == code {
return Some(finding)
}
}
None
}
///|
pub fn QualityFinding::headline(self : QualityFinding) -> String {
"[" + quality_level_name(self.level) + "] " + self.code + ": " + self.message
}
///|
pub fn QualityFinding::to_json(self : QualityFinding) -> String {
let id = match self.entity_id {
Some(value) => value.to_string()
None => "null"
}
"{\"code\":\"" +
self.code +
"\",\"level\":\"" +
quality_level_name(self.level) +
"\",\"message\":\"" +
self.message +
"\",\"entity_id\":" +
id +
",\"measured\":" +
self.measured.to_string() +
",\"threshold\":" +
self.threshold.to_string() +
",\"repair_hint\":\"" +
self.repair_hint +
"\"}"
}
///|
pub fn ModelQualityReport::headline(self : ModelQualityReport) -> String {
"score=" +
self.score.to_string() +
" entities=" +
self.entity_count.to_string() +
" products=" +
self.product_count.to_string() +
" findings=" +
self.finding_count().to_string() +
" errors=" +
self.count_level(Error).to_string() +
" warnings=" +
self.count_level(Warning).to_string()
}
///|
pub fn ModelQualityReport::to_json(self : ModelQualityReport) -> String {
"{\"score\":" +
self.score.to_string() +
",\"entity_count\":" +
self.entity_count.to_string() +
",\"product_count\":" +
self.product_count.to_string() +
",\"orphan_count\":" +
self.orphan_count.to_string() +
",\"duplicate_id_count\":" +
self.duplicate_id_count.to_string() +
",\"named_product_ratio\":" +
self.named_product_ratio.to_string() +
",\"guid_ratio\":" +
self.guid_ratio.to_string() +
",\"unknown_entity_ratio\":" +
self.unknown_entity_ratio.to_string() +
",\"findings\":[" +
self.findings.map(finding => finding.to_json()).join(",") +
"]}"
}
///|
pub fn ModelQualityReport::to_markdown(self : ModelQualityReport) -> String {
let builder = StringBuilder()
builder <+ "# Model quality audit\n\n"
builder <+ "- \{self.headline()}\n"
builder <+ "- Named product ratio: \{self.named_product_ratio}\n"
builder <+ "- Valid GUID ratio: \{self.guid_ratio}\n"
builder <+ "- Unknown entity ratio: \{self.unknown_entity_ratio}\n\n"
builder <+ "| Level | Code | Finding | Repair hint |\n"
builder <+ "| --- | --- | --- | --- |\n"
for finding in self.findings {
builder <+
"| \{quality_level_name(finding.level)} | \{finding.code} | \{finding.message} | \{finding.repair_hint} |\n"
}
builder.to_string()
}
///|
pub fn model_quality_summary(model : Model) -> Array[String] raise ParseError {
let report = model_quality_audit(model)
[
report.headline(),
"healthy=" + report.is_healthy().to_string(),
"named_ratio=" + report.named_product_ratio.to_string(),
"guid_ratio=" + report.guid_ratio.to_string(),
]
}
///|
pub fn model_quality_errors(
model : Model,
) -> Array[QualityFinding] raise ParseError {
model_quality_audit(model).findings_at(Error)
}
///|
pub fn model_quality_warnings(
model : Model,
) -> Array[QualityFinding] raise ParseError {
model_quality_audit(model).findings_at(Warning)
}