//! Property data: the row-major quantized feature matrix plus the side tables //! (addresses, postcodes, renovation/price history, POI metrics) built from the //! properties + postcode parquet files. //! //! Split by concern: //! - [`loading`]: parquet ingestion, validation, spatial sort and matrix build //! - [`stats`]: histograms, percentiles and slider-bound computation //! - [`quant`]: u16 quantization encode/decode //! - [`poi_metrics`]: postcode-level POI metric side table //! - [`address_search`]: address tokenization, indexing and ranked search //! - [`h3`]: H3 cell precomputation mod address_search; mod h3; mod loading; mod poi_metrics; mod quant; mod stats; pub use h3::precompute_h3; pub use poi_metrics::PostcodePoiMetrics; pub use quant::QuantRef; pub use stats::{FeatureStats, Histogram}; use rustc_hash::FxHashMap; use serde::Serialize; use crate::consts::NAN_U16; use crate::data::spill::SpillVec; #[derive(Serialize, Clone)] pub struct RenovationEvent { pub year: i32, pub event: String, } #[derive(Serialize, Clone)] pub struct HistoricalPrice { pub year: i32, pub month: u8, pub price: i64, /// Whether this specific sale was a new-build transfer (PPD `old_new == "Y"`). /// Defaults to `false` for older parquet that predates the per-sale flag. pub is_new: bool, } /// A point on the property's tenure timeline: the year a certificate first /// recorded a new occupancy `status` ("Owner-occupied" / "Rented (private)" / /// "Rented (social)"). Derived per-EPC and reduced to change points by the /// pipeline, so this surfaces *when* a home was let out vs lived in. #[derive(Serialize, Clone)] pub struct TenureEvent { pub year: i32, pub status: String, } pub struct PropertyData { pub lat: Vec, pub lon: Vec, pub feature_names: Vec, pub num_features: usize, /// Number of numeric features (enum features start at this index). pub num_numeric: usize, /// Row-major flat array: feature_data[row * num_features + feat_idx]. /// Quantized to u16. NaN sentinel = u16::MAX (65535). /// Numeric features: encoded via (val - min) / range * 65534. /// Enum features: stored directly as u16 cast of the f32 index. /// Heap-resident in prod; mmap-backed under `--spill-dir` (the largest array, /// so the biggest dev-memory win). Derefs to `[u16]`, so reads are unchanged. pub feature_data: SpillVec, /// Per-feature: range / QUANT_SCALE for fast decode. dequant_a: Vec, /// Per-feature: minimum value (offset for dequantization). quant_min: Vec, /// Per-feature: max - min (for encoding filter bounds). quant_range: Vec, pub feature_stats: Vec, pub poi_metrics: PostcodePoiMetrics, /// Unquantized last sale price used by the price-history chart. last_known_price_raw: Vec, /// Contiguous buffer holding all address strings end-to-end (valid UTF-8 by /// construction; spillable, so stored as bytes rather than `String`). address_buffer: SpillVec, /// Byte offset into `address_buffer` where each row's address starts. address_offsets: SpillVec, /// Length in bytes of each row's address. address_lengths: SpillVec, /// Interned postcodes: reader is thread-safe, keys index into it. postcode_interner: lasso::RodeoReader, postcode_keys: SpillVec, /// Rows for each postcode, keyed by the interned postcode key. postcode_row_index: FxHashMap>, /// Postcode keys for each postcode sector (e.g. "E14 2"). Stores interned /// keys, not rows, so the wider-area price-history charts can enumerate a /// sector's properties (via `postcode_row_index`) for ~1/13th the memory of /// duplicating the row ids. sector_postcode_index: FxHashMap>, /// Postcode keys for each outward code (e.g. "E14"). Same rationale as /// `sector_postcode_index`. outcode_postcode_index: FxHashMap>, /// Inverted index from address tokens to property rows. address_token_index: FxHashMap>, /// Prefix lookup from typed address-token prefix to indexed full address tokens. address_prefix_index: FxHashMap>, /// Interned normalized address-search tokens used for per-row scoring. /// Resolve-only (no string->key reverse map): scoring only resolves keys. address_search_interner: lasso::RodeoResolver, /// Flat per-row normalized address-search token keys. address_search_token_keys: SpillVec, /// Offset into `address_search_token_keys` for each row. address_search_token_offsets: SpillVec, /// Number of normalized address-search token keys for each row. address_search_token_lengths: SpillVec, /// For enum features: maps feature index to list of possible string values. /// Index in values list corresponds to the u16 value stored in feature_data. pub enum_values: rustc_hash::FxHashMap>, /// For enum features: maps feature index to per-value global counts (same order as enum_values). pub enum_counts: rustc_hash::FxHashMap>, /// Per-row flag: true = construction date is approximate (from EPC band), /// false = exact (from new-build transaction date). /// Bit-packed: byte `row / 8`, bit `row % 8`. 8x smaller than Vec. approx_build_date_bits: Vec, /// Per-row renovation events. Keyed by (permuted) row index. /// Only rows with events are present in the map. renovation_history: FxHashMap>, /// Per-row historical sale transactions (Land Registry price-paid). /// Keyed by (permuted) row index. Only rows with prices are present. historical_prices: FxHashMap>, /// Per-row tenure timeline (owner-occupied <-> rented change points from /// EPC). Keyed by (permuted) row index. Only rows with changes are present. tenure_history: FxHashMap>, property_sub_type: FxHashMap, price_qualifier: FxHashMap, } impl PropertyData { /// Get the address string for a given row. pub fn address(&self, row: usize) -> &str { let offset = self.address_offsets[row] as usize; let length = self.address_lengths[row] as usize; let bytes = &self.address_buffer[offset..offset + length]; // SAFETY: `address_buffer` is built by concatenating `&str` slices on UTF-8 // char boundaries, so every recorded `[offset, offset + length)` range is // valid UTF-8. unsafe { std::str::from_utf8_unchecked(bytes) } } /// Get the postcode string for a given row. pub fn postcode(&self, row: usize) -> &str { self.postcode_interner.resolve(&self.postcode_keys[row]) } /// Get postcode components for field-level borrowing (avoids conflicting borrows with feature_data). pub fn postcode_parts(&self) -> (&lasso::RodeoReader, &[lasso::Spur]) { (&self.postcode_interner, self.postcode_keys.as_slice()) } /// Property rows for a given postcode string, or empty if unknown. pub fn rows_for_postcode(&self, postcode: &str) -> &[u32] { self.postcode_interner .get(postcode) .and_then(|key| self.postcode_row_index.get(&key)) .map(Vec::as_slice) .unwrap_or(&[]) } /// All property rows in a postcode sector (e.g. "E14 2"), gathered across the /// sector's postcodes. Empty if the sector is unknown. Materializes a new Vec /// since the rows live in per-postcode buckets; callers downsample it. pub fn rows_for_sector(&self, sector: &str) -> Vec { self.rows_for_area(self.sector_postcode_index.get(sector)) } /// All property rows in an outward code (e.g. "E14"). See `rows_for_sector`. pub fn rows_for_outcode(&self, outcode: &str) -> Vec { self.rows_for_area(self.outcode_postcode_index.get(outcode)) } fn rows_for_area(&self, postcode_keys: Option<&Vec>) -> Vec { let Some(keys) = postcode_keys else { return Vec::new(); }; let mut rows = Vec::new(); for key in keys { if let Some(bucket) = self.postcode_row_index.get(key) { rows.extend_from_slice(bucket); } } rows } /// Get the is_approx_build_date flag for a given row (bit-packed). pub fn is_approx_build_date(&self, row: usize) -> bool { let byte = self.approx_build_date_bits[row / 8]; byte & (1 << (row % 8)) != 0 } /// Get renovation events for a given row (empty slice if none). pub fn renovation_history(&self, row: usize) -> &[RenovationEvent] { self.renovation_history .get(&(row as u32)) .map(|v| v.as_slice()) .unwrap_or(&[]) } /// Get historical sale transactions for a given row (empty slice if none). pub fn historical_prices(&self, row: usize) -> &[HistoricalPrice] { self.historical_prices .get(&(row as u32)) .map(|v| v.as_slice()) .unwrap_or(&[]) } /// Get tenure timeline events for a given row (empty slice if none). pub fn tenure_history(&self, row: usize) -> &[TenureEvent] { self.tenure_history .get(&(row as u32)) .map(|v| v.as_slice()) .unwrap_or(&[]) } /// Get property sub-type for a given row. pub fn property_sub_type(&self, row: usize) -> Option<&str> { self.property_sub_type .get(&(row as u32)) .map(String::as_str) } /// Get price qualifier for a given row. pub fn price_qualifier(&self, row: usize) -> Option<&str> { self.price_qualifier.get(&(row as u32)).map(String::as_str) } /// Get the unquantized last sale price for charting. #[inline] pub fn last_known_price_raw(&self, row: usize) -> f32 { self.last_known_price_raw[row] } /// Decode a single feature value from quantized u16 storage. #[inline] pub fn get_feature(&self, row: usize, feat_idx: usize) -> f32 { let raw = self.feature_data[row * self.num_features + feat_idx]; if raw == NAN_U16 { return f32::NAN; } if feat_idx >= self.num_numeric { raw as f32 } else { raw as f32 * self.dequant_a[feat_idx] + self.quant_min[feat_idx] } } /// Get a QuantRef for passing to aggregation/filter functions. pub fn quant_ref(&self) -> QuantRef<'_> { QuantRef { dequant_a: &self.dequant_a, quant_min: &self.quant_min, quant_range: &self.quant_range, num_numeric: self.num_numeric, } } } #[cfg(test)] impl PropertyData { /// Minimal empty instance for integration tests that need an `AppState` /// but never touch property data (e.g. checkout/webhook/invite flows). pub(crate) fn empty_for_tests() -> Self { PropertyData { lat: Vec::new(), lon: Vec::new(), feature_names: Vec::new(), num_features: 0, num_numeric: 0, feature_data: SpillVec::owned(Vec::new()), dequant_a: Vec::new(), quant_min: Vec::new(), quant_range: Vec::new(), feature_stats: Vec::new(), poi_metrics: PostcodePoiMetrics::empty(0), last_known_price_raw: Vec::new(), address_buffer: SpillVec::owned(Vec::new()), address_offsets: SpillVec::owned(Vec::new()), address_lengths: SpillVec::owned(Vec::new()), postcode_interner: lasso::Rodeo::default().into_reader(), postcode_keys: SpillVec::owned(Vec::new()), postcode_row_index: FxHashMap::default(), sector_postcode_index: FxHashMap::default(), outcode_postcode_index: FxHashMap::default(), address_token_index: FxHashMap::default(), address_prefix_index: FxHashMap::default(), address_search_interner: lasso::Rodeo::default().into_resolver(), address_search_token_keys: SpillVec::owned(Vec::new()), address_search_token_offsets: SpillVec::owned(Vec::new()), address_search_token_lengths: SpillVec::owned(Vec::new()), enum_values: rustc_hash::FxHashMap::default(), enum_counts: rustc_hash::FxHashMap::default(), approx_build_date_bits: Vec::new(), renovation_history: FxHashMap::default(), historical_prices: FxHashMap::default(), tenure_history: FxHashMap::default(), property_sub_type: FxHashMap::default(), price_qualifier: FxHashMap::default(), } } }