Skip to content

space-saving: drop total from the state (new ASAPv1 kinds 0x18 0x01, 0x1e 0x01, 0x1f 0x01) - #258

Merged
zzylol merged 1 commit into
mainfrom
space-saving/drop-total
Oct 29, 2026
Merged

zzylol merged 1 commit into
mainfrom
space-saving/drop-total

Conversation

@zzylol

@zzylol zzylol commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Part of #250. Golden fixtures: ProjectASAP/sketchlib-golden-bytes#7 (merge first; the submodule pointer then moves to the merged commit).

Why

A Space-Saving summary answers with its (key, count, error) triples and its ceiling discarded_max. SpaceSaving also kept total, the weight it had recorded, and the ASAPv1 payloads of Space-Saving (§3.5), CountMinSketchWithSpaceSaving (§3.24) and CountSketchWithSpaceSaving (§3.25) carried it. No Space-Saving readout reads it: estimate, error, upper_bound, min_count, is_guaranteed and top_k read the triples and the ceiling. Its only uses were the total() accessor and a decode rule that total cover the counted weight. It is also not derivable from the state: after a merge the counts include the min_count each side adds for keys the other did not hold, and a trim drops counted weight.

The design decision for #250: sum and count are a separate exact aggregate, not sketch state. This PR is the Space-Saving part.

What

  • SpaceSaving's state is the counters, the buckets and discarded_max. The total field and total() are removed.
  • The decode check sum(count - error) <= total (§3.5 rule 10) is removed.
  • A kind_id mismatch error names the retired id too, as the DDSketch decoder does.
  • ASAPv1, per Q-VER: new kinds 0x18 0x01 (Space-Saving), 0x1e 0x01 (CountMinSketchWithSpaceSaving) and 0x1f 0x01 (CountSketchWithSpaceSaving), each payload without total. The retired 0x18 0x00 / 0x1e 0x00 / 0x1f 0x00 still decode, with total read and dropped, and are never encoded.
  • Golden fixtures for the new kinds: submodule bumped to the golden-bytes branch space-saving-nototal (f6fcfe7, off golden-bytes main).
  • Docs: wire format §1 registry, §3.5, §3.24, §3.25, Section 4's scalar list, Q-DERIVED; the Space-Saving, CountMinSketchWithSpaceSaving and CountSketchWithSpaceSaving API pages; message_pack_format.md; docs/tests.md; tests/PBT_COVERAGE.md; CHANGELOG.

Deleted public API (orphaned by this change):

Item Was read by
SpaceSaving::total() summary_wire::encode (the payload's total) and tests only

No estimate or bound changes: none of them read total. The capability table carries no Space-Saving Total row (main after #254).

How

  • space_saving.rs: the field, its updates in update_weighted, clear and merge, and the rebuild check are removed. SpaceSavingState and from_parts lose total.
  • space_saving/wire.rs: SpaceSavingPayload is [keys, counts, errors, discarded_max]. SpaceSavingPayloadWithTotal reads the retired five-field payload; read_payload converts it by dropping total. from_asapv1 accepts 0x18 0x01 or 0x18 0x00 and reads each with its own shape.
  • countminsketch_space_saving/summary_wire.rs: the same for the shared CMS / CS payload (SummaryTopKPayload, SummaryTopKPayloadWithTotal). SummaryKind gains retired_kind_id_with_total, so each of the two kinds decodes its own retired id.
  • New golden fixtures were generated by decoding each retired fixture and re-encoding it. Tests check both directions.

Key code interfaces added / modified

src/sketches/space_saving.rs

/// A Space-Saving summary over a fixed number of counters.
pub struct SpaceSaving<H: SketchHasher = DefaultXxHasher> {
    capacity: usize,             // unchanged
    monitored: Vec<MonitoredKey>, // unchanged
    buckets: Vec<Bucket>,        // unchanged
    bucket_free: Vec<usize>,     // unchanged
    bucket_head: usize,          // unchanged
    bucket_tail: usize,          // unchanged
    index: Index,                // unchanged
    // total: u64,               // removed
    /// Largest count known to have left the summary. // unchanged
    discarded_max: u64,
    _hasher: PhantomData<H>,
}

impl<H: SketchHasher> SpaceSaving<H> {
    /// Total weight recorded, monitored or displaced. // removed
    // pub fn total(&self) -> u64;
    /// Absorbs `other`; the merged counts can sum to more than the weight both
    /// streams recorded. // was: doc referred to `total`
    pub fn merge(&mut self, other: &Self);
}

src/sketches/space_saving/wire.rs

impl<H: SketchHasher + HashProfile> SpaceSaving<H> {
    /// Encodes kind 0x18 0x01: payload [keys, counts, errors, discarded_max].
    pub fn to_asapv1(&self) -> Result<Vec<u8>, RmpEncodeError>; // was kind 0x18 0x00 with total
    /// Decodes 0x18 0x01, or the retired 0x18 0x00 with its total read and dropped.
    pub fn from_asapv1(bytes: &[u8]) -> Result<Self, RmpDecodeError>; // was 0x18 0x00 only
}

CountMinSketchWithSpaceSaving / CountSketchWithSpaceSaving (.../wire.rs)

/// Encodes 0x1e 0x01 / 0x1f 0x01: [counts, keys, summary_counts, errors, discarded_max].
pub fn to_asapv1(&self) -> Result<Vec<u8>, RmpEncodeError>; // was 0x1e 0x00 / 0x1f 0x00 with total
/// Decodes the new id, or the retired one with total dropped.
pub fn from_asapv1(bytes: &[u8]) -> Result<Self, RmpDecodeError>; // was the 0x00 id only

src/asapv1/kind.rs (crate-private; the wire ids)

pub(crate) const SPACE_SAVING: &[u8] = &[0x18, 0x01]; // was [0x18, 0x00]
pub(crate) const SPACE_SAVING_WITH_TOTAL: &[u8] = &[0x18, 0x00]; // new: retired, decoded only
pub(crate) const COUNT_MIN_WITH_SPACE_SAVING: &[u8] = &[0x1e, 0x01]; // was [0x1e, 0x00]
pub(crate) const COUNT_MIN_WITH_SPACE_SAVING_WITH_TOTAL: &[u8] = &[0x1e, 0x00]; // new: retired
pub(crate) const COUNT_SKETCH_WITH_SPACE_SAVING: &[u8] = &[0x1f, 0x01]; // was [0x1f, 0x00]
pub(crate) const COUNT_SKETCH_WITH_SPACE_SAVING_WITH_TOTAL: &[u8] = &[0x1f, 0x00]; // new: retired

Wire format (docs/asapv1_wire_format.md)

kind_id Payload Status
0x18 0x01 [keys, counts, errors, discarded_max] new, encoded
0x18 0x00 [keys, counts, errors, total, discarded_max] retired: decoded, total dropped
0x1e 0x01 / 0x1f 0x01 [counts, keys, summary_counts, errors, discarded_max] new, encoded
0x1e 0x00 / 0x1f 0x00 [counts, keys, summary_counts, errors, total, discarded_max] retired: decoded, total dropped

Usage

A collector pane tracks heavy-hitter status codes and keeps the request count in its own exact counter. This was compiled and run as an integration test on this branch (at 0abfa05):

use asap_sketchlib::{DataInput, SpaceSaving};

// Status codes one collector sees in one pane.
let codes = [200u64, 200, 404, 200, 500, 200, 404, 301, 200, 503];
// Space-Saving (4 counters) for the heavy hitters ...
let mut summary: SpaceSaving = SpaceSaving::with_capacity(4);
// ... and a separate exact count for the pane's total weight, no longer summary state.
let mut requests = 0u64;
for code in codes {
    summary.update(&DataInput::U64(code));
    requests += 1;
}
// On the wire the summary is kind 0x18 0x01: triples and ceiling only.
let wire = summary.to_asapv1().unwrap();
assert_eq!(&wire[8..10], &[0x18, 0x01]);
let restored: SpaceSaving = SpaceSaving::from_asapv1(&wire).unwrap();
let top = restored.top_k(2);
assert_eq!(top, summary.top_k(2));
println!("top_k(2)={top:?} requests={requests}");
// The share of 200s divides by the exact count kept beside the summary.
let share = restored.estimate(&DataInput::U64(200)) as f64 / requests as f64;
println!("share of 200 <= {share}");
assert_eq!(restored.to_asapv1().unwrap(), wire);
// Printed: top_k(2)=[(U64(200), 5, 0), (U64(503), 2, 1)] requests=10
// Printed: share of 200 <= 0.5

Before this PR

SpaceSaving kept total, exposed total(), carried it on the wire (0x18 0x00, and inside 0x1e 0x00 / 0x1f 0x00) and rejected payloads whose total was under the counted weight.

After this PR

The summary is its triples and ceiling (0x18 0x01, 0x1e 0x01, 0x1f 0x01). Every estimate and bound is unchanged, and older bytes still decode.

Verification

Only the tests covering the changed code, plus format and lint, on 0abfa05 (rebased on main 21b6357):

  • cargo test --lib -- asapv1 wire_key envelope space_saving: 117 passed. --features experimental --lib -- space_saving asapv1: 109 passed.
  • cargo test --test asapv1_golden: 71 passed (default features), 72 passed (experimental).
  • cargo test --test pbt -- space_saving: 19 passed. cargo test --release --test e2e -- heavy space_saving wire: 117 passed. cargo test --test conformance_kit: 13 passed.
  • cargo test --test capability: 3 passed (default and experimental).
  • cargo fmt --check: clean. cargo clippy --all-targets and --all-targets --features experimental: no warnings.
  • New tests:
    • space_saving_decodes_the_retired_kind_with_total, the_retired_kind_with_total_decodes_and_drops_it: retired bytes decode for any total (including ones under the counted weight, which the old rule rejected) and re-encode under the new id; each id rejects the other's payload shape.
    • space_saving_retired_fixtures_decode_to_their_nototal_twins, and the CMS / CS golden tests: each retired fixture re-encodes as its *_nototal_* twin.
  • Removed tests: total_covers_the_recorded_weight_saturating and the total of 0 under the 9 crafted cases (the rule is gone). The e2e test a_merge_keeps_the_total_and_never_reads_low is renamed a_merge_never_reads_low, since it no longer checks a total.
  • Not run: the full pbt / e2e suites, and benchmarks. CI is not run on this draft.

Limitations and follow-ups

  • sketchlib-golden-bytes must merge the space-saving-nototal branch first, then this submodule pointer moves to the merged commit.
  • sketchlib-go has no Space-Saving codec (its sketches/SpaceSaving serializes with gob only); its wire/asapv1/kind.go registry gets the new ids (18 01, 1e 01, 1f 01; the 0x00 ids retired) in a separate sketchlib-go PR, stacked on its DDSketch registry branch.
  • Downstream pin bumps: any caller of SpaceSaving::total() keeps its own count.
  • Other sketches with the same pattern are tracked in Sketch state carries fields no estimate needs (DDSketch sum/min/max, Bloom inserted, Space-Saving total, UnivMon-Q min/max) #250.

🤖 Generated with Claude Code

…0x1e 0x01, 0x1f 0x01)

SpaceSaving no longer keeps the weight it recorded: no readout reads it and
after a merge it is not derivable from the counts. total() and the decode
rule that total cover the counted weight are removed. Space-Saving,
CountMinSketchWithSpaceSaving and CountSketchWithSpaceSaving encode under new
kinds without total; the retired 0x18 0x00 / 0x1e 0x00 / 0x1f 0x00 still
decode, total dropped, and are never encoded. Golden fixtures for the new
kinds; docs, API pages, tests.md and CHANGELOG updated.

Part of #250

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zzylol
zzylol marked this pull request as ready for review October 29, 2026 00:05
@zzylol
zzylol force-pushed the space-saving/drop-total branch from 0abfa05 to 624240b Compare October 29, 2026 00:05
@zzylol
zzylol merged commit b116019 into main Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant