Compare commits
No commits in common. "45882e7de2deee04b98540242f4150f9a6dc4f68" and "3dd5cde626f52d4ba21e334fc9918c8664c9ae98" have entirely different histories.
45882e7de2
...
3dd5cde626
19 changed files with 65 additions and 480 deletions
|
|
@ -33,12 +33,6 @@ services:
|
||||||
# Fallback only — the binary uses jemalloc as its global allocator
|
# Fallback only — the binary uses jemalloc as its global allocator
|
||||||
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
|
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
|
||||||
MALLOC_ARENA_MAX: "2"
|
MALLOC_ARENA_MAX: "2"
|
||||||
# Dev only: spill the large property arrays (feature matrix +
|
|
||||||
# address-search index, ~4GB) to disk and memory-map them read-only
|
|
||||||
# instead of holding them in RAM, so a low-memory box can run the full
|
|
||||||
# dataset. Points at the `target` volume (real disk, not host-synced,
|
|
||||||
# not tmpfs). Comment out to keep everything resident (prod behaviour).
|
|
||||||
SPILL_DIR: /app/server-rs/target/spill
|
|
||||||
POCKETBASE_URL: http://pocketbase:8090
|
POCKETBASE_URL: http://pocketbase:8090
|
||||||
POCKETBASE_ADMIN_EMAIL: *pb-email
|
POCKETBASE_ADMIN_EMAIL: *pb-email
|
||||||
POCKETBASE_ADMIN_PASSWORD: *pb-password
|
POCKETBASE_ADMIN_PASSWORD: *pb-password
|
||||||
|
|
|
||||||
1
server-rs/Cargo.lock
generated
1
server-rs/Cargo.lock
generated
|
|
@ -3887,7 +3887,6 @@ dependencies = [
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
"lasso",
|
"lasso",
|
||||||
"libc",
|
"libc",
|
||||||
"memmap2",
|
|
||||||
"metrics",
|
"metrics",
|
||||||
"metrics-exporter-prometheus",
|
"metrics-exporter-prometheus",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,6 @@ urlencoding = "2"
|
||||||
url = "2"
|
url = "2"
|
||||||
rust_xlsxwriter = "0.94"
|
rust_xlsxwriter = "0.94"
|
||||||
pmtiles = { version = "0.23", features = ["mmap-async-tokio"] }
|
pmtiles = { version = "0.23", features = ["mmap-async-tokio"] }
|
||||||
# Read-only mmap backing for the large property arrays under the `--spill-dir`
|
|
||||||
# dev flag (see data/spill.rs); lets a low-memory dev box page them from disk.
|
|
||||||
memmap2 = "0.9"
|
|
||||||
rand = "0.10"
|
rand = "0.10"
|
||||||
hmac = "0.13"
|
hmac = "0.13"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ mod places;
|
||||||
mod poi;
|
mod poi;
|
||||||
mod postcodes;
|
mod postcodes;
|
||||||
mod property;
|
mod property;
|
||||||
pub mod spill;
|
|
||||||
pub mod travel_time;
|
pub mod travel_time;
|
||||||
|
|
||||||
/// Apostrophe-like code points that should be elided (not treated as word breaks) when
|
/// Apostrophe-like code points that should be elided (not treated as word breaks) when
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ use super::address_search::{
|
||||||
use super::poi_metrics::{PostcodePoiMetrics, NO_POI_METRIC_ROW};
|
use super::poi_metrics::{PostcodePoiMetrics, NO_POI_METRIC_ROW};
|
||||||
use super::stats::{column_to_f32_vec, compute_feature_stats, FeatureStats, Histogram};
|
use super::stats::{column_to_f32_vec, compute_feature_stats, FeatureStats, Histogram};
|
||||||
use super::{HistoricalPrice, PropertyData, RenovationEvent, TenureEvent};
|
use super::{HistoricalPrice, PropertyData, RenovationEvent, TenureEvent};
|
||||||
use crate::data::spill::SpillVec;
|
|
||||||
|
|
||||||
const MISSING_COORDINATE_SAMPLE_LIMIT: usize = 10;
|
const MISSING_COORDINATE_SAMPLE_LIMIT: usize = 10;
|
||||||
const COUNTRY_COLUMN_CANDIDATES: &[&str] = &[
|
const COUNTRY_COLUMN_CANDIDATES: &[&str] = &[
|
||||||
|
|
@ -161,25 +160,11 @@ fn validate_no_england_rows_missing_coordinates(
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PropertyData {
|
impl PropertyData {
|
||||||
/// Load the property data. When `spill` is `Some(dir)`, the large flat arrays
|
pub fn load(properties_path: &Path, postcode_features_path: &Path) -> anyhow::Result<Self> {
|
||||||
/// (the feature matrix and the address-search index) are written to anonymous
|
crate::data::run_polars_io(|| Self::load_inner(properties_path, postcode_features_path))
|
||||||
/// files in `dir` and memory-mapped read-only instead of held on the heap —
|
|
||||||
/// the `--spill-dir` dev flag, which lets a low-memory box page them from disk.
|
|
||||||
pub fn load(
|
|
||||||
properties_path: &Path,
|
|
||||||
postcode_features_path: &Path,
|
|
||||||
spill: Option<&Path>,
|
|
||||||
) -> anyhow::Result<Self> {
|
|
||||||
crate::data::run_polars_io(|| {
|
|
||||||
Self::load_inner(properties_path, postcode_features_path, spill)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_inner(
|
fn load_inner(properties_path: &Path, postcode_features_path: &Path) -> anyhow::Result<Self> {
|
||||||
properties_path: &Path,
|
|
||||||
postcode_features_path: &Path,
|
|
||||||
spill: Option<&Path>,
|
|
||||||
) -> anyhow::Result<Self> {
|
|
||||||
// Load postcode.parquet
|
// Load postcode.parquet
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Loading postcode features from {:?}",
|
"Loading postcode features from {:?}",
|
||||||
|
|
@ -1026,71 +1011,37 @@ impl PropertyData {
|
||||||
// Transpose to row-major AND apply spatial permutation in one pass.
|
// Transpose to row-major AND apply spatial permutation in one pass.
|
||||||
// Combines numeric and enum features into a single feature_data array, quantized to u16.
|
// Combines numeric and enum features into a single feature_data array, quantized to u16.
|
||||||
tracing::info!("Transposing to row-major layout (spatially sorted, quantized to u16)");
|
tracing::info!("Transposing to row-major layout (spatially sorted, quantized to u16)");
|
||||||
// Built in place so that under `--spill-dir` the 3GB matrix lands straight
|
let mut feature_data = vec![NAN_U16; row_count * num_features];
|
||||||
// in the mmap-backed file and never also exists on the heap.
|
feature_data
|
||||||
let feature_data = SpillVec::build_u16(
|
.par_chunks_mut(num_features)
|
||||||
row_count * num_features,
|
.enumerate()
|
||||||
NAN_U16,
|
.for_each(|(new_row, row_slice)| {
|
||||||
spill,
|
let old_index = perm[new_row] as usize;
|
||||||
"feature_data",
|
// Numeric features: quantize to u16
|
||||||
|feature_data| {
|
for (feat_idx, col_vec) in numeric_col_major.iter().enumerate() {
|
||||||
feature_data
|
let value = col_vec[old_index];
|
||||||
.par_chunks_mut(num_features)
|
row_slice[feat_idx] = if value.is_finite() {
|
||||||
.enumerate()
|
let range = quant_range[feat_idx];
|
||||||
.for_each(|(new_row, row_slice)| {
|
if range > 0.0 {
|
||||||
let old_index = perm[new_row] as usize;
|
let normalized = (value - quant_min[feat_idx]) / range;
|
||||||
// Numeric features: quantize to u16
|
(normalized * QUANT_SCALE).round().clamp(0.0, QUANT_SCALE) as u16
|
||||||
for (feat_idx, col_vec) in numeric_col_major.iter().enumerate() {
|
} else {
|
||||||
let value = col_vec[old_index];
|
0
|
||||||
row_slice[feat_idx] = if value.is_finite() {
|
|
||||||
let range = quant_range[feat_idx];
|
|
||||||
if range > 0.0 {
|
|
||||||
let normalized = (value - quant_min[feat_idx]) / range;
|
|
||||||
(normalized * QUANT_SCALE).round().clamp(0.0, QUANT_SCALE)
|
|
||||||
as u16
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
NAN_U16
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
// Enum features: store as u16 directly
|
} else {
|
||||||
for (enum_idx, (_, encoded)) in enum_col_major.iter().enumerate() {
|
NAN_U16
|
||||||
let value = encoded[old_index];
|
};
|
||||||
row_slice[num_numeric + enum_idx] = if value.is_finite() {
|
}
|
||||||
value as u16
|
// Enum features: store as u16 directly
|
||||||
} else {
|
for (enum_idx, (_, encoded)) in enum_col_major.iter().enumerate() {
|
||||||
NAN_U16
|
let value = encoded[old_index];
|
||||||
};
|
row_slice[num_numeric + enum_idx] = if value.is_finite() {
|
||||||
}
|
value as u16
|
||||||
});
|
} else {
|
||||||
},
|
NAN_U16
|
||||||
)?;
|
};
|
||||||
|
}
|
||||||
// Spill the remaining large flat arrays (the address-search index) to disk
|
});
|
||||||
// when configured. The posting-list hashmaps and interners stay on the heap
|
|
||||||
// — they aren't flat arrays, and they're a small fraction of the footprint.
|
|
||||||
let address_buffer =
|
|
||||||
SpillVec::maybe_spill(address_buffer.into_bytes(), spill, "address_buffer")?;
|
|
||||||
let address_offsets = SpillVec::maybe_spill(address_offsets, spill, "address_offsets")?;
|
|
||||||
let address_lengths = SpillVec::maybe_spill(address_lengths, spill, "address_lengths")?;
|
|
||||||
let address_search_token_keys = SpillVec::maybe_spill(
|
|
||||||
address_search_token_keys,
|
|
||||||
spill,
|
|
||||||
"address_search_token_keys",
|
|
||||||
)?;
|
|
||||||
let address_search_token_offsets = SpillVec::maybe_spill(
|
|
||||||
address_search_token_offsets,
|
|
||||||
spill,
|
|
||||||
"address_search_token_offsets",
|
|
||||||
)?;
|
|
||||||
let address_search_token_lengths = SpillVec::maybe_spill(
|
|
||||||
address_search_token_lengths,
|
|
||||||
spill,
|
|
||||||
"address_search_token_lengths",
|
|
||||||
)?;
|
|
||||||
let postcode_keys = SpillVec::maybe_spill(postcode_keys, spill, "postcode_keys")?;
|
|
||||||
|
|
||||||
tracing::info!("Data loading complete");
|
tracing::info!("Data loading complete");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ use rustc_hash::FxHashMap;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::consts::NAN_U16;
|
use crate::consts::NAN_U16;
|
||||||
use crate::data::spill::SpillVec;
|
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct RenovationEvent {
|
pub struct RenovationEvent {
|
||||||
|
|
@ -62,9 +61,7 @@ pub struct PropertyData {
|
||||||
/// Quantized to u16. NaN sentinel = u16::MAX (65535).
|
/// Quantized to u16. NaN sentinel = u16::MAX (65535).
|
||||||
/// Numeric features: encoded via (val - min) / range * 65534.
|
/// Numeric features: encoded via (val - min) / range * 65534.
|
||||||
/// Enum features: stored directly as u16 cast of the f32 index.
|
/// Enum features: stored directly as u16 cast of the f32 index.
|
||||||
/// Heap-resident in prod; mmap-backed under `--spill-dir` (the largest array,
|
pub feature_data: Vec<u16>,
|
||||||
/// so the biggest dev-memory win). Derefs to `[u16]`, so reads are unchanged.
|
|
||||||
pub feature_data: SpillVec<u16>,
|
|
||||||
/// Per-feature: range / QUANT_SCALE for fast decode.
|
/// Per-feature: range / QUANT_SCALE for fast decode.
|
||||||
dequant_a: Vec<f32>,
|
dequant_a: Vec<f32>,
|
||||||
/// Per-feature: minimum value (offset for dequantization).
|
/// Per-feature: minimum value (offset for dequantization).
|
||||||
|
|
@ -75,16 +72,15 @@ pub struct PropertyData {
|
||||||
pub poi_metrics: PostcodePoiMetrics,
|
pub poi_metrics: PostcodePoiMetrics,
|
||||||
/// Unquantized last sale price used by the price-history chart.
|
/// Unquantized last sale price used by the price-history chart.
|
||||||
last_known_price_raw: Vec<f32>,
|
last_known_price_raw: Vec<f32>,
|
||||||
/// Contiguous buffer holding all address strings end-to-end (valid UTF-8 by
|
/// Contiguous buffer holding all address strings end-to-end.
|
||||||
/// construction; spillable, so stored as bytes rather than `String`).
|
address_buffer: String,
|
||||||
address_buffer: SpillVec<u8>,
|
|
||||||
/// Byte offset into `address_buffer` where each row's address starts.
|
/// Byte offset into `address_buffer` where each row's address starts.
|
||||||
address_offsets: SpillVec<u32>,
|
address_offsets: Vec<u32>,
|
||||||
/// Length in bytes of each row's address.
|
/// Length in bytes of each row's address.
|
||||||
address_lengths: SpillVec<u16>,
|
address_lengths: Vec<u16>,
|
||||||
/// Interned postcodes: reader is thread-safe, keys index into it.
|
/// Interned postcodes: reader is thread-safe, keys index into it.
|
||||||
postcode_interner: lasso::RodeoReader,
|
postcode_interner: lasso::RodeoReader,
|
||||||
postcode_keys: SpillVec<lasso::Spur>,
|
postcode_keys: Vec<lasso::Spur>,
|
||||||
/// Rows for each postcode, keyed by the interned postcode key.
|
/// Rows for each postcode, keyed by the interned postcode key.
|
||||||
postcode_row_index: FxHashMap<lasso::Spur, Vec<u32>>,
|
postcode_row_index: FxHashMap<lasso::Spur, Vec<u32>>,
|
||||||
/// Inverted index from address tokens to property rows.
|
/// Inverted index from address tokens to property rows.
|
||||||
|
|
@ -95,11 +91,11 @@ pub struct PropertyData {
|
||||||
/// Resolve-only (no string->key reverse map): scoring only resolves keys.
|
/// Resolve-only (no string->key reverse map): scoring only resolves keys.
|
||||||
address_search_interner: lasso::RodeoResolver,
|
address_search_interner: lasso::RodeoResolver,
|
||||||
/// Flat per-row normalized address-search token keys.
|
/// Flat per-row normalized address-search token keys.
|
||||||
address_search_token_keys: SpillVec<lasso::Spur>,
|
address_search_token_keys: Vec<lasso::Spur>,
|
||||||
/// Offset into `address_search_token_keys` for each row.
|
/// Offset into `address_search_token_keys` for each row.
|
||||||
address_search_token_offsets: SpillVec<u32>,
|
address_search_token_offsets: Vec<u32>,
|
||||||
/// Number of normalized address-search token keys for each row.
|
/// Number of normalized address-search token keys for each row.
|
||||||
address_search_token_lengths: SpillVec<u16>,
|
address_search_token_lengths: Vec<u16>,
|
||||||
/// For enum features: maps feature index to list of possible string values.
|
/// 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.
|
/// Index in values list corresponds to the u16 value stored in feature_data.
|
||||||
pub enum_values: rustc_hash::FxHashMap<usize, Vec<String>>,
|
pub enum_values: rustc_hash::FxHashMap<usize, Vec<String>>,
|
||||||
|
|
@ -127,11 +123,7 @@ impl PropertyData {
|
||||||
pub fn address(&self, row: usize) -> &str {
|
pub fn address(&self, row: usize) -> &str {
|
||||||
let offset = self.address_offsets[row] as usize;
|
let offset = self.address_offsets[row] as usize;
|
||||||
let length = self.address_lengths[row] as usize;
|
let length = self.address_lengths[row] as usize;
|
||||||
let bytes = &self.address_buffer[offset..offset + length];
|
&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.
|
/// Get the postcode string for a given row.
|
||||||
|
|
@ -141,7 +133,7 @@ impl PropertyData {
|
||||||
|
|
||||||
/// Get postcode components for field-level borrowing (avoids conflicting borrows with feature_data).
|
/// Get postcode components for field-level borrowing (avoids conflicting borrows with feature_data).
|
||||||
pub fn postcode_parts(&self) -> (&lasso::RodeoReader, &[lasso::Spur]) {
|
pub fn postcode_parts(&self) -> (&lasso::RodeoReader, &[lasso::Spur]) {
|
||||||
(&self.postcode_interner, self.postcode_keys.as_slice())
|
(&self.postcode_interner, &self.postcode_keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Property rows for a given postcode string, or empty if unknown.
|
/// Property rows for a given postcode string, or empty if unknown.
|
||||||
|
|
@ -237,25 +229,25 @@ impl PropertyData {
|
||||||
feature_names: Vec::new(),
|
feature_names: Vec::new(),
|
||||||
num_features: 0,
|
num_features: 0,
|
||||||
num_numeric: 0,
|
num_numeric: 0,
|
||||||
feature_data: SpillVec::owned(Vec::new()),
|
feature_data: Vec::new(),
|
||||||
dequant_a: Vec::new(),
|
dequant_a: Vec::new(),
|
||||||
quant_min: Vec::new(),
|
quant_min: Vec::new(),
|
||||||
quant_range: Vec::new(),
|
quant_range: Vec::new(),
|
||||||
feature_stats: Vec::new(),
|
feature_stats: Vec::new(),
|
||||||
poi_metrics: PostcodePoiMetrics::empty(0),
|
poi_metrics: PostcodePoiMetrics::empty(0),
|
||||||
last_known_price_raw: Vec::new(),
|
last_known_price_raw: Vec::new(),
|
||||||
address_buffer: SpillVec::owned(Vec::new()),
|
address_buffer: String::new(),
|
||||||
address_offsets: SpillVec::owned(Vec::new()),
|
address_offsets: Vec::new(),
|
||||||
address_lengths: SpillVec::owned(Vec::new()),
|
address_lengths: Vec::new(),
|
||||||
postcode_interner: lasso::Rodeo::default().into_reader(),
|
postcode_interner: lasso::Rodeo::default().into_reader(),
|
||||||
postcode_keys: SpillVec::owned(Vec::new()),
|
postcode_keys: Vec::new(),
|
||||||
postcode_row_index: FxHashMap::default(),
|
postcode_row_index: FxHashMap::default(),
|
||||||
address_token_index: FxHashMap::default(),
|
address_token_index: FxHashMap::default(),
|
||||||
address_prefix_index: FxHashMap::default(),
|
address_prefix_index: FxHashMap::default(),
|
||||||
address_search_interner: lasso::Rodeo::default().into_resolver(),
|
address_search_interner: lasso::Rodeo::default().into_resolver(),
|
||||||
address_search_token_keys: SpillVec::owned(Vec::new()),
|
address_search_token_keys: Vec::new(),
|
||||||
address_search_token_offsets: SpillVec::owned(Vec::new()),
|
address_search_token_offsets: Vec::new(),
|
||||||
address_search_token_lengths: SpillVec::owned(Vec::new()),
|
address_search_token_lengths: Vec::new(),
|
||||||
enum_values: rustc_hash::FxHashMap::default(),
|
enum_values: rustc_hash::FxHashMap::default(),
|
||||||
enum_counts: rustc_hash::FxHashMap::default(),
|
enum_counts: rustc_hash::FxHashMap::default(),
|
||||||
approx_build_date_bits: Vec::new(),
|
approx_build_date_bits: Vec::new(),
|
||||||
|
|
|
||||||
|
|
@ -1,330 +0,0 @@
|
||||||
//! Optional disk-backed storage for the large flat arrays in [`PropertyData`].
|
|
||||||
//!
|
|
||||||
//! In production every property array is held as an owned `Vec` in RAM — fastest,
|
|
||||||
//! but the feature matrix plus the flat address-search arrays are ~4GB, which a
|
|
||||||
//! memory-constrained dev box can't hold reliably. When a spill directory is
|
|
||||||
//! configured (the `--spill-dir` dev flag), each large array is instead written
|
|
||||||
//! to an anonymous file in that directory and memory-mapped read-only. The mapped
|
|
||||||
//! pages are file-backed and clean, so under memory pressure the kernel evicts
|
|
||||||
//! them (re-faulting from disk on next touch) rather than the process being
|
|
||||||
//! OOM-killed — the same "let the kernel page it" trade-off the PMTiles reader
|
|
||||||
//! already makes. The backing files are unlinked immediately after creation, so
|
|
||||||
//! they leave nothing on disk and are reclaimed when the map drops at shutdown.
|
|
||||||
//!
|
|
||||||
//! [`SpillVec<T>`] dereferences to `[T]`, so every read site is byte-identical
|
|
||||||
//! whether the data lives on the heap or in a map. With no `--spill-dir` (prod),
|
|
||||||
//! the `Owned` variant is a thin wrapper over `Vec<T>` and the hot paths are
|
|
||||||
//! unchanged.
|
|
||||||
|
|
||||||
use std::fs::{File, OpenOptions};
|
|
||||||
use std::marker::PhantomData;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use memmap2::{Mmap, MmapMut};
|
|
||||||
|
|
||||||
/// Marker for types that may back a [`SpillVec`]: fixed-size `Copy` values that
|
|
||||||
/// round-trip losslessly through their raw bytes.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// Implementors must be `Copy`, contain no padding, and accept — for any value we
|
|
||||||
/// could have stored — the exact bytes we wrote when reinterpreted as `Self`. We
|
|
||||||
/// only ever read back bytes produced from genuine `Self` values, so niche types
|
|
||||||
/// such as `lasso::Spur` (a `NonZeroU32`) are sound here: the bytes always come
|
|
||||||
/// from real, non-zero interner keys and are never reinterpreted from arbitrary
|
|
||||||
/// input.
|
|
||||||
pub unsafe trait SpillElem: Copy + 'static {}
|
|
||||||
|
|
||||||
// SAFETY: plain integers have no padding and every bit pattern is a valid value.
|
|
||||||
unsafe impl SpillElem for u8 {}
|
|
||||||
unsafe impl SpillElem for u16 {}
|
|
||||||
unsafe impl SpillElem for u32 {}
|
|
||||||
unsafe impl SpillElem for f32 {}
|
|
||||||
// SAFETY: `lasso::Spur` is a transparent wrapper over `NonZeroU32` (size/align 4).
|
|
||||||
// We only store keys returned by an interner, which are never zero, so the bytes
|
|
||||||
// we map back never form the one invalid (all-zero) pattern.
|
|
||||||
unsafe impl SpillElem for lasso::Spur {}
|
|
||||||
|
|
||||||
/// Process-unique suffix so concurrently-created spill files never collide before
|
|
||||||
/// they are unlinked.
|
|
||||||
static NEXT_SPILL_ID: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
/// A read-only slice of `T` backed by either an owned heap `Vec` (production) or a
|
|
||||||
/// memory-mapped spill file (dev). Dereferences to `[T]`.
|
|
||||||
pub enum SpillVec<T: SpillElem> {
|
|
||||||
Owned(Vec<T>),
|
|
||||||
Mapped {
|
|
||||||
map: Mmap,
|
|
||||||
len: usize,
|
|
||||||
_marker: PhantomData<T>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: SpillElem> SpillVec<T> {
|
|
||||||
/// Keep `values` on the heap (production behaviour, and the only path used in
|
|
||||||
/// tests).
|
|
||||||
pub fn owned(values: Vec<T>) -> Self {
|
|
||||||
SpillVec::Owned(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spill `values` to `dir` and memory-map it when `dir` is `Some`; otherwise
|
|
||||||
/// keep it on the heap. `label` names the backing file for diagnostics.
|
|
||||||
pub fn maybe_spill(values: Vec<T>, dir: Option<&Path>, label: &str) -> anyhow::Result<Self> {
|
|
||||||
match dir {
|
|
||||||
None => Ok(SpillVec::owned(values)),
|
|
||||||
Some(dir) => spill_vec(values, dir, label),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Borrow the contents as a plain slice. (`SpillVec` also derefs to `[T]`, so
|
|
||||||
/// `&spill_vec` coerces to `&[T]`; this is for the rare spot where coercion
|
|
||||||
/// doesn't fire, e.g. building a tuple.)
|
|
||||||
pub fn as_slice(&self) -> &[T] {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SpillVec<u16> {
|
|
||||||
/// Build a `u16` array of `len` elements by filling it in place — on the heap
|
|
||||||
/// when `dir` is `None`, or directly inside an mmap-backed spill file when
|
|
||||||
/// `Some`, so the (large) buffer never simultaneously exists on the heap. Every
|
|
||||||
/// element is initialized to `default` first; `fill` then overwrites the cells
|
|
||||||
/// it owns. Used for the feature matrix, whose 3GB heap copy we want to avoid
|
|
||||||
/// when spilling.
|
|
||||||
pub fn build_u16(
|
|
||||||
len: usize,
|
|
||||||
default: u16,
|
|
||||||
dir: Option<&Path>,
|
|
||||||
label: &str,
|
|
||||||
fill: impl FnOnce(&mut [u16]),
|
|
||||||
) -> anyhow::Result<Self> {
|
|
||||||
match dir {
|
|
||||||
Some(dir) if len > 0 => {
|
|
||||||
let byte_len = len * std::mem::size_of::<u16>();
|
|
||||||
let file = anon_file(dir, label)?;
|
|
||||||
allocate_spill_file(&file, byte_len, label)?;
|
|
||||||
// SAFETY: `file` is a freshly-created, exclusively-owned regular
|
|
||||||
// file sized to exactly `byte_len`; no other mapping aliases it.
|
|
||||||
let mut map = unsafe { MmapMut::map_mut(&file) }
|
|
||||||
.with_context(|| format!("mapping spill file for '{label}'"))?;
|
|
||||||
// SAFETY: an mmap base is page-aligned (so aligned for `u16`) and
|
|
||||||
// the mapping is exactly `len * 2` bytes, valid to view as `len`
|
|
||||||
// `u16`s.
|
|
||||||
let slice =
|
|
||||||
unsafe { std::slice::from_raw_parts_mut(map.as_mut_ptr().cast::<u16>(), len) };
|
|
||||||
slice.fill(default);
|
|
||||||
fill(slice);
|
|
||||||
let map = map
|
|
||||||
.make_read_only()
|
|
||||||
.with_context(|| format!("sealing spill file for '{label}'"))?;
|
|
||||||
Ok(SpillVec::Mapped {
|
|
||||||
map,
|
|
||||||
len,
|
|
||||||
_marker: PhantomData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let mut values = vec![default; len];
|
|
||||||
fill(&mut values);
|
|
||||||
Ok(SpillVec::owned(values))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: SpillElem> std::ops::Deref for SpillVec<T> {
|
|
||||||
type Target = [T];
|
|
||||||
|
|
||||||
fn deref(&self) -> &[T] {
|
|
||||||
match self {
|
|
||||||
SpillVec::Owned(values) => values.as_slice(),
|
|
||||||
SpillVec::Mapped { map, len, .. } => {
|
|
||||||
let bytes: &[u8] = map;
|
|
||||||
debug_assert_eq!(bytes.len(), len * std::mem::size_of::<T>());
|
|
||||||
debug_assert_eq!(bytes.as_ptr().align_offset(std::mem::align_of::<T>()), 0);
|
|
||||||
// SAFETY: the mapping holds exactly `len * size_of::<T>()` bytes,
|
|
||||||
// all written from a `&[T]`; its base is page-aligned (hence aligned
|
|
||||||
// for `T`); `T: SpillElem` guarantees those bytes reinterpret as
|
|
||||||
// valid `T`s; and the read-only map outlives the borrow of `self`.
|
|
||||||
unsafe { std::slice::from_raw_parts(bytes.as_ptr().cast::<T>(), *len) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spill_vec<T: SpillElem>(values: Vec<T>, dir: &Path, label: &str) -> anyhow::Result<SpillVec<T>> {
|
|
||||||
let len = values.len();
|
|
||||||
let byte_len = len * std::mem::size_of::<T>();
|
|
||||||
// An empty mapping is invalid; nothing to spill, so keep the empty Vec.
|
|
||||||
if byte_len == 0 {
|
|
||||||
return Ok(SpillVec::owned(values));
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = anon_file(dir, label)?;
|
|
||||||
allocate_spill_file(&file, byte_len, label)?;
|
|
||||||
// SAFETY: `file` is a freshly-created, exclusively-owned regular file sized to
|
|
||||||
// exactly `byte_len`; no other mapping aliases it.
|
|
||||||
let mut map = unsafe { MmapMut::map_mut(&file) }
|
|
||||||
.with_context(|| format!("mapping spill file for '{label}' ({byte_len} bytes)"))?;
|
|
||||||
// SAFETY: `values` is a live `Vec<T>` of `Copy`, padding-free elements, so its
|
|
||||||
// store is `byte_len` initialized bytes valid to read as `u8`.
|
|
||||||
let src = unsafe { std::slice::from_raw_parts(values.as_ptr().cast::<u8>(), byte_len) };
|
|
||||||
map.copy_from_slice(src);
|
|
||||||
// The bytes now live in the file's page cache; release the heap copy.
|
|
||||||
drop(values);
|
|
||||||
let map = map
|
|
||||||
.make_read_only()
|
|
||||||
.with_context(|| format!("sealing spill file for '{label}'"))?;
|
|
||||||
Ok(SpillVec::Mapped {
|
|
||||||
map,
|
|
||||||
len,
|
|
||||||
_marker: PhantomData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Size a freshly-created spill file to `byte_len` bytes, reserving the disk
|
|
||||||
/// blocks up front. We write the array through a mutable mmap, and writing to a
|
|
||||||
/// dirty mmap page the kernel can't back at writeback raises `SIGBUS` — so a
|
|
||||||
/// plain sparse `set_len` would turn an out-of-space dev disk into a crash. On
|
|
||||||
/// Linux `posix_fallocate` allocates the blocks now, surfacing the shortfall as a
|
|
||||||
/// clean `ENOSPC` error here instead. Elsewhere we fall back to `set_len`.
|
|
||||||
fn allocate_spill_file(file: &File, byte_len: usize, label: &str) -> anyhow::Result<()> {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
use std::os::fd::AsRawFd;
|
|
||||||
// `posix_fallocate` returns an errno value directly (0 on success) and,
|
|
||||||
// unlike most libc calls, does not set the global errno.
|
|
||||||
let ret = unsafe { libc::posix_fallocate(file.as_raw_fd(), 0, byte_len as libc::off_t) };
|
|
||||||
if ret != 0 {
|
|
||||||
return Err(std::io::Error::from_raw_os_error(ret)).with_context(|| {
|
|
||||||
format!("reserving {byte_len} bytes of disk for spill file '{label}'")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
{
|
|
||||||
file.set_len(byte_len as u64)
|
|
||||||
.with_context(|| format!("sizing spill file for '{label}'"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a spill file in `dir` and immediately unlink it. On Unix the open
|
|
||||||
/// descriptor (and the mapping built from it) keep the inode alive, so the file is
|
|
||||||
/// invisible in the directory and its blocks are reclaimed automatically when the
|
|
||||||
/// map is dropped — no cleanup, no leftovers across runs.
|
|
||||||
fn anon_file(dir: &Path, label: &str) -> anyhow::Result<File> {
|
|
||||||
std::fs::create_dir_all(dir)
|
|
||||||
.with_context(|| format!("creating spill directory {}", dir.display()))?;
|
|
||||||
let unique = NEXT_SPILL_ID.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let path = dir.join(format!(
|
|
||||||
".ppc-spill-{}-{label}-{unique}.bin",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
let file = OpenOptions::new()
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(&path)
|
|
||||||
.with_context(|| format!("creating spill file {}", path.display()))?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
Ok(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// A scratch directory under the system temp dir; cleaned up on drop.
|
|
||||||
struct TempDir(std::path::PathBuf);
|
|
||||||
|
|
||||||
impl TempDir {
|
|
||||||
fn new(tag: &str) -> Self {
|
|
||||||
let id = NEXT_SPILL_ID.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let dir = std::env::temp_dir()
|
|
||||||
.join(format!("ppc-spill-test-{}-{tag}-{id}", std::process::id()));
|
|
||||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
|
||||||
TempDir(dir)
|
|
||||||
}
|
|
||||||
fn path(&self) -> &Path {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TempDir {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = std::fs::remove_dir_all(&self.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn maybe_spill_roundtrips_identically_owned_and_mapped() {
|
|
||||||
let values: Vec<u32> = (0..10_000u32)
|
|
||||||
.map(|n| n.wrapping_mul(2_654_435_761))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let owned = SpillVec::maybe_spill(values.clone(), None, "u32_owned").unwrap();
|
|
||||||
assert!(matches!(owned, SpillVec::Owned(_)));
|
|
||||||
assert_eq!(&*owned, values.as_slice());
|
|
||||||
|
|
||||||
let dir = TempDir::new("u32");
|
|
||||||
let mapped = SpillVec::maybe_spill(values.clone(), Some(dir.path()), "u32_mapped").unwrap();
|
|
||||||
assert!(matches!(mapped, SpillVec::Mapped { .. }));
|
|
||||||
// The mmap-backed slice must be byte-identical to the original Vec.
|
|
||||||
assert_eq!(&*mapped, values.as_slice());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_u16_fills_in_place_for_both_backings() {
|
|
||||||
let len = 4096usize;
|
|
||||||
let fill = |slice: &mut [u16]| {
|
|
||||||
for (idx, cell) in slice.iter_mut().enumerate() {
|
|
||||||
if idx % 3 == 0 {
|
|
||||||
*cell = idx as u16; // leave the rest at `default`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let owned = SpillVec::build_u16(len, 7, None, "u16_owned", fill).unwrap();
|
|
||||||
let dir = TempDir::new("u16");
|
|
||||||
let mapped = SpillVec::build_u16(len, 7, Some(dir.path()), "u16_mapped", fill).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(owned.len(), len);
|
|
||||||
assert_eq!(mapped.len(), len);
|
|
||||||
// Identical contents, and untouched cells read back as `default` (7).
|
|
||||||
assert_eq!(&*owned, &*mapped);
|
|
||||||
assert_eq!(owned[0], 0);
|
|
||||||
assert_eq!(owned[1], 7);
|
|
||||||
assert_eq!(owned[3], 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spur_keys_survive_the_mmap_roundtrip() {
|
|
||||||
// Spur is a NonZeroU32 niche type — exercises the SpillElem soundness claim.
|
|
||||||
let mut rodeo = lasso::Rodeo::default();
|
|
||||||
let keys: Vec<lasso::Spur> = (0..2000)
|
|
||||||
.map(|n| rodeo.get_or_intern(format!("token-{n}")))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let dir = TempDir::new("spur");
|
|
||||||
let mapped = SpillVec::maybe_spill(keys.clone(), Some(dir.path()), "spurs").unwrap();
|
|
||||||
assert!(matches!(mapped, SpillVec::Mapped { .. }));
|
|
||||||
assert_eq!(&*mapped, keys.as_slice());
|
|
||||||
// Keys must still resolve back to their original strings through the interner.
|
|
||||||
let reader = rodeo.into_resolver();
|
|
||||||
for (idx, &key) in mapped.iter().enumerate() {
|
|
||||||
assert_eq!(reader.resolve(&key), format!("token-{idx}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_input_stays_owned() {
|
|
||||||
let dir = TempDir::new("empty");
|
|
||||||
let mapped = SpillVec::maybe_spill(Vec::<u32>::new(), Some(dir.path()), "empty").unwrap();
|
|
||||||
// A zero-length mmap is invalid, so empties fall back to an owned Vec.
|
|
||||||
assert!(matches!(mapped, SpillVec::Owned(_)));
|
|
||||||
assert!(mapped.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -281,14 +281,6 @@ struct Cli {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
dist: Option<PathBuf>,
|
dist: Option<PathBuf>,
|
||||||
|
|
||||||
/// Dev only: spill the large property arrays (feature matrix + address-search
|
|
||||||
/// index) to anonymous files in this directory and memory-map them read-only,
|
|
||||||
/// instead of holding them on the heap. Trades a little speed for a much
|
|
||||||
/// smaller resident set so a low-memory dev box can run the full dataset; the
|
|
||||||
/// mapped pages are file-backed and reclaimable under pressure. Omit in prod.
|
|
||||||
#[arg(long, env = "SPILL_DIR")]
|
|
||||||
spill_dir: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// URL of the screenshot service (e.g. http://screenshot:8002)
|
/// URL of the screenshot service (e.g. http://screenshot:8002)
|
||||||
#[arg(long, env = "SCREENSHOT_URL")]
|
#[arg(long, env = "SCREENSHOT_URL")]
|
||||||
screenshot_url: String,
|
screenshot_url: String,
|
||||||
|
|
@ -473,15 +465,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
cli.properties.display(),
|
cli.properties.display(),
|
||||||
cli.postcode_features.display(),
|
cli.postcode_features.display(),
|
||||||
);
|
);
|
||||||
let spill_dir = cli.spill_dir.as_deref();
|
let property_data = data::PropertyData::load(&cli.properties, &cli.postcode_features)?;
|
||||||
if let Some(dir) = spill_dir {
|
|
||||||
info!(
|
|
||||||
"Spill-to-disk enabled: large property arrays will be memory-mapped from {}",
|
|
||||||
dir.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let property_data =
|
|
||||||
data::PropertyData::load(&cli.properties, &cli.postcode_features, spill_dir)?;
|
|
||||||
trim_allocator("property data load");
|
trim_allocator("property data load");
|
||||||
info!(
|
info!(
|
||||||
rows = property_data.lat.len(),
|
rows = property_data.lat.len(),
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ const KEEP_UNKNOWN_LISTING_FILTER_FEATURES: &[&str] = &[
|
||||||
"Construction year",
|
"Construction year",
|
||||||
"Interior height (m)",
|
"Interior height (m)",
|
||||||
"Current energy rating",
|
"Current energy rating",
|
||||||
"Potential energy rating",
|
"Potential energy rating"
|
||||||
];
|
];
|
||||||
const LISTING_BOUNDS_EPSILON_DEGREES: f64 = 0.00001;
|
const LISTING_BOUNDS_EPSILON_DEGREES: f64 = 0.00001;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ pub(super) fn count_matching_rows(
|
||||||
.collect();
|
.collect();
|
||||||
let has_travel = !travel_data.is_empty();
|
let has_travel = !travel_data.is_empty();
|
||||||
|
|
||||||
let feature_data: &[u16] = &state.data.feature_data;
|
let feature_data = &state.data.feature_data;
|
||||||
let num_features = state.data.num_features;
|
let num_features = state.data.num_features;
|
||||||
let num_rows = state.data.lat.len();
|
let num_rows = state.data.lat.len();
|
||||||
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
||||||
|
|
|
||||||
|
|
@ -632,7 +632,7 @@ pub async fn get_export(
|
||||||
let bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
let bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
let num_features = state.data.num_features;
|
let num_features = state.data.num_features;
|
||||||
let feature_data: &[u16] = &state.data.feature_data;
|
let feature_data = &state.data.feature_data;
|
||||||
let quant = state.data.quant_ref();
|
let quant = state.data.quant_ref();
|
||||||
let feature_names = &state.data.feature_names;
|
let feature_names = &state.data.feature_names;
|
||||||
let enum_values = &state.data.enum_values;
|
let enum_values = &state.data.enum_values;
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ pub async fn get_filter_counts(
|
||||||
let response = tokio::task::spawn_blocking(move || -> Result<FilterCountsResponse, String> {
|
let response = tokio::task::spawn_blocking(move || -> Result<FilterCountsResponse, String> {
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
let num_features = state.data.num_features;
|
let num_features = state.data.num_features;
|
||||||
let feature_data: &[u16] = &state.data.feature_data;
|
let feature_data = &state.data.feature_data;
|
||||||
|
|
||||||
// Load travel time data
|
// Load travel time data
|
||||||
let travel_data: Vec<TravelData> = travel_entries
|
let travel_data: Vec<TravelData> = travel_entries
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ pub(super) fn top_filter_exclusions(
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let feature_data: &[u16] = &data.feature_data;
|
let feature_data = &data.feature_data;
|
||||||
let num_features = data.num_features;
|
let num_features = data.num_features;
|
||||||
let quant = data.quant_ref();
|
let quant = data.quant_ref();
|
||||||
let poi_quant = data.poi_metrics.quant_ref();
|
let poi_quant = data.poi_metrics.quant_ref();
|
||||||
|
|
@ -494,7 +494,7 @@ pub async fn get_hexagon_stats(
|
||||||
.map_err(|err| format!("Invalid H3 resolution {}: {}", resolution, err))?;
|
.map_err(|err| format!("Invalid H3 resolution {}: {}", resolution, err))?;
|
||||||
let need_parent = needs_parent(resolution);
|
let need_parent = needs_parent(resolution);
|
||||||
let num_features = state.data.num_features;
|
let num_features = state.data.num_features;
|
||||||
let feature_data: &[u16] = &state.data.feature_data;
|
let feature_data = &state.data.feature_data;
|
||||||
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
|
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
|
||||||
let has_travel = !travel_entries.is_empty();
|
let has_travel = !travel_entries.is_empty();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ pub async fn get_hexagons(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let num_features = state.data.num_features;
|
let num_features = state.data.num_features;
|
||||||
let feature_data: &[u16] = &state.data.feature_data;
|
let feature_data = &state.data.feature_data;
|
||||||
let quant = state.data.quant_ref();
|
let quant = state.data.quant_ref();
|
||||||
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
||||||
let min_keys = &state.min_keys;
|
let min_keys = &state.min_keys;
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ pub async fn get_postcode_properties(
|
||||||
let result = tokio::task::spawn_blocking(move || {
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
let num_features = state.data.num_features;
|
let num_features = state.data.num_features;
|
||||||
let feature_data: &[u16] = &state.data.feature_data;
|
let feature_data = &state.data.feature_data;
|
||||||
let feature_names = &state.data.feature_names;
|
let feature_names = &state.data.feature_names;
|
||||||
let feature_name_to_index = &state.feature_name_to_index;
|
let feature_name_to_index = &state.feature_name_to_index;
|
||||||
let enum_values = &state.data.enum_values;
|
let enum_values = &state.data.enum_values;
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ pub async fn get_postcode_stats(
|
||||||
let response = tokio::task::spawn_blocking(move || {
|
let response = tokio::task::spawn_blocking(move || {
|
||||||
let start_time = std::time::Instant::now();
|
let start_time = std::time::Instant::now();
|
||||||
let num_features = state.data.num_features;
|
let num_features = state.data.num_features;
|
||||||
let feature_data: &[u16] = &state.data.feature_data;
|
let feature_data = &state.data.feature_data;
|
||||||
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
|
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
|
||||||
let has_travel = !travel_entries.is_empty();
|
let has_travel = !travel_entries.is_empty();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ pub async fn get_postcodes(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let num_features = state.data.num_features;
|
let num_features = state.data.num_features;
|
||||||
let feature_data: &[u16] = &state.data.feature_data;
|
let feature_data = &state.data.feature_data;
|
||||||
let quant = state.data.quant_ref();
|
let quant = state.data.quant_ref();
|
||||||
let min_keys = &state.min_keys;
|
let min_keys = &state.min_keys;
|
||||||
let max_keys = &state.max_keys;
|
let max_keys = &state.max_keys;
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ pub async fn get_hexagon_properties(
|
||||||
.map_err(|err| format!("Invalid H3 resolution {}: {}", resolution, err))?;
|
.map_err(|err| format!("Invalid H3 resolution {}: {}", resolution, err))?;
|
||||||
let need_parent = needs_parent(resolution);
|
let need_parent = needs_parent(resolution);
|
||||||
let num_features = state.data.num_features;
|
let num_features = state.data.num_features;
|
||||||
let feature_data: &[u16] = &state.data.feature_data;
|
let feature_data = &state.data.feature_data;
|
||||||
let feature_names = &state.data.feature_names;
|
let feature_names = &state.data.feature_names;
|
||||||
let feature_name_to_index = &state.feature_name_to_index;
|
let feature_name_to_index = &state.feature_name_to_index;
|
||||||
let enum_values = &state.data.enum_values;
|
let enum_values = &state.data.enum_values;
|
||||||
|
|
|
||||||
|
|
@ -482,13 +482,12 @@ pub fn compute_poi_feature_stats(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::consts::NAN_U16;
|
use crate::consts::NAN_U16;
|
||||||
use crate::data::spill::SpillVec;
|
|
||||||
|
|
||||||
fn enum_data(values: &[u16]) -> PropertyData {
|
fn enum_data(values: &[u16]) -> PropertyData {
|
||||||
let mut data = PropertyData::empty_for_tests();
|
let mut data = PropertyData::empty_for_tests();
|
||||||
data.num_features = 1;
|
data.num_features = 1;
|
||||||
data.num_numeric = 0; // single enum feature at index 0
|
data.num_numeric = 0; // single enum feature at index 0
|
||||||
data.feature_data = SpillVec::owned(values.to_vec());
|
data.feature_data = values.to_vec();
|
||||||
data.enum_values
|
data.enum_values
|
||||||
.insert(0, vec!["Yes".to_string(), "No".to_string()]);
|
.insert(0, vec!["Yes".to_string(), "No".to_string()]);
|
||||||
data
|
data
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue