use core::fmt;
use core::num::NonZeroU32;
use std::sync::Arc;
use crate::error::SchemaError;
use crate::ids::{CategoryDomainId, VariableId};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum ScalarType {
Float64,
Float32,
Int64,
Int32,
}
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub enum ValueType {
Continuous,
Count,
Binary,
Categorical,
Ordinal,
Vector {
width: NonZeroU32,
element: ScalarType,
},
}
impl ValueType {
#[must_use]
pub const fn requires_category_domain(&self) -> bool {
matches!(self, Self::Categorical | Self::Ordinal)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
#[repr(u16)]
pub enum RoleHint {
TreatmentCandidate = 1 << 0,
OutcomeCandidate = 1 << 1,
InstrumentCandidate = 1 << 2,
Context = 1 << 3,
Selection = 1 << 4,
UnitId = 1 << 5,
Time = 1 << 6,
Environment = 1 << 7,
Regime = 1 << 8,
}
#[derive(Clone, Copy, Default, Eq, PartialEq, Hash)]
pub struct SmallRoleSet(u16);
impl SmallRoleSet {
#[must_use]
pub const fn empty() -> Self {
Self(0)
}
#[must_use]
pub const fn from_hint(hint: RoleHint) -> Self {
Self(hint as u16)
}
#[must_use]
pub fn from_hints(hints: impl IntoIterator<Item = RoleHint>) -> Self {
let mut set = Self::empty();
for hint in hints {
set.insert(hint);
}
set
}
pub fn insert(&mut self, hint: RoleHint) {
self.0 |= hint as u16;
}
pub fn remove(&mut self, hint: RoleHint) {
self.0 &= !(hint as u16);
}
#[must_use]
pub const fn contains(self, hint: RoleHint) -> bool {
self.0 & (hint as u16) != 0
}
#[must_use]
pub const fn is_empty(self) -> bool {
self.0 == 0
}
#[must_use]
pub const fn bits(self) -> u16 {
self.0
}
#[must_use]
pub const fn from_bits_truncate(bits: u16) -> Self {
const KNOWN: u16 = (1 << 9) - 1;
Self(bits & KNOWN)
}
pub fn iter(self) -> impl Iterator<Item = RoleHint> {
const ALL: [RoleHint; 9] = [
RoleHint::TreatmentCandidate,
RoleHint::OutcomeCandidate,
RoleHint::InstrumentCandidate,
RoleHint::Context,
RoleHint::Selection,
RoleHint::UnitId,
RoleHint::Time,
RoleHint::Environment,
RoleHint::Regime,
];
ALL.into_iter().filter(move |h| self.contains(*h))
}
}
impl fmt::Debug for SmallRoleSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_set().entries(self.iter()).finish()
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MeasurementSpec {
pub description: Option<Arc<str>>,
pub noisy: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VariableSchema {
pub id: VariableId,
pub name: Arc<str>,
pub value_type: ValueType,
pub role_hints: SmallRoleSet,
pub unit: Option<Arc<str>>,
pub category_domain: Option<CategoryDomainId>,
pub measurement: MeasurementSpec,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CausalSchema {
variables: Arc<[VariableSchema]>,
}
impl CausalSchema {
#[must_use]
pub fn len(&self) -> usize {
self.variables.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.variables.is_empty()
}
#[must_use]
pub fn variables(&self) -> &[VariableSchema] {
&self.variables
}
pub fn get(&self, id: VariableId) -> Result<&VariableSchema, SchemaError> {
self.variables.get(id.as_usize()).ok_or(SchemaError::UnknownVariableId { id: id.raw() })
}
pub fn id_of(&self, name: &str) -> Result<VariableId, SchemaError> {
self.variables
.iter()
.find(|v| &*v.name == name)
.map(|v| v.id)
.ok_or_else(|| SchemaError::UnknownVariableName { name: name.to_owned() })
}
pub fn by_name(&self, name: &str) -> Result<&VariableSchema, SchemaError> {
let id = self.id_of(name)?;
self.get(id)
}
}
#[derive(Debug, Default)]
pub struct CausalSchemaBuilder {
pending: Vec<PendingVariable>,
deferred: Option<SchemaError>,
}
#[derive(Debug)]
struct PendingVariable {
name: Arc<str>,
value_type: ValueType,
role_hints: SmallRoleSet,
unit: Option<Arc<str>>,
category_domain: Option<CategoryDomainId>,
measurement: MeasurementSpec,
}
impl CausalSchemaBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn continuous(self, name: impl Into<Arc<str>>) -> VariableInProgress {
self.begin(name, ValueType::Continuous, None)
}
#[must_use]
pub fn binary(self, name: impl Into<Arc<str>>) -> VariableInProgress {
self.begin(name, ValueType::Binary, None)
}
#[must_use]
pub fn count(self, name: impl Into<Arc<str>>) -> VariableInProgress {
self.begin(name, ValueType::Count, None)
}
fn begin(
self,
name: impl Into<Arc<str>>,
value_type: ValueType,
category_domain: Option<CategoryDomainId>,
) -> VariableInProgress {
VariableInProgress {
builder: self,
pending: PendingVariable {
name: name.into(),
value_type,
role_hints: SmallRoleSet::empty(),
unit: None,
category_domain,
measurement: MeasurementSpec::default(),
},
}
}
pub fn add_variable(
&mut self,
name: impl Into<Arc<str>>,
value_type: ValueType,
role_hints: SmallRoleSet,
unit: Option<Arc<str>>,
category_domain: Option<CategoryDomainId>,
measurement: MeasurementSpec,
) -> Result<(), SchemaError> {
let name = name.into();
if self.pending.iter().any(|p| p.name == name) {
return Err(SchemaError::DuplicateVariableName { name: name.to_string() });
}
validate_domain_consistency(&name, &value_type, category_domain)?;
self.pending.push(PendingVariable {
name,
value_type,
role_hints,
unit,
category_domain,
measurement,
});
Ok(())
}
fn push_pending(&mut self, pending: PendingVariable) {
if self.deferred.is_some() {
return;
}
if self.pending.iter().any(|p| p.name == pending.name) {
self.deferred =
Some(SchemaError::DuplicateVariableName { name: pending.name.to_string() });
return;
}
if let Err(e) =
validate_domain_consistency(&pending.name, &pending.value_type, pending.category_domain)
{
self.deferred = Some(e);
return;
}
self.pending.push(pending);
}
pub fn build(self) -> Result<CausalSchema, SchemaError> {
if let Some(err) = self.deferred {
return Err(err);
}
let n_u32 = u32::try_from(self.pending.len()).map_err(|_| SchemaError::TooManyVariables)?;
let variables: Arc<[VariableSchema]> = self
.pending
.into_iter()
.zip(0..n_u32)
.map(|(p, i)| VariableSchema {
id: VariableId::from_raw(i),
name: p.name,
value_type: p.value_type,
role_hints: p.role_hints,
unit: p.unit,
category_domain: p.category_domain,
measurement: p.measurement,
})
.collect();
Ok(CausalSchema { variables })
}
}
#[derive(Debug)]
pub struct VariableInProgress {
builder: CausalSchemaBuilder,
pending: PendingVariable,
}
impl VariableInProgress {
#[must_use]
pub fn treatment(mut self) -> CausalSchemaBuilder {
self.pending.role_hints.insert(RoleHint::TreatmentCandidate);
self.finish()
}
#[must_use]
pub fn outcome(mut self) -> CausalSchemaBuilder {
self.pending.role_hints.insert(RoleHint::OutcomeCandidate);
self.finish()
}
#[must_use]
pub fn context(mut self) -> CausalSchemaBuilder {
self.pending.role_hints.insert(RoleHint::Context);
self.finish()
}
#[must_use]
pub fn instrument(mut self) -> CausalSchemaBuilder {
self.pending.role_hints.insert(RoleHint::InstrumentCandidate);
self.finish()
}
#[must_use]
pub fn unit(mut self, unit: impl Into<Arc<str>>) -> Self {
self.pending.unit = Some(unit.into());
self
}
#[must_use]
pub fn finish(self) -> CausalSchemaBuilder {
let mut builder = self.builder;
builder.push_pending(self.pending);
builder
}
pub fn build(self) -> Result<CausalSchema, SchemaError> {
self.finish().build()
}
}
fn validate_domain_consistency(
name: &str,
value_type: &ValueType,
category_domain: Option<CategoryDomainId>,
) -> Result<(), SchemaError> {
match (value_type.requires_category_domain(), category_domain) {
(true, None) => Err(SchemaError::MissingCategoryDomain { name: name.to_owned() }),
(false, Some(_)) => Err(SchemaError::UnexpectedCategoryDomain { name: name.to_owned() }),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use core::mem::size_of;
#[test]
fn role_set_is_bitmask_not_heap() {
assert_eq!(size_of::<SmallRoleSet>(), 2);
let mut set =
SmallRoleSet::from_hints([RoleHint::TreatmentCandidate, RoleHint::OutcomeCandidate]);
assert!(set.contains(RoleHint::TreatmentCandidate));
assert!(set.contains(RoleHint::OutcomeCandidate));
assert!(!set.contains(RoleHint::InstrumentCandidate));
set.remove(RoleHint::TreatmentCandidate);
assert!(!set.contains(RoleHint::TreatmentCandidate));
}
#[test]
fn schema_assigns_dense_ids_in_order() {
let mut b = CausalSchemaBuilder::new();
b.add_variable(
"x",
ValueType::Continuous,
SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
None,
None,
MeasurementSpec::default(),
)
.unwrap();
b.add_variable(
"y",
ValueType::Continuous,
SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
None,
None,
MeasurementSpec::default(),
)
.unwrap();
let schema = b.build().unwrap();
assert_eq!(schema.len(), 2);
assert_eq!(schema.variables()[0].id.raw(), 0);
assert_eq!(schema.variables()[1].id.raw(), 1);
assert_eq!(schema.id_of("y").unwrap().raw(), 1);
assert_eq!(schema.get(VariableId::from_raw(0)).unwrap().name.as_ref(), "x");
}
#[test]
fn duplicate_names_are_rejected() {
let mut b = CausalSchemaBuilder::new();
b.add_variable(
"x",
ValueType::Continuous,
SmallRoleSet::empty(),
None,
None,
MeasurementSpec::default(),
)
.unwrap();
let err = b
.add_variable(
"x",
ValueType::Continuous,
SmallRoleSet::empty(),
None,
None,
MeasurementSpec::default(),
)
.unwrap_err();
assert!(matches!(err, SchemaError::DuplicateVariableName { .. }));
}
#[test]
fn categorical_requires_domain() {
let mut b = CausalSchemaBuilder::new();
let err = b
.add_variable(
"g",
ValueType::Categorical,
SmallRoleSet::empty(),
None,
None,
MeasurementSpec::default(),
)
.unwrap_err();
assert!(matches!(err, SchemaError::MissingCategoryDomain { .. }));
}
#[test]
fn continuous_rejects_domain() {
let mut b = CausalSchemaBuilder::new();
let err = b
.add_variable(
"x",
ValueType::Continuous,
SmallRoleSet::empty(),
None,
Some(CategoryDomainId::from_raw(0)),
MeasurementSpec::default(),
)
.unwrap_err();
assert!(matches!(err, SchemaError::UnexpectedCategoryDomain { .. }));
}
#[test]
fn unknown_id_and_name_errors() {
let schema = CausalSchemaBuilder::new().build().unwrap();
assert!(matches!(
schema.get(VariableId::from_raw(0)),
Err(SchemaError::UnknownVariableId { .. })
));
assert!(matches!(schema.id_of("missing"), Err(SchemaError::UnknownVariableName { .. })));
}
#[test]
fn fluent_schema_roles() {
let schema = CausalSchemaBuilder::new()
.continuous("t")
.treatment()
.continuous("y")
.outcome()
.continuous("z")
.context()
.build()
.unwrap();
assert_eq!(schema.len(), 3);
assert!(schema.variables()[0].role_hints.contains(RoleHint::TreatmentCandidate));
assert!(schema.variables()[1].role_hints.contains(RoleHint::OutcomeCandidate));
assert!(schema.variables()[2].role_hints.contains(RoleHint::Context));
}
}