Skip to content

Commit aab7a84

Browse files
committed
feat(physical-operators): native UnivMon state over typed identities
UnivMon had a kernel but no native DAG state: a build was rejected ("summary family has no native DAG state implementation"), a summary build accepted only Float64 samples, and Pass 1's UnivMon update (each value of the item column one unit occurrence) did not bind, so no UnivMon candidate of #509 Example 2 could execute. - UnivMon is a native family; its states validate against their parameters, and one state answers its total, distinct count, L2 norm and entropy. - A UnivMon build takes Bool, Int64 and Utf8 identities as type-tagged key bytes (Int64 above 2^53 stays distinct) besides Float64 samples; NULL is skipped. Retained heap key bytes count toward state memory. - A unit-frequency update over one item column binds, for UnivMon and HLL, as a value-frequency build over that column: grouped (SQL src_ip) and per series (PromQL sample values). Ports the UnivMon part of #597 (from #557).
1 parent 0d08cc2 commit aab7a84

9 files changed

Lines changed: 607 additions & 31 deletions

File tree

‎crates/asap-physical-operators/src/capability.rs‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ pub fn validate_summary_kernel(
112112
}
113113
_ => return Err(format!("unsupported summary kernel {family:?}")),
114114
};
115-
if keyed != input.item.is_some() && !is_unit_sample_frequency(input) {
115+
if keyed != input.item.is_some() && unit_frequency_column(input).is_none() {
116116
return Err("Planner item expression does not match kernel layout".into());
117117
}
118118
Ok(())
@@ -217,6 +217,25 @@ fn valid_matrix(width: u32, depth: u32) -> bool {
217217
.is_some()
218218
}
219219

220+
/// The one item column of a unit-count frequency update (each value is one
221+
/// occurrence), the update an unkeyed frequency family (HLL, UnivMon) builds
222+
/// from that column's values.
223+
pub(crate) fn unit_frequency_column(
224+
update: &planner_types::post_asap::SummaryUpdate,
225+
) -> Option<&planner_types::pre_asap::ColumnRef> {
226+
use planner_types::post_asap::{NonNegativeWeightProof, SummaryInputExpr, WeightDomain};
227+
match (&update.item, &update.weight, &update.weight_domain) {
228+
(
229+
Some(SummaryInputExpr::Column(column)),
230+
SummaryInputExpr::Constant(weight),
231+
WeightDomain::NonNegative {
232+
proof: NonNegativeWeightProof::UnitCount,
233+
},
234+
) if *weight == 1.0 => Some(column),
235+
_ => None,
236+
}
237+
}
238+
220239
pub(crate) fn is_unit_sample_frequency(update: &planner_types::post_asap::SummaryUpdate) -> bool {
221240
use planner_types::post_asap::{NonNegativeWeightProof, SummaryInputExpr, WeightDomain};
222241
matches!(
@@ -266,7 +285,10 @@ pub fn validate_native_family(family: &SummaryFamilyType) -> Result<(), Error> {
266285
match family {
267286
SummaryFamilyType::ExactAggregate(..) => {}
268287
SummaryFamilyType::Sketch(kind, _)
269-
if matches!(kind.algorithm(), A::Kll | A::DDSketch | A::Hll | A::Kmv) => {}
288+
if matches!(
289+
kind.algorithm(),
290+
A::Kll | A::DDSketch | A::Hll | A::Kmv | A::UnivMon
291+
) => {}
270292
_ => {
271293
return Err(Error::Invalid(
272294
"summary family has no native DAG state implementation".into(),
@@ -323,6 +345,15 @@ pub fn validate_sketch_evaluation(
323345
}
324346
(A::Kmv, SketchStatistic::Cardinality) => true,
325347
(A::Kmv, _) => bare_count,
348+
// One frequency state answers its total, distinct count, L2 norm
349+
// and entropy.
350+
(
351+
A::UnivMon,
352+
SketchStatistic::Cardinality
353+
| SketchStatistic::FrequencyL2
354+
| SketchStatistic::FrequencyEntropy,
355+
) => true,
356+
(A::UnivMon, _) => bare_count,
326357
// Only count intents read a Count-Min bare count, and their
327358
// updates have unit weight; the evaluation is typed Int64 on that basis.
328359
(A::Cms, _) => bare_count,

‎crates/asap-physical-operators/src/operators/summary/mod.rs‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,14 @@ impl Operator {
167167
) -> Result<Self, Error> {
168168
crate::values::validate_family(&family)?;
169169
validate_groups(&input, &groups)?;
170-
if plain(&input, value)?.0 != &DataType::Float64 {
171-
return Err(invalid("summary numeric update requires Float64"));
170+
let dtype = plain(&input, value)?.0;
171+
let typed_frequency = matches!(&family, SummaryFamilyType::Sketch(kind, _)
172+
if kind.algorithm() == &planner_types::post_asap::SketchAlgorithm::UnivMon);
173+
if *dtype != DataType::Float64
174+
&& !(typed_frequency
175+
&& matches!(dtype, DataType::Utf8 | DataType::Int64 | DataType::Bool))
176+
{
177+
return Err(invalid("summary update type is unsupported by its family"));
172178
}
173179
if let Some(time) = time {
174180
if plain(&input, time)? != (&DataType::Timestamp, false) {
@@ -532,11 +538,10 @@ async fn build_summary(
532538
states.get_mut(&key).expect("inserted group");
533539
// SQL aggregates ignore NULL samples while retaining the group.
534540
// A missing counter sample also contributes no observation.
535-
let value = match row[value] {
536-
Value::Float64(value) => value,
537-
Value::Null => continue,
538-
_ => return Err(invalid("summary update type")),
539-
};
541+
let value = &row[value];
542+
if matches!(value, Value::Null) {
543+
continue;
544+
}
540545
let timestamp = if let Some(time) = time {
541546
let Value::Timestamp(time) = row[time] else {
542547
return Err(invalid("summary time type"));
@@ -552,9 +557,8 @@ async fn build_summary(
552557
));
553558
}
554559
updater
555-
.validate_single_input(value)
560+
.update_value(value, timestamp)
556561
.map_err(Error::Operator)?;
557-
updater.update_single(value, timestamp);
558562
*previous = Some(timestamp);
559563
memory.resize(updater.memory_usage_bytes() + *overhead)?;
560564
}

‎crates/asap-physical-operators/src/physical_planner/mod.rs‎

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -427,17 +427,25 @@ fn compile_internal(
427427
let Some(NonASAPOp::Scan { schema, .. }) = source.non_asap() else {
428428
return Err(invalid("per-entity summary requires a resolved source"));
429429
};
430-
if !schema.closed || update.item.is_some() {
430+
// An unkeyed frequency family (UnivMon) counts each value of
431+
// its one item column once, as a value-frequency build.
432+
let frequency_item = crate::capability::unit_frequency_column(update)
433+
.filter(|_| unkeyed_frequency_family(family));
434+
if !schema.closed || (update.item.is_some() && frequency_item.is_none()) {
431435
return Err(invalid(
432436
"per-entity summary requires complete source identity",
433437
));
434438
}
435439
crate::capability::validate_summary_kernel(family, update, grouping)
436440
.map_err(Error::Invalid)?;
437-
let SummaryInputExpr::Column(value) = &update.weight else {
438-
return Err(invalid(
439-
"per-entity update requires a projected value column",
440-
));
441+
let value = match (frequency_item, &update.weight) {
442+
(Some(item), _) => item,
443+
(None, SummaryInputExpr::Column(value)) => value,
444+
(None, _) => {
445+
return Err(invalid(
446+
"per-entity update requires a projected value column",
447+
))
448+
}
441449
};
442450
let input = schemas[0].clone();
443451
let value = named_column(&input, value)?;
@@ -966,6 +974,27 @@ fn summary_build(
966974
groups(input, keys)?,
967975
);
968976
}
977+
// An unkeyed frequency family (UnivMon) counts each value of its
978+
// one item column once: a value-frequency build over that column.
979+
if let (Some(item), true) = (
980+
crate::capability::unit_frequency_column(update),
981+
unkeyed_frequency_family(family),
982+
) {
983+
crate::capability::validate_summary_kernel(family, update, grouping)
984+
.map_err(Error::Invalid)?;
985+
let PlannerReduction::Reduce(keys) = reduction else {
986+
return Err(invalid(
987+
"summary construction requires explicit grouping columns",
988+
));
989+
};
990+
return Operator::summary_build(
991+
input.clone(),
992+
family.clone(),
993+
named_column(input, item)?,
994+
input.time_index,
995+
groups(input, keys)?,
996+
);
997+
}
969998
if let Some(item) = &update.item {
970999
let PlannerReduction::Reduce(keys) = reduction else {
9711000
return Err(invalid("keyed summary requires explicit partitions"));
@@ -1028,6 +1057,15 @@ fn summary_build(
10281057
groups(input, keys)?,
10291058
)
10301059
}
1060+
/// Families whose state counts values of one column without keyed items:
1061+
/// their unit-frequency update builds over that column.
1062+
fn unkeyed_frequency_family(family: &FieldDataType) -> bool {
1063+
matches!(family, FieldDataType::Sketch(kind, _) if matches!(
1064+
kind.algorithm(),
1065+
planner_types::post_asap::SketchAlgorithm::UnivMon
1066+
| planner_types::post_asap::SketchAlgorithm::Hll
1067+
))
1068+
}
10311069
fn summary_column(input: &SchemaRef) -> Result<usize, Error> {
10321070
let columns = input
10331071
.fields

‎crates/asap-physical-operators/src/summary_kernels/factory.rs‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ pub trait AccumulatorUpdater: Send {
4646
}
4747
}
4848

49+
/// Update from one typed row value. Numeric kernels keep their Float64
50+
/// contract; a frequency kernel may also take non-numeric identities.
51+
fn update_value(
52+
&mut self,
53+
value: &crate::values::Value,
54+
timestamp_ms: i64,
55+
) -> Result<(), String> {
56+
let crate::values::Value::Float64(value) = value else {
57+
return Err("summary update requires Float64".into());
58+
};
59+
self.validate_single_input(*value)?;
60+
self.update_single(*value, timestamp_ms);
61+
Ok(())
62+
}
63+
4964
/// Feed a single (value, timestamp_ms) pair — for SingleSubpopulation types.
5065
fn update_single(&mut self, value: f64, timestamp_ms: i64);
5166

@@ -645,7 +660,7 @@ pub fn create_planner_accumulator(
645660
}
646661
};
647662
if updater.is_keyed() != input.item.is_some()
648-
&& !crate::capability::is_unit_sample_frequency(input)
663+
&& crate::capability::unit_frequency_column(input).is_none()
649664
{
650665
return Err("Planner item expression does not match the selected kernel layout".into());
651666
}
@@ -718,6 +733,11 @@ impl AccumulatorUpdater for HllUpdater {
718733
}
719734

720735
impl AccumulatorUpdater for UnivMonUpdater {
736+
fn update_value(&mut self, value: &crate::values::Value, _: i64) -> Result<(), String> {
737+
self.acc
738+
.insert_value(value)
739+
.map_err(|error| error.to_string())
740+
}
721741
fn is_keyed(&self) -> bool {
722742
false
723743
}

‎crates/asap-physical-operators/src/summary_kernels/univmon.rs‎

Lines changed: 103 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,27 @@ impl UnivMonAccumulator {
9090
Ok(())
9191
}
9292

93+
/// One occurrence of a typed identity. A finite Float64 is a sample
94+
/// ([`Self::insert_sample`]); Bool, Int64 and Utf8 keep their type as
95+
/// type-tagged key bytes, since converting Int64 to Float64 would collapse
96+
/// neighbouring keys above 2^53. NULL is skipped.
97+
pub fn insert_value(&mut self, value: &crate::values::Value) -> Result<(), Error> {
98+
use crate::values::Value;
99+
match value {
100+
Value::Null => return Ok(()),
101+
Value::Float64(number) if number.is_finite() => return self.insert_sample(*number),
102+
Value::Bool(_) | Value::Int64(_) | Value::Utf8(_) => {}
103+
_ => return Err("unsupported UnivMon identity type or non-finite sample".into()),
104+
}
105+
let key = value.key()?;
106+
self.inner
107+
.bucket_size
108+
.checked_add(1)
109+
.ok_or("UnivMon count overflow")?;
110+
self.inner.insert(&DataInput::Bytes(&key), 1);
111+
Ok(())
112+
}
113+
93114
fn compatible(&self, other: &Self) -> bool {
94115
(
95116
self.inner.heap_size,
@@ -127,16 +148,28 @@ impl UnivMonAccumulator {
127148
}
128149

129150
impl AggregateCore for UnivMonAccumulator {
151+
/// Counters and heap slots of every layer, plus the bytes of the
152+
/// variable-length keys the heaps retain.
130153
fn approx_memory_bytes(&self) -> usize {
131-
std::mem::size_of::<Self>().saturating_add(
132-
self.inner.layer_size.saturating_mul(
133-
self.inner
134-
.sketch_row
135-
.saturating_mul(self.inner.sketch_col)
136-
.saturating_mul(16)
137-
.saturating_add(self.inner.heap_size.saturating_mul(256)),
138-
),
139-
)
154+
let key_bytes = (0..self.inner.layer_size)
155+
.flat_map(|layer| self.inner.hh_layers[layer].heap())
156+
.map(|item| match &item.key {
157+
asap_sketchlib::HeapItem::String(key) => key.capacity(),
158+
asap_sketchlib::HeapItem::Bytes(key) => key.capacity(),
159+
_ => 0,
160+
})
161+
.fold(0usize, usize::saturating_add);
162+
key_bytes
163+
.saturating_add(std::mem::size_of::<Self>())
164+
.saturating_add(
165+
self.inner.layer_size.saturating_mul(
166+
self.inner
167+
.sketch_row
168+
.saturating_mul(self.inner.sketch_col)
169+
.saturating_mul(16)
170+
.saturating_add(self.inner.heap_size.saturating_mul(256)),
171+
),
172+
)
140173
}
141174
fn clone_boxed_core(&self) -> Box<dyn AggregateCore> {
142175
Box::new(self.clone())
@@ -248,4 +281,65 @@ mod tests {
248281
assert!(UnivMonAccumulator::new(4, 11, 2047, 1).is_err());
249282
assert!(UnivMonAccumulator::new(4, 10, 2047, 1).is_ok());
250283
}
284+
285+
// Typed keys stay distinct, and merge, when entered as type-tagged bytes:
286+
// Int64 identities above 2^53 do not collapse.
287+
#[test]
288+
fn typed_keys_merge_and_stay_distinct() {
289+
use crate::values::Value;
290+
for keys in [
291+
[
292+
Value::Utf8("192.0.2.1".into()),
293+
Value::Utf8("192.0.2.2".into()),
294+
],
295+
[
296+
Value::Int64(9_007_199_254_740_992),
297+
Value::Int64(9_007_199_254_740_993),
298+
],
299+
[Value::Bool(false), Value::Bool(true)],
300+
] {
301+
let mut left = UnivMonAccumulator::new(32, 5, 1024, 4).unwrap();
302+
let mut right = UnivMonAccumulator::new(32, 5, 1024, 4).unwrap();
303+
for _ in 0..2 {
304+
left.insert_value(&keys[0]).unwrap();
305+
right.insert_value(&keys[1]).unwrap();
306+
}
307+
left.insert_value(&Value::Null).unwrap();
308+
left.merge_in_place(&right).unwrap();
309+
for (statistic, expected) in [
310+
(count(), 4.0),
311+
(SketchStatistic::Cardinality, 2.0),
312+
(SketchStatistic::FrequencyL2, 8.0_f64.sqrt()),
313+
(SketchStatistic::FrequencyEntropy, 1.0),
314+
] {
315+
let estimate = left.estimate(&statistic).unwrap();
316+
assert!(
317+
(estimate - expected).abs() < 0.01,
318+
"{keys:?} {statistic:?}: {estimate}"
319+
);
320+
}
321+
}
322+
}
323+
324+
// A non-finite sample or an unsupported identity type is an error.
325+
#[test]
326+
fn rejects_non_finite_and_untyped_identities() {
327+
use crate::values::Value;
328+
let mut state = UnivMonAccumulator::new(32, 5, 1024, 4).unwrap();
329+
assert!(state.insert_value(&Value::Float64(f64::INFINITY)).is_err());
330+
assert!(state.insert_value(&Value::Timestamp(1)).is_err());
331+
}
332+
333+
// Retained variable-length identities count toward the state's memory.
334+
#[test]
335+
fn memory_accounts_for_string_identities() {
336+
let mut state = UnivMonAccumulator::new(32, 5, 1024, 4).unwrap();
337+
let empty = state.approx_memory_bytes();
338+
state
339+
.insert_value(&crate::values::Value::Utf8("x".repeat(4096).into()))
340+
.unwrap();
341+
assert!(state.approx_memory_bytes() >= empty + 4096);
342+
state.clear();
343+
assert_eq!(state.approx_memory_bytes(), empty);
344+
}
251345
}

‎crates/asap-physical-operators/src/values.rs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ fn validate_state(family: &SummaryFamilyType, state: &dyn AggregateCore) -> Resu
277277
use crate::summary_kernels::{
278278
count_min_sketch::CountMinSketchAccumulator, datasketches_kll::DatasketchesKLLAccumulator,
279279
dd_sketch::DDSketchAccumulator, exact::ExactAccumulator, hll_sketch::HllSketchAccumulator,
280-
kmv::KmvAccumulator,
280+
kmv::KmvAccumulator, univmon::UnivMonAccumulator,
281281
};
282282
use planner_types::post_asap::SketchParams;
283283
validate_family(family)?;
@@ -328,6 +328,24 @@ fn validate_state(family: &SummaryFamilyType, state: &dyn AggregateCore) -> Resu
328328
.as_any()
329329
.downcast_ref::<KmvAccumulator>()
330330
.is_some_and(|s| s.inner.k == *k as usize),
331+
SketchParams::UnivMon {
332+
heap_size,
333+
sketch_rows,
334+
sketch_cols,
335+
layers,
336+
..
337+
} => state
338+
.as_any()
339+
.downcast_ref::<UnivMonAccumulator>()
340+
.is_some_and(|s| {
341+
s.dimensions()
342+
== (
343+
*heap_size as usize,
344+
*sketch_rows as usize,
345+
*sketch_cols as usize,
346+
usize::from(*layers),
347+
)
348+
}),
331349
SketchParams::Cms { width, depth } => state
332350
.as_any()
333351
.downcast_ref::<CountMinSketchAccumulator>()

0 commit comments

Comments
 (0)