use std::collections::{HashSet, VecDeque};
use thiserror::Error;
use crate::CausalDag;
use pacr_types::{CausalId, PacrRecord};
#[derive(Debug, Clone)]
pub struct DistanceTaxConfig {
pub max_causal_distance: usize,
pub alpha: f64,
pub max_children_per_node: usize,
}
impl Default for DistanceTaxConfig {
fn default() -> Self {
Self {
max_causal_distance: 64,
alpha: 0.1,
max_children_per_node: 65_536,
}
}
}
#[derive(Debug, Clone, Error, PartialEq)]
pub enum DistanceTaxError {
#[error("causal distance {distance} to predecessor {predecessor:?} exceeds max {max}")]
DistanceExceeded {
predecessor: CausalId,
distance: usize,
max: usize,
},
#[error("energy {actual:.3e} J insufficient for distance tax {required:.3e} J")]
InsufficientTax { required: f64, actual: f64 },
#[error("node {parent:?} has reached child capacity {max}")]
ChildCapacityExceeded { parent: CausalId, max: usize },
}
impl CausalDag {
pub fn validate_distance_tax(
&self,
record: &PacrRecord,
config: &DistanceTaxConfig,
) -> Result<f64, DistanceTaxError> {
let mut total_tax_multiplier = 1.0_f64;
for pred_id in &record.predecessors {
if pred_id.is_genesis() {
continue; }
let child_count = self.successors(pred_id).len();
if child_count >= config.max_children_per_node {
return Err(DistanceTaxError::ChildCapacityExceeded {
parent: *pred_id,
max: config.max_children_per_node,
});
}
let distance = self.distance_to_tips(pred_id, config.max_causal_distance);
if distance > config.max_causal_distance {
return Err(DistanceTaxError::DistanceExceeded {
predecessor: *pred_id,
distance,
max: config.max_causal_distance,
});
}
total_tax_multiplier *= (config.alpha * distance as f64).exp();
}
let required_energy = record.landauer_cost.point * total_tax_multiplier;
if record.resources.energy.point < required_energy {
return Err(DistanceTaxError::InsufficientTax {
required: required_energy,
actual: record.resources.energy.point,
});
}
Ok(total_tax_multiplier)
}
fn distance_to_tips(&self, id: &CausalId, depth_limit: usize) -> usize {
let root_children = self.successors(id);
if root_children.is_empty() {
return 0;
}
drop(root_children);
let mut queue: VecDeque<(CausalId, usize)> = VecDeque::new();
let mut visited: HashSet<CausalId> = HashSet::new();
queue.push_back((*id, 0));
visited.insert(*id);
while let Some((current, depth)) = queue.pop_front() {
if depth > depth_limit {
return depth;
}
let kids = self.successors(¤t);
if kids.is_empty() {
return depth;
}
for kid in kids {
if visited.insert(kid) {
queue.push_back((kid, depth + 1));
}
}
}
depth_limit + 1
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use bytes::Bytes;
use pacr_types::{CognitiveSplit, Estimate, PacrBuilder, ResourceTriple};
use smallvec::smallvec;
fn record_with_energy(id: u128, preds: &[u128], landauer: f64, energy: f64) -> PacrRecord {
let pred_set = preds.iter().map(|&p| CausalId(p)).collect();
PacrBuilder::new()
.id(CausalId(id))
.predecessors(pred_set)
.landauer_cost(Estimate::exact(landauer))
.resources(ResourceTriple {
energy: Estimate::exact(energy),
time: Estimate::exact(1e-6),
space: Estimate::exact(0.0),
})
.cognitive_split(CognitiveSplit {
statistical_complexity: Estimate::exact(0.5),
entropy_rate: Estimate::exact(0.3),
})
.payload(Bytes::new())
.build()
.unwrap()
}
fn append_genesis_record(dag: &Arc<CausalDag>, id: u128) {
let r = record_with_energy(id, &[], 1e-20, 1e-16);
dag.append(r).unwrap();
}
#[test]
fn tip_has_distance_zero() {
let dag = Arc::new(CausalDag::new());
append_genesis_record(&dag, 1);
assert_eq!(dag.distance_to_tips(&CausalId(1), 64), 0);
}
#[test]
fn parent_of_tip_has_distance_one() {
let dag = Arc::new(CausalDag::new());
append_genesis_record(&dag, 1);
let child = record_with_energy(2, &[1], 1e-20, 1e-16);
dag.append(child).unwrap();
assert_eq!(dag.distance_to_tips(&CausalId(1), 64), 1);
}
#[test]
fn chain_depth_respected() {
let dag = Arc::new(CausalDag::new());
append_genesis_record(&dag, 1);
for i in 2_u128..=4 {
let r = record_with_energy(i, &[i - 1], 1e-20, 1e-16);
dag.append(r).unwrap();
}
assert_eq!(dag.distance_to_tips(&CausalId(1), 64), 3);
}
#[test]
fn rejects_when_child_capacity_exceeded() {
let config = DistanceTaxConfig {
max_children_per_node: 3,
..Default::default()
};
let dag = Arc::new(CausalDag::new());
append_genesis_record(&dag, 1);
for i in 2_u128..=4 {
let r = record_with_energy(i, &[1], 1e-20, 1e-16);
dag.append(r).unwrap();
}
let overflow = record_with_energy(5, &[1], 1e-20, 1e-16);
let result = dag.validate_distance_tax(&overflow, &config);
assert!(
matches!(
result,
Err(DistanceTaxError::ChildCapacityExceeded { max: 3, .. })
),
"expected ChildCapacityExceeded, got: {result:?}"
);
}
#[test]
fn allows_exactly_at_capacity() {
let config = DistanceTaxConfig {
max_children_per_node: 3,
..Default::default()
};
let dag = Arc::new(CausalDag::new());
append_genesis_record(&dag, 1);
for i in 2_u128..=3 {
let r = record_with_energy(i, &[1], 1e-20, 1e-16);
dag.append(r).unwrap();
}
let at_limit = record_with_energy(4, &[1], 1e-20, 1e-16);
let result = dag.validate_distance_tax(&at_limit, &config);
assert!(result.is_ok(), "third child should be allowed: {result:?}");
}
#[test]
fn rejects_when_distance_exceeds_max() {
let config = DistanceTaxConfig {
max_causal_distance: 3, alpha: 0.0, max_children_per_node: 65_536,
};
let dag = Arc::new(CausalDag::new());
append_genesis_record(&dag, 1);
for i in 2_u128..=5 {
let r = record_with_energy(i, &[i - 1], 1e-20, 1e-16);
dag.append(r).unwrap();
}
let late_ref = record_with_energy(6, &[1], 1e-20, 1e-16);
let result = dag.validate_distance_tax(&late_ref, &config);
assert!(
matches!(
result,
Err(DistanceTaxError::DistanceExceeded { max: 3, .. })
),
"expected DistanceExceeded, got: {result:?}"
);
}
#[test]
fn rejects_when_energy_below_tax() {
let config = DistanceTaxConfig {
max_causal_distance: 64,
alpha: 10.0, max_children_per_node: 65_536,
};
let dag = Arc::new(CausalDag::new());
append_genesis_record(&dag, 1);
let child = record_with_energy(2, &[1], 1e-20, 1e-16);
dag.append(child).unwrap();
let grandchild = record_with_energy(3, &[1], 1e-20, 1e-16);
let result = dag.validate_distance_tax(&grandchild, &config);
assert!(
matches!(result, Err(DistanceTaxError::InsufficientTax { .. })),
"expected InsufficientTax, got: {result:?}"
);
}
#[test]
fn healthy_linear_chain_passes() {
let config = DistanceTaxConfig {
alpha: 0.1,
..Default::default()
};
let dag = Arc::new(CausalDag::new());
append_genesis_record(&dag, 1);
let mut last = 1_u128;
for i in 2_u128..=10 {
let r = record_with_energy(i, &[last], 1e-20, 1e-16);
dag.append(r.clone()).unwrap();
let _result = dag.validate_distance_tax(&r, &config);
last = i;
}
let next = record_with_energy(11, &[last], 1e-20, 1e-16);
let result = dag.validate_distance_tax(&next, &config);
assert!(
result.is_ok(),
"healthy tip reference should pass: {result:?}"
);
assert!(
(result.unwrap() - 1.0).abs() < 1e-10,
"tip distance = 0 → multiplier = 1.0"
);
}
#[test]
fn genesis_predecessor_exempt_from_tax() {
let dag = Arc::new(CausalDag::new());
let config = DistanceTaxConfig {
alpha: 999.0, max_causal_distance: 0, max_children_per_node: 65_536,
};
let genesis_child = record_with_energy(1, &[], 1e-20, 1e-16);
let r = PacrBuilder::new()
.id(CausalId(1))
.predecessors(smallvec![CausalId::GENESIS])
.landauer_cost(Estimate::exact(1e-20))
.resources(ResourceTriple {
energy: Estimate::exact(1e-16),
time: Estimate::exact(1e-6),
space: Estimate::exact(0.0),
})
.cognitive_split(CognitiveSplit {
statistical_complexity: Estimate::exact(0.5),
entropy_rate: Estimate::exact(0.3),
})
.payload(Bytes::new())
.build()
.unwrap();
let result = dag.validate_distance_tax(&r, &config);
assert!(
result.is_ok(),
"GENESIS predecessor should be exempt: {result:?}"
);
drop(genesis_child);
}
#[test]
fn distance_exceeded_error_message_contains_distance() {
let err = DistanceTaxError::DistanceExceeded {
predecessor: CausalId(42),
distance: 100,
max: 64,
};
let msg = err.to_string();
assert!(msg.contains("100"), "{msg}");
assert!(msg.contains("64"), "{msg}");
}
#[test]
fn insufficient_tax_error_message_contains_amounts() {
let err = DistanceTaxError::InsufficientTax {
required: 1.5e-10,
actual: 1e-20,
};
let msg = err.to_string();
assert!(
msg.contains("1.500e-10") || msg.contains("1.5e-10") || msg.contains("1.50"),
"{msg}"
);
}
}