Skip to content

refactor: make the fixed-point price encoding internal#823

Merged
userFRM merged 3 commits into
mainfrom
fix/price-out-of-range-safety
Jun 16, 2026
Merged

refactor: make the fixed-point price encoding internal#823
userFRM merged 3 commits into
mainfrom
fix/price-out-of-range-safety

Conversation

@userFRM

@userFRM userFRM commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Summary

A price is encoded as a (value, price_type) mantissa-and-exponent pair where the real price is value * 10^(price_type - 10). That variable-precision fixed-point encoding is a wire-transport detail. A client of this crate receives decoded prices (f64 dollars on the tick rows, via to_f64), so it must never see, set, or reason about the raw (value, price_type) pair. This PR makes the encoding internal: it removes Price, PriceType, PriceError, and MAX_PRICE_TYPE from the public API, and it hardens the exponent so only the validated internal decode path can construct a Price.

Make the encoding internal

Price and its supporting types were re-exported on the curated public surface (thetadatadx::Price, thetadatadx::PriceType, thetadatadx::PriceError, thetadatadx::MAX_PRICE_TYPE) and on the internal tdbe / tdbe::types facades. None of that is needed by a client: every public tick row already carries the decoded f64 price, and no public function or binding accepts or returns a Price. The pair never crosses a binding boundary.

These types are now gated behind the __internal feature (doc-hidden, with the same "NOT a stable public surface" contract as the other wire-internal re-exports), so they stay reachable for workspace tools, bindings, and the data-format benches but are absent from the default public API and from rendered rustdoc. The decode layer reaches them through the full tdbe::types::price leaf path, never a short facade alias, so the encoding cannot drift back onto the public surface through a convenience re-export. The price_type tick-schema column is a positional wire-layout field the parser reads and discards; it was already not a client-facing struct field in any binding, so no tick row exposes the raw exponent.

Parse, don't validate: the exponent is unrepresentable out of range

The decimal exponent is a validated PriceType newtype over 0..=MAX_PRICE_TYPE. Its only constructors check the range:

  • PriceType::new(i32) -> Result<_, PriceError> / TryFrom<i32> rejects out of range with a typed error.
  • PriceType::saturating(i32) is infallible, snapping into range at the nearest boundary; it backs the lossy Price::new.

Because an out-of-range exponent cannot be constructed, exp = price_type - 10 is confined to -10..=9 and exp.unsigned_abs() indexes the precomputed POW10_I64 / POW10_F64 tables in bounds by construction. The per-read clamps in to_f64, Display, and compare are gone; the table access is provably in range with no runtime check and no path that can observe or fabricate a result from an out-of-range exponent. The earlier panic class is eliminated because only the validated decode boundary constructs a Price: the wire decode path (FPSS / MDDS) constructs through the fallible Price::with_value_and_type, which threads PriceType::new and rejects out-of-range cells exactly as before; Price::new keeps its saturating contract via PriceType::saturating so existing infallible callers stay panic-free.

Why this is ABI-safe

Price is a pure-Rust internal type, with evidence:

  • No #[repr(C)], no layout assertion, no bytemuck / zerocopy traits on Price.
  • No C / C++ / Python / TypeScript binding reads Price's raw fields. The C ABI exposes PriceTick, which carries a decoded double price, not Price. The layout-assert suite covers PriceTick, not Price.
  • Bindings obtain the price exclusively as the decoded f64 via to_f64; the (value, price_type) pair never crosses a binding boundary.

So removing the public re-export and changing the field type from i32 to PriceType cannot break any ABI. Verified: thetadatadx-ffi, thetadatadx-py, and thetadatadx-napi all build against the default-feature crate; the tick layout-assert suite (incl. price_tick_layout) passes unchanged.

Tests

  • price_type_rejects_out_of_range accepts 0..=19 and rejects -1, 20, 99, i32::MIN, i32::MAX; out-of-range is unrepresentable.
  • price_type_saturating_clamps_to_boundaries snaps at both ends and leaves in-range values untouched.
  • every_price_type_indexes_tables_in_bounds and every_valid_price_type_renders_and_converts_finitely pin that every representable exponent indexes the tables in bounds and converts finitely.
  • in_range_conversions_are_unchanged pins value=12345/type=8 -> 123.45, 5/12 -> 500.0, 100/10 -> 100.0 for both to_f64 and Display.
  • with_value_and_type_enforces_range, accessors_match_fields, is_unset_vs_is_zero_value, new_saturates_out_of_range_price_type retained.

Verification: cargo fmt --all -- --check; cargo test -p thetadatadx --lib (815 passed); FFI + Python + TypeScript binding crates build; binding-parity check clean; git grep confirms the default public surface no longer names Price / PriceType / PriceError / MAX_PRICE_TYPE.

Price exposed pub value and pub price_type, letting a caller construct thetadatadx::Price with a price_type outside the 0..=MAX_PRICE_TYPE range that to_f64, Display, and the Ord comparison all rely on. The encoded exponent price_type - 10 indexes the 20-entry POW10 tables; a price_type of 30 or a negative price_type produced an out-of-bounds index that panicked under release array bounds-checking, and a debug_assert did not guard release builds.

Make the raw fields crate-private so the range invariant can only be established through the validating constructors new and with_value_and_type; readers use the existing value and price_type accessors. As defense in depth, clamp price_type into the table range on every read path so conversion, formatting, and comparison saturate to a well-defined result instead of indexing out of bounds, even for an internally constructed odd value. In-range values are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@userFRM userFRM enabled auto-merge (squash) June 16, 2026 14:42
@userFRM userFRM disabled auto-merge June 16, 2026 14:58
Encode the decimal exponent as a validated PriceType newtype over 0..=MAX_PRICE_TYPE whose only constructors check the range, so an out-of-range price_type cannot be represented. The conversion and comparison paths index the POW10 tables with an exponent that is in bounds by construction, which removes the per-read clamp helper and its three call sites; no read path can silently produce a wrong-but-finite price for an exponent the type forbids.

The wire decode boundary already constructs through the fallible with_value_and_type, which now threads PriceType::new and rejects out-of-range cells as before. Price::new keeps its saturating contract via PriceType::saturating so existing infallible callers stay panic-free. The public price_type() getter still returns i32, so readers are unchanged.

Price is a pure-Rust internal type: it carries no repr(C), no layout assertion, and never crosses the FFI or language-binding boundary as raw fields (bindings consume the decoded f64 via to_f64), so changing the field type from i32 to PriceType breaks no ABI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@userFRM userFRM changed the title fix: keep Price conversion and comparison total for any price_type fix: make Price decimal exponent unrepresentable when out of range Jun 16, 2026
A price is encoded as a (value, price_type) mantissa-and-exponent pair where the real price is value * 10^(price_type - 10). That variable-precision fixed-point encoding is a wire-transport detail: a client receives decoded prices (f64 dollars on the tick rows) and never sees, sets, or reasons about the raw pair. The encoding was leaking through the public crate surface.

Remove Price, PriceType, PriceError, and MAX_PRICE_TYPE from the curated public API and from the tdbe / tdbe::types facades, gating them behind the __internal feature (doc-hidden) so they stay reachable for workspace tools, bindings, and the data-format benches but are absent from the default public surface and rendered rustdoc. The decode layer reaches them through the full tdbe::types::price leaf path, never a short facade alias, so the encoding cannot drift back onto the public surface through a convenience re-export.

No public function or binding accepts or returns a Price; every public tick row already carries the decoded f64 price, so nothing public breaks. The price_type tick-schema column is a positional wire-layout field the parser reads and discards, already not a client-facing struct field in any binding.

The exponent stays a validated PriceType newtype over 0..=MAX_PRICE_TYPE, so an out-of-range exponent is unrepresentable and the POW10 table indexing needs no per-read range check. The earlier panic class is eliminated because only the validated internal decode path (Price::with_value_and_type, threading PriceType::new) constructs a Price.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@userFRM userFRM changed the title fix: make Price decimal exponent unrepresentable when out of range refactor: make the fixed-point price encoding internal Jun 16, 2026
@userFRM userFRM enabled auto-merge (squash) June 16, 2026 15:33
@userFRM userFRM merged commit 0078709 into main Jun 16, 2026
43 of 44 checks passed
@userFRM userFRM deleted the fix/price-out-of-range-safety branch June 16, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants