use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::Value;
use crate::compile_error::CompileError;
use crate::faq::is_faq_op;
use crate::types::{Expr, ExpressionNode, IndexSet, JoinClause, Model, RangeSpec, RegionBound};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum JoinKey {
Int(i64),
Cat(String),
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum KeyError {
#[error("floating-point member {0} cannot be a join key")]
Float(f64),
#[error("null member cannot be a join key")]
Null,
#[error("non-scalar member cannot be a join key")]
NonScalar,
}
impl JoinKey {
pub fn from_json(v: &Value) -> Result<JoinKey, KeyError> {
match v {
Value::Null => Err(KeyError::Null),
Value::Bool(b) => Ok(JoinKey::Int(i64::from(*b))),
Value::Number(n) => match n.as_i64() {
Some(i) => Ok(JoinKey::Int(i)),
None => Err(KeyError::Float(n.as_f64().unwrap_or(f64::NAN))),
},
Value::String(s) => Ok(JoinKey::Cat(s.clone())),
Value::Array(_) | Value::Object(_) => Err(KeyError::NonScalar),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum KeyColumn {
Const {
positions: Vec<i64>,
values: Vec<JoinKey>,
},
Column(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct OnGate {
pub id: u64,
pub sym_l: String,
pub sym_r: String,
pub cols_l: Vec<KeyColumn>,
pub cols_r: Vec<KeyColumn>,
}
impl OnGate {
pub fn map_column_names(&self, f: impl Fn(&String) -> String) -> OnGate {
let map = |cols: &Vec<KeyColumn>| -> Vec<KeyColumn> {
cols.iter()
.map(|c| match c {
KeyColumn::Column(n) => KeyColumn::Column(f(n)),
other => other.clone(),
})
.collect()
};
OnGate {
id: self.id,
sym_l: self.sym_l.clone(),
sym_r: self.sym_r.clone(),
cols_l: map(&self.cols_l),
cols_r: map(&self.cols_r),
}
}
}
static NEXT_GATE_ID: AtomicU64 = AtomicU64::new(1);
fn next_gate_id() -> u64 {
NEXT_GATE_ID.fetch_add(1, Ordering::Relaxed)
}
pub fn resolve_aggregate_joins(
model: &mut Model,
index_sets: &HashMap<String, IndexSet>,
) -> Result<(), CompileError> {
if !model_contains_join(model) {
return Ok(());
}
let var_shapes = declared_var_shapes(model);
resolve_overlap_join_syms(model);
for eq in &mut model.equations {
lower_expr_joins(&mut eq.lhs, index_sets, &var_shapes)?;
lower_expr_joins(&mut eq.rhs, index_sets, &var_shapes)?;
}
if let Some(init_eqs) = &mut model.initialization_equations {
for eq in init_eqs {
lower_expr_joins(&mut eq.lhs, index_sets, &var_shapes)?;
lower_expr_joins(&mut eq.rhs, index_sets, &var_shapes)?;
}
}
for var in model.variables.values_mut() {
var.try_for_each_expression_mut(&mut |expr| {
lower_expr_joins(expr, index_sets, &var_shapes)
})?;
}
Ok(())
}
pub fn resolve_expr_joins(
expr: &mut Expr,
index_sets: &HashMap<String, IndexSet>,
var_shapes: &HashMap<String, Vec<String>>,
) -> Result<(), CompileError> {
lower_expr_joins(expr, index_sets, var_shapes)
}
fn model_contains_join(model: &Model) -> bool {
let eqs = model
.equations
.iter()
.chain(model.initialization_equations.iter().flatten());
let mut found = false;
for eq in eqs {
found = found || contains_join(&eq.lhs) || contains_join(&eq.rhs);
}
for var in model.variables.values() {
var.for_each_expression(&mut |expr| found = found || contains_join(expr));
}
found
}
fn contains_join(e: &Expr) -> bool {
match e {
Expr::Operator(node) => node.join.is_some() || node.any_child(&mut contains_join),
_ => false,
}
}
fn lower_expr_joins(
expr: &mut Expr,
index_sets: &HashMap<String, IndexSet>,
var_shapes: &HashMap<String, Vec<String>>,
) -> Result<(), CompileError> {
if !contains_join(expr) {
return Ok(());
}
let Some(node) = expr.node_mut() else {
return Ok(());
};
if node.join.is_some() {
lower_node_joins(node, index_sets, var_shapes)?;
}
node.try_for_each_child_mut(&mut |child| lower_expr_joins(child, index_sets, var_shapes))
}
fn lower_node_joins(
node: &mut ExpressionNode,
index_sets: &HashMap<String, IndexSet>,
var_shapes: &HashMap<String, Vec<String>>,
) -> Result<(), CompileError> {
if !is_faq_op(&node.op) {
return Err(CompileError::build_err(format!(
"`join` is only valid on an aggregate/faq node, but appears on op '{}' \
(RFC semiring-faq-unified-ir §5.3)",
node.op
)));
}
let joins = node.join.take().unwrap_or_default();
let ranges = node.ranges.clone().unwrap_or_default();
let order = canonical_range_order(node.output_idx.as_deref().unwrap_or(&[]), &ranges);
let declared: HashSet<&str> = ranges.keys().map(String::as_str).collect();
let set_to_syms = set_to_syms_ordered(&ranges, &order);
let mut conjuncts: Vec<Expr> = Vec::new();
let mut kept: Vec<JoinClause> = Vec::new();
for clause in &joins {
if clause.overlap.is_some() {
kept.push(clause.clone());
continue;
}
if clause.on.is_empty() {
return Err(CompileError::build_err(
"`join` clause has an empty `on` list; at least one [left, right] \
key-column pair is required (RFC semiring-faq-unified-ir §5.3)",
));
}
let (pick_l, pick_r) = match &clause.syms {
Some([sl, sr]) => {
for s in [sl, sr] {
if !declared.contains(s.as_str()) {
return Err(CompileError::build_err(format!(
"`join.syms` names '{s}', which is not a range symbol of this \
aggregate ({declared:?}); both entries must name one of its \
`ranges` (CONFORMANCE_SPEC §5.5.8)"
)));
}
}
(Pick::Named(sl.as_str()), Pick::Named(sr.as_str()))
}
None => (Pick::Left, Pick::Right),
};
let mut groups: Vec<(String, String, Vec<KeyColumn>, Vec<KeyColumn>)> = Vec::new();
for pair in &clause.on {
let left = pair[0].as_str();
let right = pair[1].as_str();
let l = resolve_side(
left,
&declared,
&set_to_syms,
&ranges,
index_sets,
var_shapes,
pick_l,
)?
.ok_or_else(|| CompileError::UnsupportedFeatureError {
feature: "value-equality join over data-derived columns".to_string(),
message: format!(
"join key column '{left}' does not resolve to a loop index of this \
aggregate ({declared:?}): it names neither a range symbol, nor an index \
set one of those ranges draws from, nor a declared 1-D data column over \
such an index set (RFC semiring-faq-unified-ir §5.3)"
),
})?;
let Some(r) = resolve_side(
right,
&declared,
&set_to_syms,
&ranges,
index_sets,
var_shapes,
pick_r,
)?
else {
continue;
};
if l.sym == r.sym && l.col == r.col {
continue;
}
conjuncts.push(equality_predicate(&l, &r)?);
if l.sym != r.sym {
let slot = groups.iter_mut().find(|(a, b, _, _)| {
(*a == l.sym && *b == r.sym) || (*a == r.sym && *b == l.sym)
});
match slot {
Some((a, _, cl, cr)) if *a == r.sym => {
cl.push(r.col);
cr.push(l.col);
}
Some((_, _, cl, cr)) => {
cl.push(l.col);
cr.push(r.col);
}
None => groups.push((l.sym, r.sym, vec![l.col], vec![r.col])),
}
}
}
for (sym_l, sym_r, cols_l, cols_r) in groups {
kept.push(JoinClause {
on: clause.on.clone(),
syms: clause.syms.clone(),
overlap: None,
on_gate: Some(OnGate {
id: next_gate_id(),
sym_l,
sym_r,
cols_l,
cols_r,
}),
});
}
}
if !conjuncts.is_empty() {
if let Some(existing) = node.filter.take() {
conjuncts.push(*existing);
}
let pred = if conjuncts.len() == 1 {
conjuncts.pop().unwrap()
} else {
Expr::operator(ExpressionNode {
op: "*".into(),
args: conjuncts,
..Default::default()
})
};
node.filter = Some(Box::new(pred));
}
if !kept.is_empty() {
node.join = Some(kept);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
struct ResolvedSide {
sym: String,
col: KeyColumn,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Pick<'a> {
Named(&'a str),
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Via {
Binder,
ColumnAxis,
}
fn canonical_range_order(
output_idx: &[String],
ranges: &HashMap<String, RangeSpec>,
) -> Vec<String> {
let mut out: Vec<String> = output_idx
.iter()
.filter(|n| ranges.contains_key(n.as_str()))
.cloned()
.collect();
let mut rest: Vec<String> = ranges
.keys()
.filter(|k| !out.iter().any(|n| n == *k))
.cloned()
.collect();
rest.sort_unstable();
out.append(&mut rest);
out
}
fn set_to_syms_ordered<'a>(
ranges: &'a HashMap<String, RangeSpec>,
order: &[String],
) -> HashMap<&'a str, Vec<&'a str>> {
let rank = |s: &str| order.iter().position(|o| o == s).unwrap_or(usize::MAX);
let mut map: HashMap<&str, Vec<&str>> = HashMap::new();
for (sym, spec) in ranges {
if let RangeSpec::IndexSetRef { from, .. } = spec {
map.entry(from.as_str()).or_default().push(sym);
}
}
for syms in map.values_mut() {
syms.sort_by_key(|s| rank(s));
}
map
}
fn resolve_side(
key: &str,
declared: &HashSet<&str>,
set_to_syms: &HashMap<&str, Vec<&str>>,
ranges: &HashMap<String, RangeSpec>,
index_sets: &HashMap<String, IndexSet>,
var_shapes: &HashMap<String, Vec<String>>,
pick: Pick,
) -> Result<Option<ResolvedSide>, CompileError> {
if let Some(sym) = resolve_key(key, declared, set_to_syms, pick, Via::Binder)? {
let (positions, values) = key_column(&sym, ranges, index_sets)?;
return Ok(Some(ResolvedSide {
sym,
col: KeyColumn::Const { positions, values },
}));
}
if let Some(shape) = var_shapes.get(key)
&& shape.len() == 1
&& let Some(sym) = resolve_key(&shape[0], declared, set_to_syms, pick, Via::ColumnAxis)?
{
return Ok(Some(ResolvedSide {
sym,
col: KeyColumn::Column(key.to_string()),
}));
}
Ok(None)
}
fn equality_predicate(l: &ResolvedSide, r: &ResolvedSide) -> Result<Expr, CompileError> {
let (le, re) = match (&l.col, &r.col) {
(
KeyColumn::Const {
positions: pl,
values: vl,
},
KeyColumn::Const {
positions: pr,
values: vr,
},
) => {
let (cl, cr) = encode_columns(vl, vr);
(code_lookup(pl, &cl, &l.sym), code_lookup(pr, &cr, &r.sym))
}
_ => (raw_key_expr(l)?, raw_key_expr(r)?),
};
Ok(crate::precision_infer::mark_exact_key_comparison(
Expr::operator(ExpressionNode {
op: "==".into(),
args: vec![le, re],
..Default::default()
}),
))
}
fn raw_key_expr(s: &ResolvedSide) -> Result<Expr, CompileError> {
match &s.col {
KeyColumn::Column(name) => Ok(Expr::operator(ExpressionNode {
op: "index".into(),
args: vec![Expr::Variable(name.clone()), Expr::Variable(s.sym.clone())],
..Default::default()
})),
KeyColumn::Const { positions, values } => {
let ints = values
.iter()
.map(|v| match v {
JoinKey::Int(i) => Ok(*i),
JoinKey::Cat(c) => Err(CompileError::UnsupportedFeatureError {
feature: "value-equality join of a categorical member column against a \
numeric data column"
.to_string(),
message: format!(
"join key '{}' carries the categorical member '{c}', but the other \
side of the pair is a numeric data column; an index-set member \
column equi-joined against a data column must carry integer IDs \
(RFC semiring-faq-unified-ir §5.3 / §5.7 rule 1)",
s.sym
),
}),
})
.collect::<Result<Vec<_>, _>>()?;
Ok(code_lookup(positions, &ints, &s.sym))
}
}
}
pub fn resolve_overlap_join_syms(model: &mut Model) {
let var_shapes = declared_var_shapes(model);
for eq in &mut model.equations {
resolve_overlap_syms_expr(&mut eq.lhs, &var_shapes);
resolve_overlap_syms_expr(&mut eq.rhs, &var_shapes);
}
if let Some(init_eqs) = &mut model.initialization_equations {
for eq in init_eqs {
resolve_overlap_syms_expr(&mut eq.lhs, &var_shapes);
resolve_overlap_syms_expr(&mut eq.rhs, &var_shapes);
}
}
for var in model.variables.values_mut() {
var.for_each_expression_mut(&mut |expr| resolve_overlap_syms_expr(expr, &var_shapes));
}
}
pub fn declared_var_shapes(model: &Model) -> HashMap<String, Vec<String>> {
model
.variables
.iter()
.filter_map(|(n, v)| v.shape.as_ref().map(|s| (n.clone(), s.clone())))
.collect()
}
pub fn resolve_overlap_syms_expr(expr: &mut Expr, var_shapes: &HashMap<String, Vec<String>>) {
if !contains_join(expr) {
return;
}
let Some(node) = expr.node_mut() else {
return;
};
if node.join.is_some() {
let ranges = node.ranges.clone().unwrap_or_default();
let declared: HashSet<&str> = ranges.keys().map(String::as_str).collect();
let order = canonical_range_order(node.output_idx.as_deref().unwrap_or(&[]), &ranges);
let set_to_syms = set_to_syms_ordered(&ranges, &order);
let env_sym = |env: &[String], pick: Pick| -> Option<String> {
let shape = var_shapes.get(env.first()?)?;
if shape.len() != 1 {
return None;
}
resolve_key(&shape[0], &declared, &set_to_syms, pick, Via::ColumnAxis).unwrap_or(None)
};
if let Some(joins) = &mut node.join {
for clause in joins.iter_mut() {
if let Some(ov) = &mut clause.overlap {
ov.sym_src = env_sym(&ov.src_env, Pick::Left);
ov.sym_tgt = env_sym(&ov.tgt_env, Pick::Right);
}
}
}
}
node.for_each_child_mut(&mut |child| resolve_overlap_syms_expr(child, var_shapes));
}
fn resolve_key(
key: &str,
declared: &HashSet<&str>,
set_to_syms: &HashMap<&str, Vec<&str>>,
pick: Pick,
via: Via,
) -> Result<Option<String>, CompileError> {
if declared.contains(key) {
if let Pick::Named(want) = pick
&& want != key
{
return Err(CompileError::build_err(format!(
"join key '{key}' names a range symbol of this aggregate, but this clause's \
`join.syms` puts that side on '{want}'; a key naming a range symbol must name \
its own side's (CONFORMANCE_SPEC §5.5.8)"
)));
}
return Ok(Some(key.to_string()));
}
let Some(syms) = set_to_syms.get(key) else {
return Ok(None);
};
match pick {
Pick::Named(want) => {
if syms.contains(&want) {
Ok(Some(want.to_string()))
} else {
Err(CompileError::build_err(format!(
"this clause's `join.syms` puts a side on range symbol '{want}', but the key \
resolving through index set '{key}' is read at one of {syms:?} — '{want}' \
does not draw that index set (CONFORMANCE_SPEC §5.5.8)"
)))
}
}
Pick::Left | Pick::Right => match syms.len() {
0 => Ok(None),
1 => Ok(Some(syms[0].to_string())),
_ if via == Via::Binder => Err(CompileError::build_err(format!(
"join key '{key}' names an index set bound by multiple range symbols \
{syms:?}; reference the range symbol directly, or name this clause's two \
sides with `join.syms` (RFC semiring-faq-unified-ir §5.3 / \
CONFORMANCE_SPEC §5.5.8)"
))),
2 => Ok(Some(
syms[usize::from(matches!(pick, Pick::Right))].to_string(),
)),
_ => Err(CompileError::build_err(format!(
"index set '{key}' is drawn by {} range symbols of this aggregate ({syms:?}), so \
which one a key column over it is read at is not determined; name the two sides \
explicitly with this clause's `join.syms` (CONFORMANCE_SPEC §5.5.8 \"Two ranges \
over one index set\")",
syms.len()
))),
},
}
}
fn key_column(
sym: &str,
ranges: &HashMap<String, RangeSpec>,
index_sets: &HashMap<String, IndexSet>,
) -> Result<(Vec<i64>, Vec<JoinKey>), CompileError> {
match ranges.get(sym) {
Some(RangeSpec::IndexSetRef { from, of }) => {
if of.as_ref().is_some_and(|p| !p.is_empty()) {
return Err(CompileError::UnsupportedFeatureError {
feature: "value-equality join over a ragged key column".to_string(),
message: format!(
"join key '{sym}' references index set '{from}' with a dependent `of` \
(ragged) binding; equi-join keys must be dense interval / categorical \
columns (RFC semiring-faq-unified-ir §5.3)"
),
});
}
let set = index_sets.get(from.as_str()).ok_or_else(|| {
CompileError::build_err(format!(
"join key '{sym}' references index set '{from}', which is not declared \
in the document `index_sets` registry (RFC semiring-faq-unified-ir §5.3)"
))
})?;
match set.kind.as_str() {
"categorical" => {
let members = set.members.as_ref().ok_or_else(|| {
CompileError::build_err(format!(
"categorical index set '{from}' (join key '{sym}') has no `members`"
))
})?;
let positions: Vec<i64> = (1..=members.len() as i64).collect();
let vals = members
.iter()
.map(|m| join_key_member(m, from))
.collect::<Result<Vec<_>, _>>()?;
Ok((positions, vals))
}
"interval" => {
let size = set.size.ok_or_else(|| {
CompileError::build_err(format!(
"interval index set '{from}' (join key '{sym}') has no `size`"
))
})?;
let positions: Vec<i64> = (1..=size).collect();
let vals = positions.iter().map(|p| JoinKey::Int(*p)).collect();
Ok((positions, vals))
}
other => Err(CompileError::UnsupportedFeatureError {
feature: "value-equality join over a non-enumerable key column".to_string(),
message: format!(
"join key '{sym}' references index set '{from}' of kind '{other}'; only \
interval (integer IDs) and categorical members can be equi-joined (RFC \
semiring-faq-unified-ir §5.3)"
),
}),
}
}
Some(RangeSpec::Interval([lo, hi])) | Some(RangeSpec::Strided([lo, hi, _])) => {
let positions: Vec<i64> = (*lo..=*hi).collect();
let vals = positions.iter().map(|p| JoinKey::Int(*p)).collect();
Ok((positions, vals))
}
Some(RangeSpec::RaggedDyn { .. }) => Err(CompileError::UnsupportedFeatureError {
feature: "value-equality join over a ragged key column".to_string(),
message: format!(
"join key '{sym}' is a ragged (per-parent dynamic) column; equi-join keys must be \
dense interval / categorical columns (RFC semiring-faq-unified-ir §5.3)"
),
}),
Some(RangeSpec::DerivedDyn { .. }) => Err(CompileError::UnsupportedFeatureError {
feature: "value-equality join over a derived key column".to_string(),
message: format!(
"join key '{sym}' is a derived (FAQ-materialized, data-dependent) column; equi-join \
keys must be dense interval / categorical columns (RFC semiring-faq-unified-ir §5.3)"
),
}),
None => Err(CompileError::build_err(format!(
"join key '{sym}' has no declared range on this aggregate"
))),
}
}
fn join_key_member(m: &Value, set_name: &str) -> Result<JoinKey, CompileError> {
JoinKey::from_json(m).map_err(|e| {
let why = match e {
KeyError::Float(f) => format!("floating-point member {f}"),
KeyError::Null => "null member".to_string(),
KeyError::NonScalar => "non-scalar member".to_string(),
};
CompileError::build_err(format!(
"{why} in join key index set '{set_name}': join keys must be integer IDs or \
categorical members — floats / nulls are forbidden (equality is not portable \
across bindings; RFC semiring-faq-unified-ir §5.3 / §5.7 rule 1)"
))
})
}
fn encode_columns(vals_l: &[JoinKey], vals_r: &[JoinKey]) -> (Vec<i64>, Vec<i64>) {
let mut union: BTreeSet<JoinKey> = BTreeSet::new();
for v in vals_l.iter().chain(vals_r.iter()) {
union.insert(v.clone());
}
let codes: BTreeMap<JoinKey, i64> = union
.into_iter()
.enumerate()
.map(|(i, k)| (k, i as i64 + 1))
.collect();
let map = |vals: &[JoinKey]| -> Vec<i64> { vals.iter().map(|k| codes[k]).collect() };
(map(vals_l), map(vals_r))
}
fn code_lookup(positions: &[i64], codes: &[i64], sym: &str) -> Expr {
let hi = positions.iter().copied().max().unwrap_or(0);
let code_at: HashMap<i64, i64> = positions
.iter()
.copied()
.zip(codes.iter().copied())
.collect();
let mut regions: Vec<Vec<[RegionBound; 2]>> = Vec::with_capacity(hi.max(0) as usize);
let mut values: Vec<Expr> = Vec::with_capacity(hi.max(0) as usize);
for p in 1..=hi {
regions.push(vec![[RegionBound::Int(p), RegionBound::Int(p)]]);
values.push(Expr::Integer(code_at.get(&p).copied().unwrap_or(0)));
}
let table = Expr::operator(ExpressionNode {
op: "makearray".into(),
regions: Some(regions),
values: Some(values),
..Default::default()
});
Expr::operator(ExpressionNode {
op: "index".into(),
args: vec![table, Expr::Variable(sym.to_string())],
..Default::default()
})
}
pub(crate) fn join_key_cone<'e>(
roots: impl IntoIterator<Item = &'e Expr>,
definition: impl Fn(&str) -> Option<&'e Expr>,
) -> HashSet<String> {
fn key_columns(e: &Expr, out: &mut Vec<String>) {
if let Expr::Operator(n) = e {
for clause in n.join.iter().flatten() {
out.extend(clause.on.iter().flatten().cloned());
}
n.for_each_child(&mut |c| key_columns(c, out));
}
}
fn reads(e: &Expr, out: &mut Vec<String>) {
match e {
Expr::Variable(v) => out.push(v.clone()),
Expr::Operator(n) => {
for clause in n.join.iter().flatten() {
out.extend(clause.on.iter().flatten().cloned());
}
n.for_each_child(&mut |c| reads(c, out));
}
_ => {}
}
}
let mut frontier: Vec<String> = Vec::new();
for r in roots {
key_columns(r, &mut frontier);
}
let mut seen: HashSet<String> = HashSet::new();
while let Some(name) = frontier.pop() {
if !seen.insert(name.clone()) {
continue;
}
if let Some(def) = definition(&name) {
reads(def, &mut frontier);
}
}
seen
}
pub(crate) fn join_key_parameters(model: &Model) -> Vec<String> {
let defs = crate::classification::observed_definitions(model);
let cone = join_key_cone(model.equations.iter().map(|eq| &eq.rhs), |n| defs.get(n));
let mut out: Vec<String> = model
.variables
.iter()
.filter(|(name, var)| {
cone.contains(name.as_str())
&& var.var_type == crate::types::VariableType::Parameter
&& var.shape.as_ref().is_none_or(|s| s.is_empty())
&& var.update.is_none()
})
.map(|(name, _)| name.clone())
.collect();
out.sort();
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ExpressionNode, JoinClause, RangeSpec};
use std::collections::HashMap;
#[test]
fn join_key_cone_is_linear_in_nesting_depth() {
let mut e = Expr::Variable("x".into());
for _ in 0..64 {
e = Expr::operator(ExpressionNode {
op: "+".into(),
args: vec![e, Expr::Number(1.0)],
..Default::default()
});
}
assert!(join_key_cone([&e], |_| None).is_empty());
let defs: HashMap<&str, &Expr> = [("k", &e)].into_iter().collect();
let keyed = Expr::operator(ExpressionNode {
op: "faq".into(),
join: Some(vec![JoinClause {
on: vec![["k".into(), "id".into()]],
..Default::default()
}]),
..Default::default()
});
let cone = join_key_cone([&keyed], |n| defs.get(n).copied());
assert!(cone.contains("k") && cone.contains("x"), "{cone:?}");
}
#[test]
fn int_keys_order_by_value() {
assert!(JoinKey::Int(2) < JoinKey::Int(10));
assert!(JoinKey::Int(-1) < JoinKey::Int(0));
let mut v = vec![JoinKey::Int(10), JoinKey::Int(2), JoinKey::Int(-5)];
v.sort();
assert_eq!(v, vec![JoinKey::Int(-5), JoinKey::Int(2), JoinKey::Int(10)]);
}
#[test]
fn string_keys_order_by_code_point_not_locale() {
let mut v = vec![
JoinKey::Cat("a".into()),
JoinKey::Cat("Z".into()),
JoinKey::Cat("B".into()),
];
v.sort();
assert_eq!(
v,
vec![
JoinKey::Cat("B".into()),
JoinKey::Cat("Z".into()),
JoinKey::Cat("a".into()),
]
);
}
#[test]
fn cross_type_order_is_total_int_before_cat() {
assert!(JoinKey::Int(999) < JoinKey::Cat("".into()));
let a = vec![JoinKey::Int(1), JoinKey::Cat("x".into())];
let b = vec![JoinKey::Int(1), JoinKey::Cat("y".into())];
assert!(a < b);
}
#[test]
fn from_json_accepts_int_and_string() {
assert_eq!(
JoinKey::from_json(&Value::from(5)).unwrap(),
JoinKey::Int(5)
);
assert_eq!(
JoinKey::from_json(&Value::from("onroad")).unwrap(),
JoinKey::Cat("onroad".into())
);
}
#[test]
fn from_json_rejects_float_keys() {
assert_eq!(
JoinKey::from_json(&serde_json::json!(1.5)),
Err(KeyError::Float(1.5))
);
assert_eq!(
JoinKey::from_json(&serde_json::json!(5.0)),
Err(KeyError::Float(5.0))
);
}
#[test]
fn from_json_rejects_null_in_key() {
assert_eq!(JoinKey::from_json(&Value::Null), Err(KeyError::Null));
}
#[test]
fn from_json_bool_is_categorical_int() {
assert_eq!(
JoinKey::from_json(&Value::from(true)).unwrap(),
JoinKey::Int(1)
);
assert_eq!(
JoinKey::from_json(&Value::from(false)).unwrap(),
JoinKey::Int(0)
);
}
#[test]
fn encode_columns_equal_codes_for_equal_members() {
let l = vec![
JoinKey::Cat("coal".into()),
JoinKey::Cat("coal".into()),
JoinKey::Cat("oil".into()),
];
let r = vec![
JoinKey::Cat("coal".into()),
JoinKey::Cat("coal".into()),
JoinKey::Cat("gas".into()),
];
let (cl, cr) = encode_columns(&l, &r);
assert_eq!(cl[0], cl[1], "both 'coal' on the left share a code");
assert_eq!(cl[0], cr[0], "'coal' == 'coal' across columns");
assert_eq!(cl[0], cr[1]);
assert_ne!(cl[2], cr[2], "'oil' != 'gas'");
assert_ne!(cl[0], cl[2], "'coal' != 'oil'");
let admitted = (0..3)
.flat_map(|a| (0..3).map(move |b| (a, b)))
.filter(|&(a, b)| cl[a] == cr[b])
.count();
assert_eq!(admitted, 4, "coal 2×2 matches; oil/gas unmatched");
}
#[test]
fn encode_columns_is_independent_of_member_order() {
let count = |l: &[JoinKey], r: &[JoinKey]| {
let (cl, cr) = encode_columns(l, r);
(0..l.len())
.flat_map(|a| (0..r.len()).map(move |b| (a, b)))
.filter(|&(a, b)| cl[a] == cr[b])
.count()
};
let cat = |s: &str| JoinKey::Cat(s.into());
let canonical = count(
&[cat("coal"), cat("coal"), cat("oil")],
&[cat("coal"), cat("coal"), cat("gas")],
);
let permuted = count(
&[cat("oil"), cat("coal"), cat("coal")],
&[cat("gas"), cat("coal"), cat("coal")],
);
assert_eq!(canonical, permuted, "value-equality is order-independent");
assert_eq!(canonical, 4);
}
fn no_shapes() -> HashMap<String, Vec<String>> {
HashMap::new()
}
fn interval(size: i64) -> IndexSet {
IndexSet {
kind: "interval".into(),
size: Some(size),
members: None,
from_faq: None,
member_factor: None,
of: None,
offsets: None,
values: None,
}
}
fn categorical(members: &[&str]) -> IndexSet {
IndexSet {
kind: "categorical".into(),
size: None,
members: Some(members.iter().map(|m| Value::from(*m)).collect()),
from_faq: None,
member_factor: None,
of: None,
offsets: None,
values: None,
}
}
fn agg_with_join(joins: Vec<JoinClause>, ranges: Vec<&str>) -> Expr {
let mut range_map = HashMap::new();
for r in ranges {
range_map.insert(r.to_string(), RangeSpec::Interval([1, 2]));
}
Expr::operator(ExpressionNode {
op: "faq".into(),
ranges: Some(range_map),
output_idx: Some(vec![]),
join: Some(joins),
expr: Some(Box::new(Expr::Variable("x".into()))),
args: vec![Expr::Variable("x".into())],
..Default::default()
})
}
#[test]
fn lowers_data_derived_join_to_member_equality_filter() {
let mut range_map = HashMap::new();
range_map.insert(
"i".to_string(),
RangeSpec::IndexSetRef {
from: "sources".into(),
of: None,
},
);
range_map.insert(
"j".to_string(),
RangeSpec::IndexSetRef {
from: "factors".into(),
of: None,
},
);
let mut expr = Expr::operator(ExpressionNode {
op: "faq".into(),
ranges: Some(range_map),
output_idx: Some(vec![]),
join: Some(vec![JoinClause {
on: vec![["i".into(), "j".into()]],
..Default::default()
}]),
expr: Some(Box::new(Expr::Number(1.0))),
..Default::default()
});
let mut isets = HashMap::new();
isets.insert("sources".to_string(), categorical(&["coal", "coal", "oil"]));
isets.insert("factors".to_string(), categorical(&["coal", "coal", "gas"]));
lower_expr_joins(&mut expr, &isets, &no_shapes()).unwrap();
let Expr::Operator(node) = &expr else {
panic!("expr is not an operator");
};
let filter = node
.filter
.as_ref()
.expect("data-derived join adds a filter");
let Expr::Operator(f) = filter.as_ref() else {
panic!("filter is not an operator");
};
assert_eq!(
f.op,
crate::precision_infer::MARKER_OP,
"the lowered key comparison is marked exact"
);
assert_eq!(f.name.as_deref(), Some("Float64"));
let Expr::Operator(f) = &f.args[0] else {
panic!("marker wraps an operator");
};
assert_eq!(f.op, "==", "a single key pair lowers to one equality gate");
let gate = node
.join
.as_ref()
.and_then(|j| j.first())
.and_then(|c| c.on_gate.as_ref())
.expect("data-derived join attaches a drivable gate");
assert_eq!((gate.sym_l.as_str(), gate.sym_r.as_str()), ("i", "j"));
assert_eq!(gate.cols_l.len(), 1, "one column per listed key pair");
assert!(matches!(
gate.cols_l[0],
KeyColumn::Const { .. } | KeyColumn::Column(_)
));
}
#[test]
fn accepts_degenerate_positional_join() {
let join = vec![JoinClause {
on: vec![
["src".into(), "sourceType".into()],
["fuel".into(), "fuelType".into()],
],
..Default::default()
}];
let mut expr = agg_with_join(join, vec!["src", "fuel"]);
lower_expr_joins(&mut expr, &HashMap::new(), &no_shapes()).unwrap();
let Expr::Operator(node) = &expr else {
panic!("expr is not an operator");
};
assert!(node.join.is_none(), "resolved join must be consumed");
assert!(
node.filter.is_none(),
"a degenerate positional join adds no filter"
);
}
#[test]
fn rejects_non_positional_join_as_unsupported() {
let join = vec![JoinClause {
on: vec![["srcCol".into(), "sourceType".into()]],
..Default::default()
}];
let mut expr = agg_with_join(join, vec!["src", "fuel"]);
let err = lower_expr_joins(&mut expr, &HashMap::new(), &no_shapes()).unwrap_err();
match err {
CompileError::UnsupportedFeatureError { feature, message } => {
assert!(feature.contains("value-equality join"));
assert!(message.contains("srcCol"));
}
other => panic!("expected UnsupportedFeatureError, got {other:?}"),
}
}
#[test]
fn rejects_empty_on_list() {
let join = vec![JoinClause {
on: vec![],
..Default::default()
}];
let mut expr = agg_with_join(join, vec!["src"]);
assert!(lower_expr_joins(&mut expr, &HashMap::new(), &no_shapes()).is_err());
}
#[test]
fn rejects_join_on_non_aggregate_op() {
let mut bogus = Expr::operator(ExpressionNode {
op: "+".into(),
join: Some(vec![JoinClause {
on: vec![["a".into(), "b".into()]],
..Default::default()
}]),
args: vec![Expr::Variable("x".into())],
..Default::default()
});
assert!(lower_expr_joins(&mut bogus, &HashMap::new(), &no_shapes()).is_err());
}
fn self_join_expr(on: Vec<[String; 2]>, syms: Option<[String; 2]>, ranges: &[&str]) -> Expr {
self_join_expr_out(on, syms, ranges, ranges[0])
}
fn self_join_expr_out(
on: Vec<[String; 2]>,
syms: Option<[String; 2]>,
ranges: &[&str],
out_sym: &str,
) -> Expr {
let range_map: HashMap<String, RangeSpec> = ranges
.iter()
.map(|s| {
(
(*s).to_string(),
RangeSpec::IndexSetRef {
from: "rows".into(),
of: None,
},
)
})
.collect();
Expr::operator(ExpressionNode {
op: "faq".into(),
ranges: Some(range_map),
output_idx: Some(vec![out_sym.to_string()]),
join: Some(vec![JoinClause {
on,
syms,
..Default::default()
}]),
expr: Some(Box::new(Expr::Variable("payload".into()))),
..Default::default()
})
}
fn self_join_shapes() -> HashMap<String, Vec<String>> {
["row_prior", "row_id", "payload"]
.into_iter()
.map(|n| (n.to_string(), vec!["rows".to_string()]))
.collect()
}
fn rows_interval(size: i64) -> HashMap<String, IndexSet> {
HashMap::from([("rows".to_string(), interval(size))])
}
fn gate_of(expr: &Expr) -> OnGate {
let Expr::Operator(node) = expr else {
panic!("not an operator");
};
node.join
.as_ref()
.and_then(|j| j.first())
.and_then(|c| c.on_gate.clone())
.expect("a resolved self-join attaches a drivable gate")
}
#[test]
fn the_left_key_of_a_self_join_reads_at_the_output_symbol() {
let mut expr = self_join_expr(
vec![["row_prior".into(), "row_id".into()]],
None,
&["a", "b"],
);
lower_expr_joins(&mut expr, &rows_interval(5), &self_join_shapes()).unwrap();
let g = gate_of(&expr);
assert_eq!((g.sym_l.as_str(), g.sym_r.as_str()), ("a", "b"));
assert_eq!(g.cols_l, vec![KeyColumn::Column("row_prior".into())]);
assert_eq!(g.cols_r, vec![KeyColumn::Column("row_id".into())]);
}
#[test]
fn the_default_order_does_not_depend_on_the_symbols_names() {
let mut expr = self_join_expr_out(
vec![["row_prior".into(), "row_id".into()]],
None,
&["z_out", "b"],
"z_out",
);
lower_expr_joins(&mut expr, &rows_interval(5), &self_join_shapes()).unwrap();
let g = gate_of(&expr);
assert_eq!(
(g.sym_l.as_str(), g.sym_r.as_str()),
("z_out", "b"),
"the output symbol is the left side however its name sorts"
);
}
#[test]
fn explicit_syms_override_the_default_orientation() {
let mut expr = self_join_expr(
vec![["row_prior".into(), "row_id".into()]],
Some(["b".into(), "a".into()]),
&["a", "b"],
);
lower_expr_joins(&mut expr, &rows_interval(5), &self_join_shapes()).unwrap();
let g = gate_of(&expr);
assert_eq!((g.sym_l.as_str(), g.sym_r.as_str()), ("b", "a"));
}
#[test]
fn a_third_range_over_the_same_index_set_is_refused_not_guessed() {
let mut expr = self_join_expr(
vec![["row_prior".into(), "row_id".into()]],
None,
&["a", "b", "c"],
);
let err = lower_expr_joins(&mut expr, &rows_interval(5), &self_join_shapes()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("drawn by 3 range symbols"), "{msg}");
assert!(msg.contains("join.syms"), "{msg}");
}
#[test]
fn syms_naming_a_non_range_is_refused() {
let mut expr = self_join_expr(
vec![["row_prior".into(), "row_id".into()]],
Some(["a".into(), "nope".into()]),
&["a", "b"],
);
let err = lower_expr_joins(&mut expr, &rows_interval(5), &self_join_shapes()).unwrap_err();
assert!(err.to_string().contains("'nope'"), "{err:?}");
}
#[test]
fn a_join_between_two_distinct_relations_is_unaffected_by_the_side_rule() {
let mut range_map = HashMap::new();
range_map.insert(
"l".to_string(),
RangeSpec::IndexSetRef {
from: "src".into(),
of: None,
},
);
range_map.insert(
"r".to_string(),
RangeSpec::IndexSetRef {
from: "emf".into(),
of: None,
},
);
let mut expr = Expr::operator(ExpressionNode {
op: "faq".into(),
ranges: Some(range_map),
output_idx: Some(vec![]),
join: Some(vec![JoinClause {
on: vec![["src_type".into(), "emf_type".into()]],
..Default::default()
}]),
expr: Some(Box::new(Expr::Number(1.0))),
..Default::default()
});
let shapes: HashMap<String, Vec<String>> = HashMap::from([
("src_type".to_string(), vec!["src".to_string()]),
("emf_type".to_string(), vec!["emf".to_string()]),
]);
let isets = HashMap::from([
("src".to_string(), interval(4)),
("emf".to_string(), interval(3)),
]);
lower_expr_joins(&mut expr, &isets, &shapes).unwrap();
let g = gate_of(&expr);
assert_eq!((g.sym_l.as_str(), g.sym_r.as_str()), ("l", "r"));
}
#[test]
fn noop_when_no_join_present() {
let mut agg = Expr::operator(ExpressionNode {
op: "faq".into(),
ranges: Some(HashMap::from([(
"i".to_string(),
RangeSpec::Interval([1, 3]),
)])),
output_idx: Some(vec![]),
expr: Some(Box::new(Expr::Variable("x".into()))),
args: vec![Expr::Variable("x".into())],
..Default::default()
});
lower_expr_joins(&mut agg, &HashMap::new(), &no_shapes()).unwrap();
let Expr::Operator(node) = &agg else {
panic!("expr is not an operator");
};
assert!(node.filter.is_none(), "no join ⇒ no synthesized filter");
}
}