feat(datatypes): add finite raw series frame codec (#9607)

I hereby agree to the terms of the [GreptimeDB
CLA](https://github.com/GreptimeTeam/.github/blob/main/CLA.md).

## Refer to a related PR or issue link (optional)

- Depends on: #9603 (`feat/narthex-raw-contract`).
- Base of: #9611 (`feat/narthex-raw-frame-stream`).
- The finite raw SeriesFrame codec remains independent of the Mito
producer in #9608.

## What's changed and what's your intention?

Adds an Arrow `RecordBatch` representation for the raw series chunks
defined in the stack, so subsequent
execution and transport code can exchange typed samples without
rebuilding every sample, plus the bounded
multirow encoder that packs raw series chunks into multi-row batches
directly.

**Codec.** The new `series::frame` module provides raw-frame schema
construction, chunk packing and
whole-batch unpacking, in encoded-key and explicit-label modes,
preserving explicit timestamps,
float/histogram/mixed payloads, nulls, field order and `is_last`.
Different fields may have different chunk
boundaries: packing splits at their union and retains Arrow views rather
than densifying the inputs.
`Samples::segments()` exposes the zero-copy dense segment view the codec
walks, with a public-call doctest, so
the codec can consume existing dense segments without a new segment type
or allocation. Decode validates
schema metadata, payload lengths, indices, histogram structure and
timestamps, and any malformed row fails the
entire unpacking call rather than returning a successful prefix.

**Bounded multirow encoding.** `RawSeriesFrameBuilder` buffers rows and
emits one batch when either bound is
reached (`DEFAULT_MAX_ROWS` 256, `DEFAULT_MAX_BYTES` 1 MiB); the
effective byte cap is stored as
`min(requested, i32::MAX)`, which keeps every cumulative Arrow offset
domain representable for any admitted
group. `RawSeriesChunkCursor` walks one already validated chunk at the
union of the field segment boundaries,
holds at most one pending measured row, and measures each row exactly
once; the pending row is retried after a
flush, so no row is dropped or split, and per-field offsets advance
against each field's original segment
length, which keeps nonzero-offset (sliced) sample arrays correct. The
byte measure dominates the encoder's
offset domains: key and timestamp bytes, list element counts and bitmap
bytes, and the visible extent of each
histogram table list column read from the rebased `value_offsets` view
rather than from retained capacity. It
is an internal batching work estimate — not an exact IPC size and not a
resident-memory or RSS cap — so one
individually legal row that exceeds the requested cap is still accepted
and emitted alone, because that slice
cannot be split into smaller legal rows.

**Histogram layout and sharing.** Row-local histogram indices are never
remapped; physical tables are
concatenated under cumulative row list offsets, so each row still
addresses its own table through a nonzero
slice of the child. A row group with a single Arrow constituent reuses
it by clone, which shares buffers and
keeps the single-row zero-copy and owner-drop behavior; a group with
several constituents is concatenated,
which copies. Both cases are asserted. The `Samples::into_dense` sharing
check compares the Arrow table view
(`ArrayData` identity) in addition to `Arc` identity, and the first
histogram batch's `ArrayData` is computed
lazily and cached: that is pointer-identity caching, not content
deduplication, and distinct buffers with
equal content are never merged. Local buffer sharing does not prevent a
full table from being encoded in every
batch; any future trimming or remapping belongs at the actual
IPC/network/spill boundary, reusing Arrow
slicing and selection and the existing writer, and preserving duplicate
indices without content hashing.

**Preserved interfaces.** `pack_raw_series_chunk` keeps its
`Vec<RecordBatch>` return type and
`unpack_raw_series_frame` keeps its signature; `RawSeriesFrameDecoder`
prepares the expected frame schema once
for a stream of batches, while the wrapper still builds a decoder per
call. The raw contract's `RawTimestamps`
validates strictly increasing source timestamps once at construction
behind a private buffer. The frozen `v1`
contract is untouched: the four reserved metadata entries, the column
names (`read_key`, `is_last`, `time`,
`fields`, plus `name`/`labels` in explicit mode) and the wire column
literal `read_key` stay as they were
(`primary_key` is only the Rust-side name of that buffer).
Histogram-only rows decode through
`Samples::try_from_histogram` directly, without a duplicate bounds
check.

**Tests.** The frame tests cover schema and metadata acceptance and
rejection, bit-exact payloads (including
NaN payloads and `-0.0`), sliced-payload owner lifetimes, row and byte
bounds (bounds taken from real measured
rows, flush-before-exceed with pending retry, legal oversized row),
row-local histogram indices and complete
tables with a real Arrow IPC round trip, decoder schema caching and
label-mode separation. The `samples` tests
pin both sides of the sharing rule: distinct wrappers of one Arrow table
view are reused, while distinct
buffers, distinct table slices, and shared value buffers with different
null-bitmap offsets are never merged.

**Limitations and boundaries.** Each histogram-bearing row carries its
complete histogram table, including
entries no index references, and a large table referenced by several
segments can be repeated per row; there
is no deduplication and no global reindex, so an encoded batch can
exceed the source sample bytes. These entry
points are exercised by unit tests, and the builder, cursor and decoder
are also used by the in-process stream
adapter of #9611, which is not yet wired into a production query path;
there is no transport, persistence or
query-performance change. This PR handles finite raw frames only: it
does not add stream-wide
key/order/EOF validation (that is #9611), evaluated frames, a Flight/IPC
execution path, logical-name
authentication, a native query consumer, a production query-path switch,
distributed or cross-region merging,
or a prepared producer. The frame preserves ordered logical field names
and rejects mismatched supplied
schemas; that is not authentication of label binding or data provenance.
Existing persisted storage formats
and query entrypoints are unchanged, and this is not a cross-version
wire-compatibility claim. Open and
unmerged; current-head CI has completed successfully.

### Bounded local Frame-boundary measurement (B baseline single-row, C
selected multirow)

Provenance: measured at the frozen local candidate
`eddd595b82888452b3c066678fabc6c1200c74f7`, whose
bounded-multirow finite codec content is
`3406a5334574a518924c67c319e2d92b6cac943b`, against the old public
baseline `36843e8cebeded1328dd7005ed668300a606f182`. The later normal
parent synchronization and the
strict-lint syntax-only fix are not separately rebenchmarked, so these
numbers describe that frozen local
content, not the pushed head; no remote CI or performance result is
implied by this local measurement. This
PR now implements the selected C direction and native A is unchanged:
the native direct path remains the
control and is not replaced.

A bounded local-only experiment measured this codec's role as the
pack/unpack kernel of that direction.
Fixture: six synthetic raw-series Frame-boundary cases in two sequential
campaigns (100000 series in the
first, 1000 in the second, one chunk per series in both), CPU affinity
2, seven repetitions, default warmup,
plain-timing and allocator-instrumented runs kept separate, no retries
and no excluded rounds. Stage wall
time from the plain runner, milliseconds, median only, for the six cases
in table order:

| Case | Series / rows per series | A0 native, baseline build | A1
native, candidate build | B baseline single-row | C selected multirow |
|---|---:|---:|---:|---:|---:|
| float2_key16 | 100000 / 2 | 42.313 | 43.520 | 2549.316 | 206.888 |
| float3_key16 | 100000 / 3 | 59.434 | 60.111 | 3261.311 | 260.933 |
| float3_key128 | 100000 / 3 | 64.968 | 62.999 | 3240.701 | 291.846 |
| long_float1024_key16 | 1000 / 1024 | 7.556 | 7.113 | 26.311 | 10.468 |
| histogram3_key16 | 1000 / 3 | 0.452 | 0.448 | 17.250 | 5.632 |
| multifield_float_hist3_key16 | 1000 / 3 | 0.593 | 0.588 | 25.194 |
6.328 |

The same run's full min–max, MAD, all seven rounds per role, allocation
counters, RSS, frame counts and
acceptance checks are in #9611, which carries the canonical full results
— including the frame totals counted
from the runs (B emitted 100000 / 100000 / 100000 / 1000 / 1000 / 1000
batches and C 391 / 391 / 391 / 17 /
4 / 4, with frame rows equal to series × chunks in every case and no
frames on the native path).

Reading: the A0 and A1 native control ranges overlap in every case, so
the native controls establish no
candidate-build difference, and the C/B range disjointness in this
bounded seven-round sample is descriptive
for these synthetic fixtures only. Relative to B, C lowers stage wall
time and allocation request counts while
raising phase peak-live bytes and whole-process RSS; that
batching-versus-retained-memory tradeoff of the
selected direction is accepted once, as part of that direction, and is
not a veto or an open design question
(largest observed increase: the long-float case, peak live Δ 2156848 B
versus 50438 B and lifetime RSS
9176 kB versus 6080 kB).

Scope limits, as in #9611: every SOURCE_KEY group uses one identical
explicit metric label set, so this is
not real PromQL label diversity or cardinality evidence; the histogram
table template is built outside the
measured phase; no `Samples::Mixed` inside one field. The measured scope
is local Frame generation, the codec
boundary, digests and phase-owned cleanup — not a Flight/IPC network
path, not a whole-query or
query-regression benchmark, not a storage or service measurement, and
not a CI result. No query speedup is
claimed from this codec or from the selected direction.

### Verification

Pushed head: `ba88f817cdcffeae6594943c55aaced2ffa769e5`, synchronized
normally with #9603 at `794389688084ee97a7760d1ae993bcf1c109489e`.

Checks on the exact frozen source recorded by this head:

- `cargo +1.98.1 check --locked -p datatypes --all-targets` — passed.
- `cargo +1.98.1 clippy --locked -p datatypes --all-targets
--all-features -- -D warnings` — passed with the official pinned 1.98.1
Clippy component.
- `cargo +1.98.1 nextest run --locked -p datatypes --retries 0
--no-fail-fast` — **363 passed, 0 skipped**.
- `cargo +1.98.1 test --locked -p datatypes --doc
series::samples::Samples::segments` — **1 passed**.
- Rust 1.99 formatting, TOML/license/typo checks, `git diff --check`,
and normal signed-off commit/push hooks passed. Source hashes were
verified unchanged after the hooks.

The final closeout only collapses the full-builder flush guard into a
short-circuit-preserving let-chain for strict Clippy; it changes no
batching behavior or assertion. The local measurements above remain
attributed to their frozen candidate. Current-head remote CI completed
successfully in all three workflows below. The PR remains open and
unmerged until the normal merge gates and parent-first integration are
checked.

## PR Checklist
Please convert it to a draft if some of the following conditions are not
met.

- [x] I have written the necessary rustdoc comments.
- [x] I have added the necessary unit tests and integration tests.
- [ ] This PR requires documentation updates.
- [x] API changes are backward compatible.
- [x] Schema or data changes are backward compatible.
- [ ] This PR needs to be backported to release branches, and I have
added the `backport-<target>` labels (e.g. `backport-v1.3` targets
`release/v1.3`).


### Verified current-head remote CI

Exact pushed head: `ba88f817cdcffeae6594943c55aaced2ffa769e5`.

- [Rust
CI](https://github.com/GreptimeTeam/greptimedb/actions/runs/38882922544)
— **success**.
-
[Checks](https://github.com/GreptimeTeam/greptimedb/actions/runs/38882922504)
— **success**.
- [Integration
CI](https://github.com/GreptimeTeam/greptimedb/actions/runs/38882922665)
— **success**.

Linux and Windows test jobs, strict Clippy, builds, SQLness and the
executed integration/fuzz jobs succeeded. Coverage and recent-release
compatibility jobs were skipped by their workflow conditions; they are
not claimed as executed. These CI results are independent of the frozen
local experiment provenance above.
This commit is contained in:
discord9
2026-10-16 19:09:57 +08:00
committed by GitHub
5 changed files with 4447 additions and 7 deletions
+1
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
pub mod frame;
pub mod histogram;
pub mod raw;
pub mod samples;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+52 -5
View File
@@ -199,6 +199,46 @@ impl Samples {
storage: SampleStorage::Chunks(flat.into(), total),
})
}
/// Returns the dense segments in logical order without allocating, copying payloads, or
/// densifying the samples.
///
/// Chunked samples expose their retained segment boundaries; different fields may therefore
/// have different boundaries. The logical start of each segment is the sum of the lengths of
/// all preceding segments, and the total length is [`Self::len`]. For non-chunked samples,
/// including empty samples, this returns a singleton slice containing `self`. Chunk
/// construction flattens nested chunks and drops empty segments; an all-empty input becomes
/// one empty float segment. Each segment is dense: [`Self::dense`] returns `Some` with its
/// typed Arrow view. Clone the samples cheaply if they need to outlive this borrow.
///
/// ```
/// use arrow::array::Float64Array;
/// use datatypes::series::samples::{DenseSamplesRef, Samples};
///
/// let samples = Samples::try_from_chunks(vec![
/// Samples::from_float(Float64Array::from(vec![1.0, 2.0])),
/// Samples::from_float(Float64Array::from(vec![3.0])),
/// ])
/// .unwrap();
///
/// let mut logical_start = 0;
/// let mut starts = Vec::new();
/// for segment in samples.segments() {
/// starts.push(logical_start);
/// match segment.dense().unwrap() {
/// DenseSamplesRef::Float(values) => assert_eq!(values.len(), segment.len()),
/// _ => unreachable!("these segments contain floats"),
/// }
/// logical_start += segment.len();
/// }
/// assert_eq!(starts, vec![0, 2]);
/// assert_eq!(logical_start, samples.len());
/// ```
pub fn segments(&self) -> &[Samples] {
match &self.storage {
SampleStorage::Chunks(chunks, _) => chunks,
_ => std::slice::from_ref(self),
}
}
/// Number of sample positions.
pub fn len(&self) -> usize {
match &self.storage {
@@ -315,8 +355,7 @@ impl Samples {
}
}
/// Materialize chunks into one dense array. When all histogram-bearing chunks reference the
/// same table, reuse it; otherwise concatenate tables per chunk and remap indices, including
/// unused rows.
/// same Arrow table view, reuse it; otherwise concatenate tables per chunk and remap indices.
pub fn into_dense(self) -> Result<Self> {
let SampleStorage::Chunks(chunks, total_len) = self.storage else {
return Ok(self);
@@ -350,9 +389,17 @@ impl Samples {
SampleStorage::Mixed(mixed) => Some(&mixed.histograms.batch),
SampleStorage::Float(_) | SampleStorage::Chunks(_, _) => None,
});
let shared_batch = histogram_batches
.next()
.filter(|first| histogram_batches.all(|batch| Arc::ptr_eq(first, batch)));
let mut first_data = None;
let shared_batch = histogram_batches.next().filter(|first| {
histogram_batches.all(|batch| {
if Arc::ptr_eq(first, batch) {
return true;
}
first_data
.get_or_insert_with(|| first.as_arrow().to_data())
.ptr_eq(&batch.as_arrow().to_data())
})
});
let needs_float = chunks.iter().any(|chunk| {
matches!(
chunk.storage,
+304 -2
View File
@@ -15,8 +15,8 @@
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use arrow::array::{Float64Array, UInt32Array};
use arrow::buffer::{Buffer, ScalarBuffer};
use arrow::array::{ArrayRef, Float64Array, Int64Array, StructArray, UInt32Array};
use arrow::buffer::{BooleanBuffer, Buffer, NullBuffer, ScalarBuffer};
use crate::series::histogram::{BucketSideView, CounterResetHint, HistogramBatchBuilder};
@@ -235,6 +235,308 @@ fn chunk_iterator_is_sequential_exact_and_handles_many_singletons() {
assert!(iter.next().is_none());
}
#[test]
fn densification_reuses_distinct_wrappers_of_the_same_arrow_table_view() {
let base_row = HistogramView {
schema: 2,
zero_threshold: 0.25,
sum: 7.0,
count: 8.0,
zero_count: 1.0,
reset_hint: CounterResetHint::Gauge,
start_timestamp: None,
custom_values: &[],
positive: BucketSideView {
span_offsets: &[0],
span_lengths: &[1],
buckets: &[9.0],
},
negative: BucketSideView {
span_offsets: &[],
span_lengths: &[],
buckets: &[],
},
};
let rows = [
HistogramView {
schema: 1,
zero_threshold: 0.0,
sum: 1.0,
count: 2.0,
zero_count: 0.0,
reset_hint: CounterResetHint::Unknown,
start_timestamp: Some(11),
custom_values: &[1.0],
positive: BucketSideView {
span_offsets: &[0],
span_lengths: &[1],
buckets: &[3.0],
},
negative: BucketSideView {
span_offsets: &[],
span_lengths: &[],
buckets: &[],
},
},
HistogramView {
sum: -0.0,
count: f64::NAN,
start_timestamp: Some(42),
custom_values: &[f64::from_bits(0x7ff8_0000_0000_0042)],
..base_row
},
];
let backing = histogram_batch(&[rows[0], rows[1], rows[1]]);
let original =
Arc::new(HistogramBatch::try_from_arrow(backing.as_arrow().slice(1, 1)).unwrap());
let wrapped = Arc::new(HistogramBatch::try_from_arrow(original.as_arrow().clone()).unwrap());
assert!(!Arc::ptr_eq(&original, &wrapped));
assert!(
original
.as_arrow()
.to_data()
.ptr_eq(&wrapped.as_arrow().to_data())
);
let mixed = Samples::try_from_mixed(
Float64Array::from(vec![
Some(-0.0),
Some(f64::from_bits(0x7ff8_0000_0000_0042)),
None,
]),
HistogramSamples::try_new(UInt32Array::from(vec![None, None, Some(0)]), wrapped).unwrap(),
)
.unwrap();
let dense = Samples::try_from_chunks(vec![
Samples::try_from_histogram(UInt32Array::from(vec![Some(0), None]), original.clone())
.unwrap(),
mixed,
])
.unwrap()
.into_dense()
.unwrap();
let DenseSamplesRef::Mixed(values) = dense.dense().unwrap() else {
panic!()
};
assert_eq!(values.histograms.batch().len(), 1);
assert!(Arc::ptr_eq(values.histograms.batch(), &original));
assert_eq!(values.histograms.indices().value(0), 0);
assert!(values.histograms.indices().is_null(1));
assert!(values.histograms.indices().is_null(2));
assert!(values.histograms.indices().is_null(3));
assert_eq!(values.histograms.indices().value(4), 0);
let expected = [
SampleRef::Histogram(original.view(0).unwrap()),
SampleRef::Missing,
SampleRef::Float(-0.0),
SampleRef::Float(f64::from_bits(0x7ff8_0000_0000_0042)),
SampleRef::Histogram(original.view(0).unwrap()),
];
for (index, expected) in expected.into_iter().enumerate() {
assert!(
same_sample(dense.sample(index), expected),
"position {index}"
);
}
}
#[test]
fn densification_does_not_merge_equal_content_distinct_buffers() {
let first = batch(2.0, 3.0, &[4.0]);
let equal = batch(2.0, 3.0, &[4.0]);
assert!(
!first
.as_arrow()
.to_data()
.ptr_eq(&equal.as_arrow().to_data())
);
let dense = Samples::try_from_chunks(vec![
Samples::try_from_histogram(UInt32Array::from(vec![Some(0)]), first.clone()).unwrap(),
Samples::try_from_histogram(UInt32Array::from(vec![Some(0)]), equal.clone()).unwrap(),
])
.unwrap()
.into_dense()
.unwrap();
let DenseSamplesRef::Histogram(values) = dense.dense().unwrap() else {
panic!()
};
assert_eq!(values.batch().len(), 2);
assert_eq!(values.indices().value(0), 0);
assert_eq!(values.indices().value(1), 1);
assert!(same_sample(
dense.sample(0),
SampleRef::Histogram(first.view(0).unwrap())
));
assert!(same_sample(
dense.sample(1),
SampleRef::Histogram(equal.view(0).unwrap())
));
}
#[test]
fn densification_does_not_merge_distinct_table_slices() {
let base_row = HistogramView {
schema: 1,
zero_threshold: 0.0,
sum: 2.0,
count: 3.0,
zero_count: 0.0,
reset_hint: CounterResetHint::Unknown,
start_timestamp: None,
custom_values: &[],
positive: BucketSideView {
span_offsets: &[0],
span_lengths: &[1],
buckets: &[4.0],
},
negative: BucketSideView {
span_offsets: &[],
span_lengths: &[],
buckets: &[],
},
};
let rows = [
base_row,
HistogramView {
start_timestamp: Some(42),
sum: 12.0,
..base_row
},
HistogramView {
start_timestamp: Some(99),
sum: 19.0,
..base_row
},
];
let backing = histogram_batch(&rows);
let first = Arc::new(HistogramBatch::try_from_arrow(backing.as_arrow().slice(0, 1)).unwrap());
let equal_len_other_offset =
Arc::new(HistogramBatch::try_from_arrow(backing.as_arrow().slice(1, 1)).unwrap());
let longer_other_offset =
Arc::new(HistogramBatch::try_from_arrow(backing.as_arrow().slice(1, 2)).unwrap());
for (second, expected_table_len, second_expected) in [
(
equal_len_other_offset,
2,
SampleRef::Histogram(backing.view(1).unwrap()),
),
(
longer_other_offset,
3,
SampleRef::Histogram(backing.view(1).unwrap()),
),
] {
let dense = Samples::try_from_chunks(vec![
Samples::try_from_histogram(UInt32Array::from(vec![Some(0)]), first.clone()).unwrap(),
Samples::try_from_histogram(UInt32Array::from(vec![Some(0)]), second).unwrap(),
])
.unwrap()
.into_dense()
.unwrap();
let DenseSamplesRef::Histogram(values) = dense.dense().unwrap() else {
panic!()
};
assert_eq!(values.batch().len(), expected_table_len);
assert_eq!(values.indices().value(0), 0);
assert_eq!(values.indices().value(1), 1);
assert!(same_sample(
dense.sample(0),
SampleRef::Histogram(first.view(0).unwrap())
));
assert!(same_sample(dense.sample(1), second_expected));
}
}
#[test]
fn densification_does_not_merge_shared_value_buffer_with_different_null_bitmap_offsets() {
let source = batch(2.0, 3.0, &[4.0]);
let start_values = ScalarBuffer::from(vec![42_i64]);
let bitmap = Buffer::from(vec![0b10_u8]);
let missing_starts = Int64Array::new(
start_values.clone(),
Some(NullBuffer::new(BooleanBuffer::new(bitmap.clone(), 0, 1))),
);
let present_starts = Int64Array::new(
start_values.clone(),
Some(NullBuffer::new(BooleanBuffer::new(bitmap.clone(), 1, 1))),
);
assert_eq!(
missing_starts.values().inner().as_ptr(),
present_starts.values().inner().as_ptr()
);
let missing_nulls = missing_starts.nulls().unwrap();
let present_nulls = present_starts.nulls().unwrap();
assert_eq!(
missing_nulls.buffer().as_ptr(),
present_nulls.buffer().as_ptr()
);
assert_ne!(missing_nulls.offset(), present_nulls.offset());
assert!(missing_starts.is_null(0));
assert_eq!(present_starts.value(0), 42);
let with_start = |starts: Int64Array| {
let mut columns = source.as_arrow().columns().to_vec();
columns[6] = Arc::new(starts) as ArrayRef;
Arc::new(
HistogramBatch::try_from_arrow(
StructArray::try_new(source.as_arrow().fields().clone(), columns, None).unwrap(),
)
.unwrap(),
)
};
let missing = with_start(missing_starts);
let present = with_start(present_starts);
let missing_starts = missing
.as_arrow()
.column(6)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
let present_starts = present
.as_arrow()
.column(6)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
assert_eq!(
missing_starts.values().inner().as_ptr(),
present_starts.values().inner().as_ptr()
);
assert_eq!(
missing_starts.nulls().unwrap().buffer().as_ptr(),
present_starts.nulls().unwrap().buffer().as_ptr()
);
assert_ne!(
missing_starts.nulls().unwrap().offset(),
present_starts.nulls().unwrap().offset()
);
assert!(missing_starts.is_null(0));
assert_eq!(present_starts.value(0), 42);
assert!(
!missing
.as_arrow()
.to_data()
.ptr_eq(&present.as_arrow().to_data())
);
let dense = Samples::try_from_chunks(vec![
Samples::try_from_histogram(UInt32Array::from(vec![Some(0)]), missing.clone()).unwrap(),
Samples::try_from_histogram(UInt32Array::from(vec![Some(0)]), present.clone()).unwrap(),
])
.unwrap()
.into_dense()
.unwrap();
assert_eq!(dense.len(), 2);
assert!(same_sample(
dense.sample(0),
SampleRef::Histogram(missing.view(0).unwrap())
));
assert!(same_sample(
dense.sample(1),
SampleRef::Histogram(present.view(0).unwrap())
));
}
#[test]
fn densifying_slices_keeps_shared_histogram_table() {
let table = batch(2.0, 3.0, &[4.0, 5.0]);