@@ -214,7 +214,11 @@ fn json_to_value(v: serde_json::Value) -> Value {
214214 . cloned ( )
215215 . map ( json_to_value)
216216 . collect ( ) ;
217- Value :: ArrayCell ( ArrayCell { coords, attrs } )
217+ Value :: ArrayCell ( ArrayCell {
218+ coords,
219+ attrs,
220+ system_time : None ,
221+ } )
218222 }
219223 serde_json:: Value :: Number ( n) => {
220224 if let Some ( i) = n. as_i64 ( ) {
@@ -264,7 +268,7 @@ fn slice_returns_only_cells_in_range() {
264268 limit : 0 ,
265269 cell_filter : None ,
266270 hilbert_range : None ,
267- system_as_of : None ,
271+ system_time : nodedb_types :: SystemTimeScope :: Current ,
268272 valid_at_ms : None ,
269273 } ) ;
270274 assert_eq ! ( r. status, Status :: Ok , "slice failed: {r:?}" ) ;
@@ -283,6 +287,79 @@ fn slice_returns_only_cells_in_range() {
283287 assert ! ( ( sums - 7.0 ) . abs( ) < 1e-9 ) ;
284288}
285289
290+ #[ test]
291+ fn slice_all_versions_emits_audit_log_ascending ( ) {
292+ let mut h = Harness :: new ( ) ;
293+ let s = schema_2d_f64 ( "t6_audit" ) ;
294+ let aid = ArrayId :: new ( TenantId :: new ( 1 ) , "t6_audit" ) ;
295+ h. open ( & aid, & s, 0xA7 ) ;
296+
297+ // Three system-time versions of the same cell (0,0), each sealed into
298+ // its own segment so they are distinct retained tile-versions.
299+ let mk = |v : f64 , sys : i64 | ArrayPutCell {
300+ coord : vec ! [ CoordValue :: Int64 ( 0 ) , CoordValue :: Int64 ( 0 ) ] ,
301+ attrs : vec ! [ CellValue :: Float64 ( v) ] ,
302+ surrogate : Surrogate :: ZERO ,
303+ system_from_ms : sys,
304+ valid_from_ms : 0 ,
305+ valid_until_ms : i64:: MAX ,
306+ } ;
307+ h. put ( & aid, vec ! [ mk( 1.0 , 100 ) ] , 1 ) ;
308+ h. flush ( & aid) ;
309+ h. put ( & aid, vec ! [ mk( 2.0 , 200 ) ] , 2 ) ;
310+ h. flush ( & aid) ;
311+ h. put ( & aid, vec ! [ mk( 3.0 , 300 ) ] , 3 ) ;
312+ h. flush ( & aid) ;
313+
314+ let slice = ArraySlice :: new ( vec ! [ None , None ] ) ;
315+ let slice_bytes = zerompk:: to_msgpack_vec ( & slice) . unwrap ( ) ;
316+ let r = h. send ( ArrayOp :: Slice {
317+ array_id : aid. clone ( ) ,
318+ slice_msgpack : slice_bytes,
319+ attr_projection : vec ! [ ] ,
320+ limit : 0 ,
321+ cell_filter : None ,
322+ hilbert_range : None ,
323+ system_time : nodedb_types:: SystemTimeScope :: AllVersions ,
324+ valid_at_ms : None ,
325+ } ) ;
326+ assert_eq ! ( r. status, Status :: Ok , "all-versions slice failed: {r:?}" ) ;
327+
328+ // Read rows as raw JSON: `json_to_value` drops the injected `_ts_system`
329+ // column, so inspect the envelope directly.
330+ use crate :: data:: executor:: response_codec:: ArraySliceResponse ;
331+ let env: ArraySliceResponse =
332+ zerompk:: from_msgpack ( r. payload . as_ref ( ) ) . expect ( "ArraySliceResponse envelope" ) ;
333+ assert ! (
334+ !env. truncated_before_horizon,
335+ "AllVersions applies no system-time horizon"
336+ ) ;
337+ let json = nodedb_types:: msgpack_to_json_string ( & env. rows_msgpack ) . expect ( "rows msgpack→json" ) ;
338+ let rows: Vec < serde_json:: Value > = serde_json:: from_str ( & json) . expect ( "rows json parse" ) ;
339+
340+ let got: Vec < ( i64 , f64 ) > = rows
341+ . iter ( )
342+ . map ( |row| {
343+ let ts = row
344+ . get ( "_ts_system" )
345+ . and_then ( |v| v. as_i64 ( ) )
346+ . expect ( "_ts_system column present on audit-log rows" ) ;
347+ let v = row
348+ . get ( "attrs" )
349+ . and_then ( |a| a. as_array ( ) )
350+ . and_then ( |a| a. first ( ) )
351+ . and_then ( |v| v. as_f64 ( ) )
352+ . expect ( "attr value" ) ;
353+ ( ts, v)
354+ } )
355+ . collect ( ) ;
356+ assert_eq ! (
357+ got,
358+ vec![ ( 100 , 1.0 ) , ( 200 , 2.0 ) , ( 300 , 3.0 ) ] ,
359+ "audit log must return every version ascending by system time"
360+ ) ;
361+ }
362+
286363#[ test]
287364fn aggregate_sum_scalar_across_multiple_tiles ( ) {
288365 let mut h = Harness :: new ( ) ;
@@ -317,6 +394,127 @@ fn aggregate_sum_scalar_across_multiple_tiles() {
317394 assert ! ( ( f - 10.0 ) . abs( ) < 1e-9 , "sum got {f}" ) ;
318395}
319396
397+ #[ test]
398+ fn aggregate_return_partial_emits_tuple_wire_shape ( ) {
399+ // Distributed shard aggregates set `return_partial = true`. The Data Plane
400+ // MUST emit the `(partials, truncated_before_horizon)` tuple — the same
401+ // shape the bitemporal path uses — so the cluster `exec_agg` (which always
402+ // decodes a tuple) can read non-temporal aggregates too. A bare `Vec` here
403+ // (the old `encode_partials` shape) would fail that decode.
404+ let mut h = Harness :: new ( ) ;
405+ let s = schema_2d_f64 ( "t6_partial" ) ;
406+ let aid = ArrayId :: new ( TenantId :: new ( 1 ) , "t6_partial" ) ;
407+ h. open ( & aid, & s, 0xA9 ) ;
408+ h. put ( & aid, vec ! [ cell( 0 , 0 , 1.0 ) , cell( 1 , 1 , 2.0 ) ] , 1 ) ;
409+
410+ let r = h. send ( ArrayOp :: Aggregate {
411+ array_id : aid. clone ( ) ,
412+ attr_idx : 0 ,
413+ reducer : ArrayReducer :: Sum ,
414+ group_by_dim : -1 ,
415+ cell_filter : None ,
416+ return_partial : true ,
417+ hilbert_range : None ,
418+ system_as_of : None ,
419+ valid_at_ms : None ,
420+ } ) ;
421+ assert_eq ! ( r. status, Status :: Ok , "agg failed: {r:?}" ) ;
422+
423+ // Must decode as a 2-tuple (partials, flag), NOT a bare Vec.
424+ let ( partials, truncated) : (
425+ Vec < nodedb_cluster:: distributed_array:: merge:: ArrayAggPartial > ,
426+ bool ,
427+ ) = zerompk:: from_msgpack ( r. payload . as_ref ( ) )
428+ . expect ( "return_partial payload must be a (Vec<ArrayAggPartial>, bool) tuple" ) ;
429+ assert_eq ! ( partials. len( ) , 1 ) ;
430+ assert ! (
431+ ( partials[ 0 ] . sum - 3.0 ) . abs( ) < 1e-9 ,
432+ "sum got {}" ,
433+ partials[ 0 ] . sum
434+ ) ;
435+ assert ! (
436+ !truncated,
437+ "non-temporal current-state read is never below-horizon"
438+ ) ;
439+ }
440+
441+ #[ test]
442+ fn aggregate_temporal_appends_horizon_summary_row ( ) {
443+ // Temporal aggregates surface the below-horizon signal as a trailing
444+ // {"truncated_before_horizon": bool} summary row — the same shape the
445+ // cluster `finalize_agg_partials` produces. Non-temporal aggregates (tested
446+ // above) carry no such row, so the two modes stay distinguishable.
447+ let mut h = Harness :: new ( ) ;
448+ let s = schema_2d_f64 ( "t6_agg_ts" ) ;
449+ let aid = ArrayId :: new ( TenantId :: new ( 1 ) , "t6_agg_ts" ) ;
450+ h. open ( & aid, & s, 0xAB ) ;
451+ let mk = |x : i64 , y : i64 , v : f64 , sys : i64 | ArrayPutCell {
452+ coord : vec ! [ CoordValue :: Int64 ( x) , CoordValue :: Int64 ( y) ] ,
453+ attrs : vec ! [ CellValue :: Float64 ( v) ] ,
454+ surrogate : Surrogate :: ZERO ,
455+ system_from_ms : sys,
456+ valid_from_ms : 0 ,
457+ valid_until_ms : i64:: MAX ,
458+ } ;
459+ h. put ( & aid, vec ! [ mk( 0 , 0 , 10.0 , 100 ) , mk( 1 , 1 , 20.0 , 100 ) ] , 1 ) ;
460+ h. flush ( & aid) ;
461+
462+ // AS OF after the writes: both cells visible, not below horizon.
463+ let r = h. send ( ArrayOp :: Aggregate {
464+ array_id : aid. clone ( ) ,
465+ attr_idx : 0 ,
466+ reducer : ArrayReducer :: Sum ,
467+ group_by_dim : -1 ,
468+ cell_filter : None ,
469+ return_partial : false ,
470+ hilbert_range : None ,
471+ system_as_of : Some ( 150 ) ,
472+ valid_at_ms : None ,
473+ } ) ;
474+ assert_eq ! ( r. status, Status :: Ok , "temporal agg failed: {r:?}" ) ;
475+ let rows = decode_agg_rows ( r. payload . as_ref ( ) ) ;
476+ assert_eq ! (
477+ rows. len( ) ,
478+ 2 ,
479+ "temporal scalar agg = result row + summary row"
480+ ) ;
481+ let sum = rows[ 0 ]
482+ . get ( "result" )
483+ . and_then ( |v| v. as_f64 ( ) )
484+ . expect ( "result" ) ;
485+ assert ! ( ( sum - 30.0 ) . abs( ) < 1e-9 , "sum got {sum}" ) ;
486+ assert_eq ! (
487+ rows[ 1 ]
488+ . get( "truncated_before_horizon" )
489+ . and_then( |v| v. as_bool( ) ) ,
490+ Some ( false ) ,
491+ "cutoff after all data is not below-horizon"
492+ ) ;
493+
494+ // AS OF before the writes: below horizon → trailing flag is true.
495+ let r2 = h. send ( ArrayOp :: Aggregate {
496+ array_id : aid. clone ( ) ,
497+ attr_idx : 0 ,
498+ reducer : ArrayReducer :: Sum ,
499+ group_by_dim : -1 ,
500+ cell_filter : None ,
501+ return_partial : false ,
502+ hilbert_range : None ,
503+ system_as_of : Some ( 50 ) ,
504+ valid_at_ms : None ,
505+ } ) ;
506+ assert_eq ! ( r2. status, Status :: Ok , "below-horizon agg failed: {r2:?}" ) ;
507+ let rows2 = decode_agg_rows ( r2. payload . as_ref ( ) ) ;
508+ assert_eq ! (
509+ rows2
510+ . last( )
511+ . and_then( |row| row. get( "truncated_before_horizon" ) )
512+ . and_then( |v| v. as_bool( ) ) ,
513+ Some ( true ) ,
514+ "cutoff before all data must report below-horizon"
515+ ) ;
516+ }
517+
320518#[ test]
321519fn aggregate_group_by_dim_buckets_per_x ( ) {
322520 let mut h = Harness :: new ( ) ;
@@ -428,7 +626,7 @@ fn slice_cell_filter_excludes_non_member_surrogates() {
428626 limit : 0 ,
429627 cell_filter : Some ( bm) ,
430628 hilbert_range : None ,
431- system_as_of : None ,
629+ system_time : nodedb_types :: SystemTimeScope :: Current ,
432630 valid_at_ms : None ,
433631 } ) ;
434632 assert_eq ! ( r. status, Status :: Ok , "slice+filter failed: {r:?}" ) ;
0 commit comments