SPlit up
This commit is contained in:
parent
cf39ad754e
commit
f59d01227b
91 changed files with 10370 additions and 7562 deletions
973
server-rs/src/data/property/address_search.rs
Normal file
973
server-rs/src/data/property/address_search.rs
Normal file
|
|
@ -0,0 +1,973 @@
|
|||
//! Address search: tokenization, query parsing, inverted/prefix indexes and the
|
||||
//! ranked per-row search over property addresses.
|
||||
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
use super::PropertyData;
|
||||
|
||||
/// Upper bound on rows scored per query. Intersection keeps most candidate sets far below
|
||||
/// this; only a single very common road word (e.g. "high") approaches it, and the in-area
|
||||
/// priority sort keeps a refined query's matches ahead of the cut.
|
||||
const ADDRESS_SEARCH_CANDIDATE_LIMIT: usize = 150_000;
|
||||
const ADDRESS_SEARCH_PREFIX_MIN_LEN: usize = 4;
|
||||
const ADDRESS_SEARCH_PREFIX_MAX_LEN: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct AddressTermGroup {
|
||||
alternatives: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct AddressQuery {
|
||||
full_postcode: Option<String>,
|
||||
/// Compact uppercase outward code (optionally with a sector digit) recovered when the
|
||||
/// user appended a partial postcode like "NW1" or "NW1 6". Used as an additive ranking
|
||||
/// bias, never as a hard filter — so the disambiguating hint is honoured without
|
||||
/// excluding the same road in other areas.
|
||||
postcode_area: Option<String>,
|
||||
text_groups: Vec<AddressTermGroup>,
|
||||
numeric_terms: Vec<String>,
|
||||
candidate_terms: Vec<String>,
|
||||
}
|
||||
|
||||
fn tokenize_address_text(text: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for ch in text.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
current.push(ch.to_ascii_lowercase());
|
||||
} else if matches!(ch, '\'' | '’' | '`') {
|
||||
continue;
|
||||
} else if !current.is_empty() {
|
||||
tokens.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
tokens
|
||||
}
|
||||
|
||||
fn is_full_postcode_compact(compact: &str) -> bool {
|
||||
let bytes = compact.as_bytes();
|
||||
let len = bytes.len();
|
||||
if !(5..=7).contains(&len) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let inward = &bytes[len - 3..];
|
||||
if !inward[0].is_ascii_digit()
|
||||
|| !inward[1].is_ascii_alphabetic()
|
||||
|| !inward[2].is_ascii_alphabetic()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let outward = &bytes[..len - 3];
|
||||
if !(2..=4).contains(&outward.len()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
outward[0].is_ascii_alphabetic()
|
||||
&& outward.iter().all(u8::is_ascii_alphanumeric)
|
||||
&& outward.iter().any(u8::is_ascii_digit)
|
||||
}
|
||||
|
||||
fn canonical_postcode_from_compact(compact: &str) -> String {
|
||||
let upper = compact.to_ascii_uppercase();
|
||||
let split = upper.len() - 3;
|
||||
format!("{} {}", &upper[..split], &upper[split..])
|
||||
}
|
||||
|
||||
fn extract_full_postcode(tokens: &[String]) -> Option<(String, Vec<usize>)> {
|
||||
for (idx, token) in tokens.iter().enumerate() {
|
||||
let compact = token.to_ascii_uppercase();
|
||||
if is_full_postcode_compact(&compact) {
|
||||
return Some((canonical_postcode_from_compact(&compact), vec![idx]));
|
||||
}
|
||||
}
|
||||
|
||||
for idx in 0..tokens.len().saturating_sub(1) {
|
||||
let compact = format!(
|
||||
"{}{}",
|
||||
tokens[idx].to_ascii_uppercase(),
|
||||
tokens[idx + 1].to_ascii_uppercase()
|
||||
);
|
||||
if is_full_postcode_compact(&compact) {
|
||||
return Some((
|
||||
canonical_postcode_from_compact(&compact),
|
||||
vec![idx, idx + 1],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn looks_like_postcode_fragment(token: &str) -> bool {
|
||||
(2..=4).contains(&token.len())
|
||||
&& token
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|ch| ch.is_ascii_alphabetic())
|
||||
&& token.chars().any(|ch| ch.is_ascii_digit())
|
||||
&& token.chars().all(|ch| ch.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn is_numeric_address_token(token: &str) -> bool {
|
||||
token.chars().all(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn address_token_aliases(token: &str) -> Vec<&'static str> {
|
||||
match token {
|
||||
"apt" => vec!["apt", "apartment"],
|
||||
"apartment" => vec!["apartment", "apt"],
|
||||
"ave" => vec!["ave", "avenue"],
|
||||
"avenue" => vec!["avenue", "ave"],
|
||||
"blvd" => vec!["blvd", "boulevard"],
|
||||
"boulevard" => vec!["boulevard", "blvd"],
|
||||
"cl" => vec!["cl", "close"],
|
||||
"close" => vec!["close", "cl"],
|
||||
"ct" => vec!["ct", "court"],
|
||||
"court" => vec!["court", "ct"],
|
||||
"cres" => vec!["cres", "crescent"],
|
||||
"crescent" => vec!["crescent", "cres"],
|
||||
"dr" => vec!["dr", "drive"],
|
||||
"drive" => vec!["drive", "dr"],
|
||||
"fl" => vec!["fl", "flat"],
|
||||
"flat" => vec!["flat", "fl"],
|
||||
"gdns" => vec!["gdns", "gardens", "garden"],
|
||||
"garden" => vec!["garden", "gardens", "gdns"],
|
||||
"gardens" => vec!["gardens", "garden", "gdns"],
|
||||
"hse" => vec!["hse", "house"],
|
||||
"house" => vec!["house", "hse"],
|
||||
"ln" => vec!["ln", "lane"],
|
||||
"lane" => vec!["lane", "ln"],
|
||||
"rd" => vec!["rd", "road"],
|
||||
"road" => vec!["road", "rd"],
|
||||
"sq" => vec!["sq", "square"],
|
||||
"square" => vec!["square", "sq"],
|
||||
"st" => vec!["st", "street", "saint"],
|
||||
"street" => vec!["street", "st"],
|
||||
"saint" => vec!["saint", "st"],
|
||||
"terr" => vec!["terr", "terrace"],
|
||||
"terrace" => vec!["terrace", "terr"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_address_stop_token(token: &str) -> bool {
|
||||
matches!(
|
||||
token,
|
||||
"a" | "an"
|
||||
| "and"
|
||||
| "apartment"
|
||||
| "apt"
|
||||
| "avenue"
|
||||
| "ave"
|
||||
| "block"
|
||||
| "building"
|
||||
| "bungalow"
|
||||
| "close"
|
||||
| "cl"
|
||||
| "court"
|
||||
| "ct"
|
||||
| "cres"
|
||||
| "crescent"
|
||||
| "drive"
|
||||
| "dr"
|
||||
| "estate"
|
||||
| "flat"
|
||||
| "fl"
|
||||
| "floor"
|
||||
| "garden"
|
||||
| "gardens"
|
||||
| "gdns"
|
||||
| "grove"
|
||||
| "house"
|
||||
| "hse"
|
||||
| "lane"
|
||||
| "ln"
|
||||
| "lodge"
|
||||
| "mansions"
|
||||
| "mews"
|
||||
| "of"
|
||||
| "park"
|
||||
| "place"
|
||||
| "road"
|
||||
| "rd"
|
||||
| "room"
|
||||
| "row"
|
||||
| "saint"
|
||||
| "sq"
|
||||
| "square"
|
||||
| "st"
|
||||
| "street"
|
||||
| "terr"
|
||||
| "terrace"
|
||||
| "the"
|
||||
| "unit"
|
||||
| "view"
|
||||
| "villas"
|
||||
| "walk"
|
||||
| "way"
|
||||
| "yard"
|
||||
)
|
||||
}
|
||||
|
||||
fn address_term_group(token: &str) -> Option<AddressTermGroup> {
|
||||
if token.len() < 3 || is_numeric_address_token(token) || looks_like_postcode_fragment(token) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut alternatives = Vec::new();
|
||||
alternatives.push(token.to_string());
|
||||
for alias in address_token_aliases(token) {
|
||||
if !alternatives.iter().any(|existing| existing == alias) {
|
||||
alternatives.push(alias.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if alternatives
|
||||
.iter()
|
||||
.all(|alternative| is_address_stop_token(alternative))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(AddressTermGroup { alternatives })
|
||||
}
|
||||
|
||||
pub(super) fn address_search_tokens(text: &str) -> Vec<String> {
|
||||
let mut tokens: Vec<String> = tokenize_address_text(text)
|
||||
.into_iter()
|
||||
.filter(|token| is_address_search_token(token))
|
||||
.collect();
|
||||
tokens.sort_unstable();
|
||||
tokens.dedup();
|
||||
tokens
|
||||
}
|
||||
|
||||
fn is_address_search_token(token: &str) -> bool {
|
||||
if looks_like_postcode_fragment(token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_numeric_address_token(token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if token.chars().any(|ch| ch.is_ascii_digit()) {
|
||||
return token.len() >= 2;
|
||||
}
|
||||
|
||||
token.len() >= 3
|
||||
}
|
||||
|
||||
pub(super) fn is_address_candidate_token(token: &str) -> bool {
|
||||
!is_numeric_address_token(token)
|
||||
&& !looks_like_postcode_fragment(token)
|
||||
&& (token.chars().any(|ch| ch.is_ascii_digit())
|
||||
|| (token.len() >= 3 && !is_address_stop_token(token)))
|
||||
}
|
||||
|
||||
fn address_prefix_key(term: &str) -> &str {
|
||||
if term.len() > ADDRESS_SEARCH_PREFIX_MAX_LEN {
|
||||
&term[..ADDRESS_SEARCH_PREFIX_MAX_LEN]
|
||||
} else {
|
||||
term
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_address_prefix_index(
|
||||
address_token_index: &FxHashMap<String, Vec<u32>>,
|
||||
) -> FxHashMap<String, Vec<String>> {
|
||||
let mut prefix_index: FxHashMap<String, Vec<String>> = FxHashMap::default();
|
||||
|
||||
for token in address_token_index.keys() {
|
||||
let max_prefix_len = token.len().min(ADDRESS_SEARCH_PREFIX_MAX_LEN);
|
||||
for prefix_len in ADDRESS_SEARCH_PREFIX_MIN_LEN..=max_prefix_len {
|
||||
prefix_index
|
||||
.entry(token[..prefix_len].to_string())
|
||||
.or_default()
|
||||
.push(token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for tokens in prefix_index.values_mut() {
|
||||
tokens.sort_unstable();
|
||||
tokens.dedup();
|
||||
}
|
||||
|
||||
prefix_index
|
||||
}
|
||||
|
||||
/// Intersect two ascending-sorted row-id slices.
|
||||
fn intersect_sorted(left: &[u32], right: &[u32]) -> Vec<u32> {
|
||||
let mut out = Vec::new();
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < left.len() && j < right.len() {
|
||||
match left[i].cmp(&right[j]) {
|
||||
std::cmp::Ordering::Less => i += 1,
|
||||
std::cmp::Ordering::Greater => j += 1,
|
||||
std::cmp::Ordering::Equal => {
|
||||
out.push(left[i]);
|
||||
i += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Union two ascending-sorted row-id slices (deduplicated, stays sorted).
|
||||
fn union_sorted(left: &[u32], right: &[u32]) -> Vec<u32> {
|
||||
let mut out = Vec::with_capacity(left.len() + right.len());
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < left.len() && j < right.len() {
|
||||
match left[i].cmp(&right[j]) {
|
||||
std::cmp::Ordering::Less => {
|
||||
out.push(left[i]);
|
||||
i += 1;
|
||||
}
|
||||
std::cmp::Ordering::Greater => {
|
||||
out.push(right[j]);
|
||||
j += 1;
|
||||
}
|
||||
std::cmp::Ordering::Equal => {
|
||||
out.push(left[i]);
|
||||
i += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&left[i..]);
|
||||
out.extend_from_slice(&right[j..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// An ordinal like "1st", "2nd", "3rd", "21st" — part of the street name ("2nd Avenue"), not a
|
||||
/// house-number prefix.
|
||||
fn is_ordinal_token(token: &str) -> bool {
|
||||
let split = token.len().saturating_sub(2);
|
||||
let (digits, suffix) = token.split_at(split);
|
||||
!digits.is_empty()
|
||||
&& digits.chars().all(|ch| ch.is_ascii_digit())
|
||||
&& matches!(suffix, "st" | "nd" | "rd" | "th")
|
||||
}
|
||||
|
||||
/// Leading address tokens that denote a unit/house number rather than the street itself.
|
||||
fn is_house_prefix_token(token: &str) -> bool {
|
||||
if is_ordinal_token(token) {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
token,
|
||||
"flat" | "fl" | "apartment" | "apt" | "unit" | "no" | "block" | "floor" | "room"
|
||||
) || token.len() == 1
|
||||
|| token.chars().all(|ch| ch.is_ascii_digit())
|
||||
|| (token.chars().next().is_some_and(|ch| ch.is_ascii_digit())
|
||||
&& token.chars().any(|ch| ch.is_ascii_alphabetic()))
|
||||
}
|
||||
|
||||
/// Street-level key for an address: drops the leading house-number / flat prefix so that
|
||||
/// "12 Baker Street" and "5 Baker Street" collapse to a single street entry.
|
||||
fn street_key(address: &str) -> String {
|
||||
let tokens = tokenize_address_text(address);
|
||||
let mut start = 0;
|
||||
while start < tokens.len() && is_house_prefix_token(&tokens[start]) {
|
||||
start += 1;
|
||||
}
|
||||
if start >= tokens.len() {
|
||||
return tokens.join(" ");
|
||||
}
|
||||
tokens[start..].join(" ")
|
||||
}
|
||||
|
||||
/// Road-type words. Their presence (with no house number) marks a road browse, which we
|
||||
/// collapse to one result per street.
|
||||
const ROAD_TYPE_TOKENS: &[&str] = &[
|
||||
"street",
|
||||
"st",
|
||||
"road",
|
||||
"rd",
|
||||
"lane",
|
||||
"ln",
|
||||
"avenue",
|
||||
"ave",
|
||||
"close",
|
||||
"cl",
|
||||
"drive",
|
||||
"dr",
|
||||
"way",
|
||||
"court",
|
||||
"ct",
|
||||
"crescent",
|
||||
"cres",
|
||||
"place",
|
||||
"terrace",
|
||||
"terr",
|
||||
"grove",
|
||||
"gardens",
|
||||
"gdns",
|
||||
"walk",
|
||||
"row",
|
||||
"square",
|
||||
"sq",
|
||||
"hill",
|
||||
"parade",
|
||||
"mews",
|
||||
"embankment",
|
||||
"broadway",
|
||||
"boulevard",
|
||||
"blvd",
|
||||
];
|
||||
|
||||
fn query_has_road_type(query: &str) -> bool {
|
||||
tokenize_address_text(query)
|
||||
.iter()
|
||||
.any(|token| ROAD_TYPE_TOKENS.contains(&token.as_str()))
|
||||
}
|
||||
|
||||
/// The outward code (everything before the space) of a canonical postcode.
|
||||
fn outcode_of(postcode: &str) -> &str {
|
||||
postcode.split(' ').next().unwrap_or(postcode)
|
||||
}
|
||||
|
||||
fn parse_address_query(query: &str) -> AddressQuery {
|
||||
let tokens = tokenize_address_text(query);
|
||||
let (full_postcode, postcode_token_indices) = extract_full_postcode(&tokens)
|
||||
.map(|(postcode, indices)| (Some(postcode), indices))
|
||||
.unwrap_or((None, Vec::new()));
|
||||
|
||||
let skip_postcode_tokens: FxHashSet<usize> = postcode_token_indices.into_iter().collect();
|
||||
|
||||
// Recover an appended partial postcode (outcode, or outcode + sector digit) as a ranking
|
||||
// bias rather than discarding it — but only from the TRAILING position, so a leading road
|
||||
// designation like "A4 Great West Road" is not mistaken for an area refinement.
|
||||
let mut postcode_area: Option<String> = None;
|
||||
let mut consumed_partial_tokens: FxHashSet<usize> = FxHashSet::default();
|
||||
if full_postcode.is_none() && !tokens.is_empty() {
|
||||
let last = tokens.len() - 1;
|
||||
if !skip_postcode_tokens.contains(&last) {
|
||||
let sector_digit =
|
||||
tokens[last].len() == 1 && tokens[last].chars().all(|ch| ch.is_ascii_digit());
|
||||
if last >= 1
|
||||
&& sector_digit
|
||||
&& !skip_postcode_tokens.contains(&(last - 1))
|
||||
&& looks_like_postcode_fragment(&tokens[last - 1])
|
||||
{
|
||||
postcode_area = Some(format!(
|
||||
"{}{}",
|
||||
tokens[last - 1].to_ascii_uppercase(),
|
||||
tokens[last]
|
||||
));
|
||||
consumed_partial_tokens.insert(last);
|
||||
consumed_partial_tokens.insert(last - 1);
|
||||
} else if looks_like_postcode_fragment(&tokens[last]) {
|
||||
postcode_area = Some(tokens[last].to_ascii_uppercase());
|
||||
consumed_partial_tokens.insert(last);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut text_groups = Vec::new();
|
||||
let mut numeric_terms = Vec::new();
|
||||
let mut candidate_terms = Vec::new();
|
||||
|
||||
for (idx, token) in tokens.iter().enumerate() {
|
||||
if skip_postcode_tokens.contains(&idx)
|
||||
|| consumed_partial_tokens.contains(&idx)
|
||||
|| looks_like_postcode_fragment(token)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_numeric_address_token(token) {
|
||||
numeric_terms.push(token.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(group) = address_term_group(token) {
|
||||
for alternative in &group.alternatives {
|
||||
if !is_address_stop_token(alternative)
|
||||
&& !candidate_terms.iter().any(|term| term == alternative)
|
||||
{
|
||||
candidate_terms.push(alternative.clone());
|
||||
}
|
||||
}
|
||||
text_groups.push(group);
|
||||
} else if token.chars().any(|ch| ch.is_ascii_digit()) && token.len() >= 2 {
|
||||
numeric_terms.push(token.clone());
|
||||
if !candidate_terms.iter().any(|term| term == token) {
|
||||
candidate_terms.push(token.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text_groups.dedup_by(|left, right| left.alternatives == right.alternatives);
|
||||
numeric_terms.sort_unstable();
|
||||
numeric_terms.dedup();
|
||||
|
||||
AddressQuery {
|
||||
full_postcode,
|
||||
postcode_area,
|
||||
text_groups,
|
||||
numeric_terms,
|
||||
candidate_terms,
|
||||
}
|
||||
}
|
||||
|
||||
fn token_matches_query_term(token: &str, query_term: &str) -> bool {
|
||||
token == query_term || (query_term.len() >= 3 && token.starts_with(query_term))
|
||||
}
|
||||
|
||||
fn token_matches_numeric_term(token: &str, query_term: &str) -> bool {
|
||||
token == query_term || token.starts_with(query_term)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn address_tokens_match_group(tokens: &[String], group: &AddressTermGroup) -> bool {
|
||||
group.alternatives.iter().any(|alternative| {
|
||||
tokens
|
||||
.iter()
|
||||
.any(|token| token_matches_query_term(token, alternative))
|
||||
})
|
||||
}
|
||||
|
||||
impl PropertyData {
|
||||
fn row_address_search_tokens(&self, row: usize) -> &[lasso::Spur] {
|
||||
let offset = self.address_search_token_offsets[row] as usize;
|
||||
let length = self.address_search_token_lengths[row] as usize;
|
||||
&self.address_search_token_keys[offset..offset + length]
|
||||
}
|
||||
|
||||
/// Search individual property addresses, returning `(row, score)` ranked best-first.
|
||||
///
|
||||
/// Candidate rows come from intersecting the posting lists of the distinctive words the
|
||||
/// user typed in full (so "Cherry Hinton Road" narrows to rows containing both), unioned
|
||||
/// with the exact-postcode rows when a complete postcode is present (so a postcode is a
|
||||
/// boost, not an all-or-nothing gate). An appended partial postcode keeps in-area rows
|
||||
/// ahead of the candidate cut and adds a scoring bias. With a road-type word and no house
|
||||
/// number, results collapse to one row per street.
|
||||
pub fn search_addresses(&self, query: &str, limit: usize) -> Vec<(usize, i32)> {
|
||||
if limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let parsed = parse_address_query(query);
|
||||
if parsed.full_postcode.is_none()
|
||||
&& parsed.text_groups.is_empty()
|
||||
&& parsed.numeric_terms.is_empty()
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut candidate_rows = self.address_candidate_rows(&parsed.candidate_terms);
|
||||
|
||||
// A complete postcode contributes its rows too, instead of replacing the road match.
|
||||
if let Some(postcode) = parsed.full_postcode.as_deref() {
|
||||
if let Some(rows) = self
|
||||
.postcode_interner
|
||||
.get(postcode)
|
||||
.and_then(|key| self.postcode_row_index.get(&key))
|
||||
{
|
||||
candidate_rows = if candidate_rows.is_empty() {
|
||||
rows.clone()
|
||||
} else {
|
||||
union_sorted(&candidate_rows, rows)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if candidate_rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// When the user appended a partial postcode, keep in-area rows ahead of the cut so the
|
||||
// refinement still surfaces even for very common roads. Single pass (stable partition) so
|
||||
// the postcode check — which allocates — runs exactly once per candidate.
|
||||
if let Some(area) = parsed.postcode_area.as_deref() {
|
||||
let mut in_area = Vec::new();
|
||||
let mut others = Vec::new();
|
||||
for &row in &candidate_rows {
|
||||
if self.row_postcode_in_area(row as usize, area) {
|
||||
in_area.push(row);
|
||||
} else {
|
||||
others.push(row);
|
||||
}
|
||||
}
|
||||
in_area.extend(others);
|
||||
candidate_rows = in_area;
|
||||
}
|
||||
candidate_rows.truncate(ADDRESS_SEARCH_CANDIDATE_LIMIT);
|
||||
|
||||
let mut scored: Vec<(i32, usize, usize)> = candidate_rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let row = row as usize;
|
||||
self.address_match_score(row, &parsed)
|
||||
.map(|score| (score, self.address(row).len(), row))
|
||||
})
|
||||
.collect();
|
||||
|
||||
scored.sort_unstable_by(|left, right| {
|
||||
right
|
||||
.0
|
||||
.cmp(&left.0)
|
||||
.then(left.1.cmp(&right.1))
|
||||
.then(left.2.cmp(&right.2))
|
||||
});
|
||||
|
||||
// Collapse a road browse (road-type word, no house number) to one row per street.
|
||||
let collapse_streets = parsed.numeric_terms.is_empty() && query_has_road_type(query);
|
||||
|
||||
let mut seen = FxHashSet::default();
|
||||
let mut results = Vec::with_capacity(limit);
|
||||
for (score, _, row) in scored {
|
||||
let address = self.address(row).trim();
|
||||
if address.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = if collapse_streets {
|
||||
format!(
|
||||
"{}\n{}",
|
||||
street_key(address),
|
||||
outcode_of(self.postcode(row))
|
||||
)
|
||||
} else {
|
||||
format!("{}\n{}", address.to_ascii_lowercase(), self.postcode(row))
|
||||
};
|
||||
if !seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
results.push((row, score));
|
||||
if results.len() == limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// True when the row's postcode begins with the compact partial-postcode `area`
|
||||
/// (e.g. "NW1" or "NW16" matches "NW1 6XE").
|
||||
fn row_postcode_in_area(&self, row: usize, area: &str) -> bool {
|
||||
let mut compact = String::new();
|
||||
for ch in self.postcode(row).chars() {
|
||||
if !ch.is_whitespace() {
|
||||
compact.push(ch.to_ascii_uppercase());
|
||||
}
|
||||
}
|
||||
compact.starts_with(area)
|
||||
}
|
||||
|
||||
/// Candidate rows for the distinctive query words. Words typed in full intersect by their
|
||||
/// exact posting lists (precise); a still-being-typed final word with no exact match seeds
|
||||
/// from the smallest prefix-expanded posting list (so partial typing keeps working).
|
||||
fn address_candidate_rows(&self, terms: &[String]) -> Vec<u32> {
|
||||
let mut exact: Vec<&[u32]> = terms
|
||||
.iter()
|
||||
.filter_map(|term| self.address_token_index.get(term).map(Vec::as_slice))
|
||||
.collect();
|
||||
|
||||
if !exact.is_empty() {
|
||||
exact.sort_by_key(|rows| rows.len());
|
||||
let mut acc = exact[0].to_vec();
|
||||
for rows in &exact[1..] {
|
||||
if acc.is_empty() {
|
||||
break;
|
||||
}
|
||||
acc = intersect_sorted(&acc, rows);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
self.prefix_seed_rows(terms)
|
||||
}
|
||||
|
||||
/// Seed rows from the smallest prefix-expanded term — used only when no word matched an
|
||||
/// indexed token exactly (i.e. the user is still typing the final word).
|
||||
fn prefix_seed_rows(&self, terms: &[String]) -> Vec<u32> {
|
||||
let mut best: Option<Vec<u32>> = None;
|
||||
for term in terms {
|
||||
if term.len() < ADDRESS_SEARCH_PREFIX_MIN_LEN {
|
||||
continue;
|
||||
}
|
||||
let Some(tokens) = self.address_prefix_index.get(address_prefix_key(term)) else {
|
||||
continue;
|
||||
};
|
||||
let mut union: Vec<u32> = Vec::new();
|
||||
for token in tokens {
|
||||
if !token.starts_with(term) {
|
||||
continue;
|
||||
}
|
||||
if let Some(rows) = self.address_token_index.get(token) {
|
||||
union = if union.is_empty() {
|
||||
rows.clone()
|
||||
} else {
|
||||
union_sorted(&union, rows)
|
||||
};
|
||||
}
|
||||
}
|
||||
if !union.is_empty()
|
||||
&& best
|
||||
.as_ref()
|
||||
.is_none_or(|current| union.len() < current.len())
|
||||
{
|
||||
best = Some(union);
|
||||
}
|
||||
}
|
||||
best.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn address_match_score(&self, row: usize, parsed: &AddressQuery) -> Option<i32> {
|
||||
if self.address(row).trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tokens = self.row_address_search_tokens(row);
|
||||
if parsed
|
||||
.text_groups
|
||||
.iter()
|
||||
.any(|group| !self.address_tokens_match_group(tokens, group))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let numeric_matches = parsed
|
||||
.numeric_terms
|
||||
.iter()
|
||||
.filter(|term| {
|
||||
tokens.iter().any(|token| {
|
||||
token_matches_numeric_term(self.address_search_interner.resolve(token), term)
|
||||
})
|
||||
})
|
||||
.count();
|
||||
|
||||
if !parsed.numeric_terms.is_empty() && numeric_matches == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut score = 0;
|
||||
if parsed.full_postcode.is_some() {
|
||||
score += 1_000;
|
||||
}
|
||||
score += (parsed.text_groups.len() as i32) * 200;
|
||||
score += (numeric_matches as i32) * 90;
|
||||
if numeric_matches == parsed.numeric_terms.len() && numeric_matches > 0 {
|
||||
score += 50;
|
||||
}
|
||||
// Additive bias (never a filter) when the row sits in the appended partial postcode.
|
||||
if let Some(area) = parsed.postcode_area.as_deref() {
|
||||
if self.row_postcode_in_area(row, area) {
|
||||
score += 400;
|
||||
}
|
||||
}
|
||||
|
||||
Some(score)
|
||||
}
|
||||
|
||||
fn address_tokens_match_group(&self, tokens: &[lasso::Spur], group: &AddressTermGroup) -> bool {
|
||||
group.alternatives.iter().any(|alternative| {
|
||||
tokens.iter().any(|token| {
|
||||
token_matches_query_term(self.address_search_interner.resolve(token), alternative)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn full_postcode_detection_accepts_common_formats() {
|
||||
assert!(is_full_postcode_compact("SW1A1AA"));
|
||||
assert!(is_full_postcode_compact("E142DG"));
|
||||
assert!(is_full_postcode_compact("M11AE"));
|
||||
assert!(!is_full_postcode_compact("E14"));
|
||||
assert!(!is_full_postcode_compact("DOWNING"));
|
||||
assert!(!is_full_postcode_compact("10A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_query_parsing_skips_postcodes_and_street_suffixes() {
|
||||
let parsed = parse_address_query("Flat 2, 10 Downing St, SW1A 2AA");
|
||||
|
||||
assert_eq!(parsed.full_postcode.as_deref(), Some("SW1A 2AA"));
|
||||
assert_eq!(
|
||||
parsed.numeric_terms,
|
||||
vec!["10".to_string(), "2".to_string()]
|
||||
);
|
||||
assert_eq!(parsed.candidate_terms, vec!["downing".to_string()]);
|
||||
assert_eq!(parsed.text_groups.len(), 1);
|
||||
assert_eq!(
|
||||
parsed.text_groups[0].alternatives,
|
||||
vec!["downing".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_query_parsing_handles_compact_postcodes() {
|
||||
let parsed = parse_address_query("10 downing street sw1a1aa");
|
||||
|
||||
assert_eq!(parsed.full_postcode.as_deref(), Some("SW1A 1AA"));
|
||||
assert_eq!(parsed.numeric_terms, vec!["10".to_string()]);
|
||||
assert_eq!(parsed.candidate_terms, vec!["downing".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_query_recovers_appended_partial_postcode_as_bias() {
|
||||
let parsed = parse_address_query("Baker Street NW1");
|
||||
assert_eq!(parsed.full_postcode, None);
|
||||
assert_eq!(parsed.postcode_area.as_deref(), Some("NW1"));
|
||||
// The road words are still searchable; the postcode fragment did not consume them.
|
||||
assert_eq!(parsed.candidate_terms, vec!["baker".to_string()]);
|
||||
assert!(parsed.numeric_terms.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_query_recovers_outcode_plus_sector_without_a_phantom_house_number() {
|
||||
let parsed = parse_address_query("High Street CR0 2");
|
||||
assert_eq!(parsed.postcode_area.as_deref(), Some("CR02"));
|
||||
// The lone sector digit must not be treated as a house number.
|
||||
assert!(parsed.numeric_terms.is_empty());
|
||||
assert_eq!(parsed.candidate_terms, vec!["high".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_postcode_takes_precedence_over_partial_bias() {
|
||||
let parsed = parse_address_query("Baker Street NW1 6XE");
|
||||
assert_eq!(parsed.full_postcode.as_deref(), Some("NW1 6XE"));
|
||||
assert_eq!(parsed.postcode_area, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_and_union_sorted_row_ids() {
|
||||
assert_eq!(
|
||||
intersect_sorted(&[1, 2, 3, 5], &[2, 3, 4, 5]),
|
||||
vec![2, 3, 5]
|
||||
);
|
||||
assert_eq!(intersect_sorted(&[1, 2], &[3, 4]), Vec::<u32>::new());
|
||||
assert_eq!(union_sorted(&[1, 3, 5], &[2, 3, 4]), vec![1, 2, 3, 4, 5]);
|
||||
assert_eq!(union_sorted(&[], &[2, 4]), vec![2, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn street_key_collapses_house_numbers_and_flats() {
|
||||
assert_eq!(street_key("12 Baker Street"), "baker street");
|
||||
assert_eq!(street_key("5 Baker Street"), "baker street");
|
||||
assert_eq!(street_key("Flat 2, 10 Downing Street"), "downing street");
|
||||
assert_eq!(street_key("221B Baker Street"), "baker street");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn street_key_keeps_ordinal_street_names() {
|
||||
// Ordinals are part of the street name, not a house-number prefix.
|
||||
assert_eq!(street_key("2nd Avenue"), "2nd avenue");
|
||||
assert_eq!(street_key("12 3rd Avenue"), "3rd avenue");
|
||||
assert!(is_ordinal_token("21st"));
|
||||
assert!(!is_ordinal_token("21"));
|
||||
assert!(!is_ordinal_token("221b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postcode_area_recovered_only_from_the_trailing_position() {
|
||||
// A leading road designation must NOT be taken as an area refinement.
|
||||
let parsed = parse_address_query("A4 Great West Road");
|
||||
assert_eq!(parsed.postcode_area, None);
|
||||
// A genuine trailing outcode still is.
|
||||
let trailing = parse_address_query("Great West Road W4");
|
||||
assert_eq!(trailing.postcode_area.as_deref(), Some("W4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn road_type_detection() {
|
||||
assert!(query_has_road_type("high street"));
|
||||
assert!(query_has_road_type("acacia avenue"));
|
||||
assert!(!query_has_road_type("acacia"));
|
||||
assert!(!query_has_road_type("london"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_query_parsing_keeps_partial_terms_for_row_matching() {
|
||||
let parsed = parse_address_query("settlers cour");
|
||||
|
||||
assert_eq!(parsed.full_postcode, None);
|
||||
assert_eq!(parsed.numeric_terms, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
parsed.candidate_terms,
|
||||
vec!["settlers".to_string(), "cour".to_string()]
|
||||
);
|
||||
assert_eq!(parsed.text_groups.len(), 2);
|
||||
assert_eq!(
|
||||
parsed.text_groups[0].alternatives,
|
||||
vec!["settlers".to_string()]
|
||||
);
|
||||
assert_eq!(parsed.text_groups[1].alternatives, vec!["cour".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_search_tokens_keep_actual_address_terms_for_scoring() {
|
||||
let tokens = address_search_tokens("Flat 2, 10 Downing Cour");
|
||||
|
||||
assert_eq!(
|
||||
tokens,
|
||||
vec![
|
||||
"10".to_string(),
|
||||
"2".to_string(),
|
||||
"cour".to_string(),
|
||||
"downing".to_string(),
|
||||
"flat".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_prefix_index_finds_partial_address_terms() {
|
||||
let mut token_index: FxHashMap<String, Vec<u32>> = FxHashMap::default();
|
||||
token_index.insert("downing".to_string(), vec![1]);
|
||||
token_index.insert("downton".to_string(), vec![2]);
|
||||
token_index.insert("market".to_string(), vec![3]);
|
||||
|
||||
let prefix_index = build_address_prefix_index(&token_index);
|
||||
|
||||
assert_eq!(
|
||||
prefix_index.get("down").cloned().unwrap_or_default(),
|
||||
vec!["downing".to_string(), "downton".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
prefix_index.get("downi").cloned().unwrap_or_default(),
|
||||
vec!["downing".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
prefix_index.get("downt").cloned().unwrap_or_default(),
|
||||
vec!["downton".to_string()]
|
||||
);
|
||||
assert!(!prefix_index.contains_key("do"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_term_matching_allows_prefixes_and_aliases() {
|
||||
let tokens = tokenize_address_text("10 Downing Street");
|
||||
let prefix_group = address_term_group("down").expect("prefix term should be searchable");
|
||||
let alias_group = AddressTermGroup {
|
||||
alternatives: vec!["st".to_string(), "street".to_string()],
|
||||
};
|
||||
|
||||
assert!(address_tokens_match_group(&tokens, &prefix_group));
|
||||
assert!(address_tokens_match_group(&tokens, &alias_group));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_term_matching_uses_actual_token_prefixes() {
|
||||
let tokens = tokenize_address_text("12 Settlers Court");
|
||||
let prefix_group = address_term_group("cou").expect("partial term should be searchable");
|
||||
|
||||
assert!(address_tokens_match_group(&tokens, &prefix_group));
|
||||
}
|
||||
}
|
||||
34
server-rs/src/data/property/h3.rs
Normal file
34
server-rs/src/data/property/h3.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//! H3 spatial cell precomputation for property rows.
|
||||
|
||||
use anyhow::Context;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::consts::H3_PRECOMPUTE_MAX;
|
||||
|
||||
/// Precompute H3 cell IDs for all rows at the maximum resolution only.
|
||||
/// Parent cells for lower resolutions are derived on the fly via `CellIndex::parent()`.
|
||||
pub fn precompute_h3(lat: &[f32], lon: &[f32]) -> anyhow::Result<Vec<u64>> {
|
||||
let res = H3_PRECOMPUTE_MAX;
|
||||
tracing::info!("Precomputing H3 cells at resolution {}", res);
|
||||
|
||||
let h3_res =
|
||||
h3o::Resolution::try_from(res).with_context(|| format!("Invalid H3 resolution: {res}"))?;
|
||||
|
||||
let cells: Vec<u64> = lat
|
||||
.par_iter()
|
||||
.zip(lon.par_iter())
|
||||
.enumerate()
|
||||
.map(|(i, (&latitude, &longitude))| {
|
||||
let coord = h3o::LatLng::new(latitude as f64, longitude as f64).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"Invalid coordinates at row {}: lat={}, lon={}: {}",
|
||||
i, latitude, longitude, err
|
||||
)
|
||||
});
|
||||
u64::from(coord.to_cell(h3_res))
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::info!("H3 precomputation complete ({} cells)", cells.len());
|
||||
Ok(cells)
|
||||
}
|
||||
1105
server-rs/src/data/property/loading.rs
Normal file
1105
server-rs/src/data/property/loading.rs
Normal file
File diff suppressed because it is too large
Load diff
238
server-rs/src/data/property/mod.rs
Normal file
238
server-rs/src/data/property/mod.rs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
//! 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;
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
pub struct PropertyData {
|
||||
pub lat: Vec<f32>,
|
||||
pub lon: Vec<f32>,
|
||||
pub feature_names: Vec<String>,
|
||||
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.
|
||||
pub feature_data: Vec<u16>,
|
||||
/// Per-feature: range / QUANT_SCALE for fast decode.
|
||||
dequant_a: Vec<f32>,
|
||||
/// Per-feature: minimum value (offset for dequantization).
|
||||
quant_min: Vec<f32>,
|
||||
/// Per-feature: max - min (for encoding filter bounds).
|
||||
quant_range: Vec<f32>,
|
||||
pub feature_stats: Vec<FeatureStats>,
|
||||
pub poi_metrics: PostcodePoiMetrics,
|
||||
/// Unquantized last sale price used by the price-history chart.
|
||||
last_known_price_raw: Vec<f32>,
|
||||
/// Contiguous buffer holding all address strings end-to-end.
|
||||
address_buffer: String,
|
||||
/// Byte offset into `address_buffer` where each row's address starts.
|
||||
address_offsets: Vec<u32>,
|
||||
/// Length in bytes of each row's address.
|
||||
address_lengths: Vec<u16>,
|
||||
/// Interned postcodes: reader is thread-safe, keys index into it.
|
||||
postcode_interner: lasso::RodeoReader,
|
||||
postcode_keys: Vec<lasso::Spur>,
|
||||
/// Rows for each postcode, keyed by the interned postcode key.
|
||||
postcode_row_index: FxHashMap<lasso::Spur, Vec<u32>>,
|
||||
/// Inverted index from address tokens to property rows.
|
||||
address_token_index: FxHashMap<String, Vec<u32>>,
|
||||
/// Prefix lookup from typed address-token prefix to indexed full address tokens.
|
||||
address_prefix_index: FxHashMap<String, Vec<String>>,
|
||||
/// Interned normalized address-search tokens used for per-row scoring.
|
||||
address_search_interner: lasso::RodeoReader,
|
||||
/// Flat per-row normalized address-search token keys.
|
||||
address_search_token_keys: Vec<lasso::Spur>,
|
||||
/// Offset into `address_search_token_keys` for each row.
|
||||
address_search_token_offsets: Vec<u32>,
|
||||
/// Number of normalized address-search token keys for each row.
|
||||
address_search_token_lengths: Vec<u16>,
|
||||
/// 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<usize, Vec<String>>,
|
||||
/// For enum features: maps feature index to per-value global counts (same order as enum_values).
|
||||
pub enum_counts: rustc_hash::FxHashMap<usize, Vec<u64>>,
|
||||
/// 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<bool>.
|
||||
approx_build_date_bits: Vec<u8>,
|
||||
/// Per-row renovation events. Keyed by (permuted) row index.
|
||||
/// Only rows with events are present in the map.
|
||||
renovation_history: FxHashMap<u32, Vec<RenovationEvent>>,
|
||||
/// Per-row historical sale transactions (Land Registry price-paid).
|
||||
/// Keyed by (permuted) row index. Only rows with prices are present.
|
||||
historical_prices: FxHashMap<u32, Vec<HistoricalPrice>>,
|
||||
property_sub_type: FxHashMap<u32, String>,
|
||||
price_qualifier: FxHashMap<u32, String>,
|
||||
}
|
||||
|
||||
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;
|
||||
&self.address_buffer[offset..offset + length]
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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(&[])
|
||||
}
|
||||
|
||||
/// 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 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: 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: String::new(),
|
||||
address_offsets: Vec::new(),
|
||||
address_lengths: Vec::new(),
|
||||
postcode_interner: lasso::Rodeo::default().into_reader(),
|
||||
postcode_keys: Vec::new(),
|
||||
postcode_row_index: FxHashMap::default(),
|
||||
address_token_index: FxHashMap::default(),
|
||||
address_prefix_index: FxHashMap::default(),
|
||||
address_search_interner: lasso::Rodeo::default().into_reader(),
|
||||
address_search_token_keys: Vec::new(),
|
||||
address_search_token_offsets: Vec::new(),
|
||||
address_search_token_lengths: 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(),
|
||||
property_sub_type: FxHashMap::default(),
|
||||
price_qualifier: FxHashMap::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
200
server-rs/src/data/property/poi_metrics.rs
Normal file
200
server-rs/src/data/property/poi_metrics.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
//! Postcode-level POI metric side table: dynamic POI features are stored once
|
||||
//! per postcode (not per property row) to keep the hot row-major feature matrix
|
||||
//! narrow, with a per-property row mapping for lookups.
|
||||
|
||||
use anyhow::Context;
|
||||
use polars::prelude::*;
|
||||
use rayon::prelude::*;
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::consts::{NAN_U16, QUANT_SCALE};
|
||||
use crate::features::{self, Bounds};
|
||||
|
||||
use super::quant::QuantRef;
|
||||
use super::stats::{column_to_f32_vec, compute_feature_stats, FeatureStats};
|
||||
|
||||
pub(super) const NO_POI_METRIC_ROW: u32 = u32::MAX;
|
||||
|
||||
pub struct PostcodePoiMetrics {
|
||||
pub feature_names: Vec<String>,
|
||||
pub name_to_index: FxHashMap<String, usize>,
|
||||
/// Metric-major storage: columns[metric_idx][postcode_metric_idx].
|
||||
pub columns: Vec<Vec<u16>>,
|
||||
pub feature_stats: Vec<FeatureStats>,
|
||||
/// Per-property row lookup into the postcode metric table.
|
||||
row_to_metric_idx: Vec<u32>,
|
||||
dequant_a: Vec<f32>,
|
||||
quant_min: Vec<f32>,
|
||||
quant_range: Vec<f32>,
|
||||
}
|
||||
|
||||
impl PostcodePoiMetrics {
|
||||
pub(super) fn empty(row_count: usize) -> Self {
|
||||
Self {
|
||||
feature_names: Vec::new(),
|
||||
name_to_index: FxHashMap::default(),
|
||||
columns: Vec::new(),
|
||||
feature_stats: Vec::new(),
|
||||
row_to_metric_idx: vec![NO_POI_METRIC_ROW; row_count],
|
||||
dequant_a: Vec::new(),
|
||||
quant_min: Vec::new(),
|
||||
quant_range: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn from_postcode_df(
|
||||
df: &DataFrame,
|
||||
feature_names: Vec<String>,
|
||||
) -> anyhow::Result<Self> {
|
||||
if feature_names.is_empty() {
|
||||
return Ok(Self::empty(0));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
metrics = feature_names.len(),
|
||||
postcodes = df.height(),
|
||||
"Building postcode POI metric side table"
|
||||
);
|
||||
|
||||
let col_major: Vec<Vec<f32>> = feature_names
|
||||
.par_iter()
|
||||
.map(|name| {
|
||||
let column = df
|
||||
.column(name.as_str())
|
||||
.with_context(|| format!("Missing POI metric column '{name}'"))?;
|
||||
column_to_f32_vec(column)
|
||||
})
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
|
||||
let feature_stats: Vec<FeatureStats> = col_major
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.map(|(metric_idx, vals)| {
|
||||
let name = feature_names[metric_idx].as_str();
|
||||
let bounds = features::bounds_for(name)
|
||||
.with_context(|| format!("No bounds config for POI metric '{name}'"))?;
|
||||
Ok(compute_feature_stats(
|
||||
vals,
|
||||
&bounds,
|
||||
features::has_integer_bins(name),
|
||||
))
|
||||
})
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
|
||||
let mut quant_min = Vec::with_capacity(feature_names.len());
|
||||
let mut quant_range = Vec::with_capacity(feature_names.len());
|
||||
for (metric_idx, stats) in feature_stats.iter().enumerate() {
|
||||
let (min, max) = match features::bounds_for(feature_names[metric_idx].as_str()) {
|
||||
Some(Bounds::Fixed { min, max }) => (min, max),
|
||||
_ => (stats.histogram.min, stats.histogram.max),
|
||||
};
|
||||
quant_min.push(min);
|
||||
quant_range.push(if max > min { max - min } else { 0.0 });
|
||||
}
|
||||
let dequant_a: Vec<f32> = quant_range
|
||||
.iter()
|
||||
.map(|&range| {
|
||||
if range > 0.0 {
|
||||
range / QUANT_SCALE
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let columns: Vec<Vec<u16>> = col_major
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.map(|(metric_idx, vals)| {
|
||||
let range = quant_range[metric_idx];
|
||||
let min = quant_min[metric_idx];
|
||||
vals.iter()
|
||||
.map(|&value| {
|
||||
if !value.is_finite() {
|
||||
NAN_U16
|
||||
} else if range > 0.0 {
|
||||
let normalized = (value - min) / range;
|
||||
(normalized * QUANT_SCALE).round().clamp(0.0, QUANT_SCALE) as u16
|
||||
} else {
|
||||
0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let name_to_index = feature_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, name)| (name.clone(), idx))
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
feature_names,
|
||||
name_to_index,
|
||||
columns,
|
||||
feature_stats,
|
||||
row_to_metric_idx: Vec::new(),
|
||||
dequant_a,
|
||||
quant_min,
|
||||
quant_range,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn set_row_mapping(&mut self, row_to_metric_idx: Vec<u32>) {
|
||||
self.row_to_metric_idx = row_to_metric_idx;
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.feature_names.is_empty()
|
||||
}
|
||||
|
||||
pub fn num_features(&self) -> usize {
|
||||
self.feature_names.len()
|
||||
}
|
||||
|
||||
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.feature_names.len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn metric_row_for_property(&self, row: usize) -> Option<usize> {
|
||||
self.row_to_metric_idx
|
||||
.get(row)
|
||||
.copied()
|
||||
.filter(|&idx| idx != NO_POI_METRIC_ROW)
|
||||
.map(|idx| idx as usize)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn raw_for_metric_row(&self, metric_row: usize, metric_idx: usize) -> u16 {
|
||||
self.columns[metric_idx][metric_row]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn raw_for_property_row(&self, row: usize, metric_idx: usize) -> u16 {
|
||||
let Some(metric_row) = self.metric_row_for_property(row) else {
|
||||
return NAN_U16;
|
||||
};
|
||||
self.raw_for_metric_row(metric_row, metric_idx)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn decode_raw(&self, metric_idx: usize, raw: u16) -> f32 {
|
||||
if raw == NAN_U16 {
|
||||
f32::NAN
|
||||
} else {
|
||||
raw as f32 * self.dequant_a[metric_idx] + self.quant_min[metric_idx]
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_for_property_row(&self, row: usize, metric_idx: usize) -> f32 {
|
||||
self.decode_raw(metric_idx, self.raw_for_property_row(row, metric_idx))
|
||||
}
|
||||
}
|
||||
46
server-rs/src/data/property/quant.rs
Normal file
46
server-rs/src/data/property/quant.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! u16 quantization: decoding stored feature values and encoding filter bounds.
|
||||
|
||||
use crate::consts::{NAN_U16, QUANT_SCALE};
|
||||
|
||||
/// Lightweight reference to quantization parameters for decoding u16 feature data.
|
||||
pub struct QuantRef<'a> {
|
||||
pub dequant_a: &'a [f32],
|
||||
pub quant_min: &'a [f32],
|
||||
pub quant_range: &'a [f32],
|
||||
pub num_numeric: usize,
|
||||
}
|
||||
|
||||
impl QuantRef<'_> {
|
||||
/// Decode a raw u16 value back to f32.
|
||||
#[inline]
|
||||
pub fn decode(&self, feat_idx: usize, raw: u16) -> f32 {
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a filter minimum bound to u16 (floors to include boundary values).
|
||||
#[inline]
|
||||
pub fn encode_min(&self, feat_idx: usize, value: f32) -> u16 {
|
||||
if !value.is_finite() || self.quant_range[feat_idx] == 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let norm = (value - self.quant_min[feat_idx]) / self.quant_range[feat_idx];
|
||||
(norm * QUANT_SCALE).floor().clamp(0.0, QUANT_SCALE) as u16
|
||||
}
|
||||
|
||||
/// Encode a filter maximum bound to u16 (ceils to include boundary values).
|
||||
#[inline]
|
||||
pub fn encode_max(&self, feat_idx: usize, value: f32) -> u16 {
|
||||
if !value.is_finite() || self.quant_range[feat_idx] == 0.0 {
|
||||
return QUANT_SCALE as u16;
|
||||
}
|
||||
let norm = (value - self.quant_min[feat_idx]) / self.quant_range[feat_idx];
|
||||
(norm * QUANT_SCALE).ceil().clamp(0.0, QUANT_SCALE) as u16
|
||||
}
|
||||
}
|
||||
544
server-rs/src/data/property/stats.rs
Normal file
544
server-rs/src/data/property/stats.rs
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
//! Feature statistics: outlier-bracketed histograms, percentile estimation and
|
||||
//! slider-bound computation.
|
||||
|
||||
use anyhow::Context;
|
||||
use polars::prelude::*;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::consts::HISTOGRAM_BINS;
|
||||
use crate::features::Bounds;
|
||||
|
||||
/// Histogram with outlier buckets at the edges.
|
||||
/// - Bin 0: [min, p1) — low outliers
|
||||
/// - Bins 1 to n-2: [p1, p99) — main distribution, evenly divided
|
||||
/// - Bin n-1: [p99, max] — high outliers
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct Histogram {
|
||||
pub min: f32,
|
||||
pub max: f32,
|
||||
/// 1st percentile (left edge of main distribution)
|
||||
pub p1: f32,
|
||||
/// 99th percentile (right edge of main distribution)
|
||||
pub p99: f32,
|
||||
pub counts: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Histogram {
|
||||
/// Return the bin index for a given value using the outlier-bracket layout.
|
||||
#[cfg(test)]
|
||||
pub fn bin_for_value(&self, value: f32) -> usize {
|
||||
let num_bins = self.counts.len();
|
||||
if value < self.p1 {
|
||||
0
|
||||
} else if value >= self.p99 {
|
||||
num_bins - 1
|
||||
} else {
|
||||
let middle_bins = num_bins.saturating_sub(2);
|
||||
if middle_bins > 0 && self.p99 > self.p1 {
|
||||
let width = (self.p99 - self.p1) / middle_bins as f32;
|
||||
let middle_bin = ((value - self.p1) / width) as usize;
|
||||
(1 + middle_bin).min(num_bins - 2)
|
||||
} else {
|
||||
num_bins / 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of a single middle bin (bins 1..n-2).
|
||||
#[cfg(test)]
|
||||
pub fn middle_bin_width(&self) -> f32 {
|
||||
let middle_bins = self.counts.len().saturating_sub(2);
|
||||
if middle_bins > 0 && self.p99 > self.p1 {
|
||||
(self.p99 - self.p1) / middle_bins as f32
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FeatureStats {
|
||||
pub slider_min: f32,
|
||||
pub slider_max: f32,
|
||||
pub histogram: Histogram,
|
||||
}
|
||||
|
||||
/// Compute a percentile from a uniformly-binned histogram.
|
||||
/// `prelim_counts` are uniform bins over [min, max].
|
||||
fn percentile_from_uniform_histogram(
|
||||
count: usize,
|
||||
min: f32,
|
||||
max: f32,
|
||||
prelim_counts: &[u64],
|
||||
percentile: f32,
|
||||
) -> f32 {
|
||||
if count == 0 || prelim_counts.is_empty() {
|
||||
return min;
|
||||
}
|
||||
let target = (count as f64 * percentile as f64 / 100.0).floor() as u64;
|
||||
let bin_width = (max - min) / prelim_counts.len() as f32;
|
||||
let mut cumulative = 0u64;
|
||||
for (i, &bin_count) in prelim_counts.iter().enumerate() {
|
||||
let prev_cumulative = cumulative;
|
||||
cumulative += bin_count;
|
||||
if cumulative > target {
|
||||
// Interpolate within this bin
|
||||
let bin_start = min + i as f32 * bin_width;
|
||||
let fraction = if bin_count > 0 {
|
||||
(target - prev_cumulative) as f32 / bin_count as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
return bin_start + fraction * bin_width;
|
||||
}
|
||||
}
|
||||
max
|
||||
}
|
||||
|
||||
/// Build a histogram and compute slider bounds based on the feature's Bounds config.
|
||||
pub fn compute_feature_stats(vals: &[f32], bounds: &Bounds, integer_bins: bool) -> FeatureStats {
|
||||
// Single pass: min, max, count (skipping NaN and infinity)
|
||||
let mut min = f32::INFINITY;
|
||||
let mut max = f32::NEG_INFINITY;
|
||||
let mut count = 0usize;
|
||||
for &value in vals {
|
||||
if value.is_finite() {
|
||||
if value < min {
|
||||
min = value;
|
||||
}
|
||||
if value > max {
|
||||
max = value;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
let (slider_min, slider_max) = match bounds {
|
||||
Bounds::Fixed {
|
||||
min: fmin,
|
||||
max: fmax,
|
||||
} => (*fmin, *fmax),
|
||||
Bounds::Percentile { .. } => (0.0, 0.0),
|
||||
};
|
||||
return FeatureStats {
|
||||
slider_min,
|
||||
slider_max,
|
||||
histogram: Histogram {
|
||||
min: 0.0,
|
||||
max: 0.0,
|
||||
p1: 0.0,
|
||||
p99: 0.0,
|
||||
counts: vec![0; HISTOGRAM_BINS],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build preliminary histogram with uniform bins to compute percentiles
|
||||
// Use full HISTOGRAM_BINS for percentile precision
|
||||
let range = if max == min { 1.0 } else { max - min };
|
||||
let prelim_max = min + range * (1.0 + 1e-6);
|
||||
let prelim_bin_width = (prelim_max - min) / HISTOGRAM_BINS as f32;
|
||||
|
||||
let mut prelim_counts = vec![0u64; HISTOGRAM_BINS];
|
||||
for &value in vals {
|
||||
if value.is_finite() {
|
||||
let bin = ((value - min) / prelim_bin_width) as usize;
|
||||
prelim_counts[bin.min(HISTOGRAM_BINS - 1)] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute p1 and p99 from preliminary histogram
|
||||
let mut p1 = percentile_from_uniform_histogram(count, min, max, &prelim_counts, 1.0);
|
||||
let mut p99 = percentile_from_uniform_histogram(count, min, max, &prelim_counts, 99.0);
|
||||
|
||||
// Iterative refinement for outlier-dominated distributions.
|
||||
// When extreme outliers (e.g. 317M sqm from web scraping) dominate the range,
|
||||
// the uniform histogram puts all real data in one bin, making percentile
|
||||
// estimation useless. Zoom into the estimated data region and recompute.
|
||||
let mut refined_counts = prelim_counts;
|
||||
let mut refined_count = count;
|
||||
let mut refined_min = min;
|
||||
let mut refined_max = max;
|
||||
for _ in 0..3 {
|
||||
let iqr = p99 - p1;
|
||||
if iqr <= 0.0 || (refined_max - refined_min) <= 5.0 * iqr {
|
||||
break;
|
||||
}
|
||||
let new_min = (p1 - iqr).max(min);
|
||||
let new_max = p99 + iqr;
|
||||
if new_max <= new_min {
|
||||
break;
|
||||
}
|
||||
let bin_width = (new_max - new_min) / HISTOGRAM_BINS as f32;
|
||||
let mut counts = vec![0u64; HISTOGRAM_BINS];
|
||||
let mut cnt = 0usize;
|
||||
for &value in vals {
|
||||
if value.is_finite() && value >= new_min && value <= new_max {
|
||||
let bin = ((value - new_min) / bin_width) as usize;
|
||||
counts[bin.min(HISTOGRAM_BINS - 1)] += 1;
|
||||
cnt += 1;
|
||||
}
|
||||
}
|
||||
if cnt == 0 {
|
||||
break;
|
||||
}
|
||||
p1 = percentile_from_uniform_histogram(cnt, new_min, new_max, &counts, 1.0);
|
||||
p99 = percentile_from_uniform_histogram(cnt, new_min, new_max, &counts, 99.0);
|
||||
refined_counts = counts;
|
||||
refined_count = cnt;
|
||||
refined_min = new_min;
|
||||
refined_max = new_max;
|
||||
}
|
||||
|
||||
// For integer-binned features, snap p1/p99 to integer boundaries
|
||||
// so each middle bin is exactly 1 unit wide.
|
||||
if integer_bins {
|
||||
p1 = p1.floor();
|
||||
p99 = p99.ceil();
|
||||
}
|
||||
|
||||
// Determine number of histogram bins
|
||||
let num_bins = if integer_bins && p99 > p1 {
|
||||
// One middle bin per integer + 2 outlier bins
|
||||
(p99 - p1) as usize + 2
|
||||
} else {
|
||||
// Count unique values within the p1–p99 range to cap histogram bins.
|
||||
// Using the full-range cardinality would over-allocate bins when outliers
|
||||
// inflate it (e.g. bedrooms: 1–137 unique values but only ~10 within p1–p99).
|
||||
let cardinality = {
|
||||
let mut unique_set = rustc_hash::FxHashSet::default();
|
||||
for &val in vals {
|
||||
if val.is_finite() && val >= p1 && val <= p99 {
|
||||
unique_set.insert(val.to_bits());
|
||||
}
|
||||
}
|
||||
unique_set.len()
|
||||
};
|
||||
HISTOGRAM_BINS.min(cardinality).max(3)
|
||||
};
|
||||
|
||||
// Build final histogram with outlier bins at edges:
|
||||
// - Bin 0: [min, p1) — low outliers
|
||||
// - Bins 1 to n-2: [p1, p99) — main distribution, evenly divided
|
||||
// - Bin n-1: [p99, max] — high outliers
|
||||
let mut counts = vec![0u64; num_bins];
|
||||
let middle_bins = num_bins.saturating_sub(2);
|
||||
let middle_width = if middle_bins > 0 && p99 > p1 {
|
||||
(p99 - p1) / middle_bins as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
for &value in vals {
|
||||
if value.is_finite() {
|
||||
let bin = if value < p1 {
|
||||
0 // Low outlier bin
|
||||
} else if value >= p99 {
|
||||
num_bins - 1 // High outlier bin
|
||||
} else if middle_width > 0.0 {
|
||||
// Middle bins (1 to n-2)
|
||||
let middle_bin = ((value - p1) / middle_width) as usize;
|
||||
(1 + middle_bin).min(num_bins - 2)
|
||||
} else {
|
||||
num_bins / 2 // Fallback if p1 == p99
|
||||
};
|
||||
counts[bin] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let histogram = Histogram {
|
||||
min: refined_min,
|
||||
max: refined_max,
|
||||
p1,
|
||||
p99,
|
||||
counts,
|
||||
};
|
||||
|
||||
// Compute slider bounds (use refined histogram for accurate percentiles)
|
||||
let (slider_min, slider_max) = match bounds {
|
||||
Bounds::Fixed {
|
||||
min: fmin,
|
||||
max: fmax,
|
||||
} => (*fmin, *fmax),
|
||||
Bounds::Percentile { low, high } => {
|
||||
let p_low = percentile_from_uniform_histogram(
|
||||
refined_count,
|
||||
refined_min,
|
||||
refined_max,
|
||||
&refined_counts,
|
||||
*low as f32,
|
||||
);
|
||||
let p_high = percentile_from_uniform_histogram(
|
||||
refined_count,
|
||||
refined_min,
|
||||
refined_max,
|
||||
&refined_counts,
|
||||
*high as f32,
|
||||
);
|
||||
(p_low, p_high)
|
||||
}
|
||||
};
|
||||
|
||||
FeatureStats {
|
||||
slider_min,
|
||||
slider_max,
|
||||
histogram,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn column_to_f32_vec(column: &Column) -> anyhow::Result<Vec<f32>> {
|
||||
let float_series = column
|
||||
.cast(&DataType::Float32)
|
||||
.context("Failed to cast column to Float32")?;
|
||||
let chunked = float_series
|
||||
.f32()
|
||||
.context("Failed to get f32 chunked array")?;
|
||||
Ok(chunked
|
||||
.into_iter()
|
||||
.map(|value| value.unwrap_or(f32::NAN))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::consts::QUANT_SCALE;
|
||||
use crate::features::Bounds;
|
||||
|
||||
fn make_fixed_bounds(min: f32, max: f32) -> Bounds {
|
||||
Bounds::Fixed { min, max }
|
||||
}
|
||||
|
||||
fn make_percentile_bounds(low: f64, high: f64) -> Bounds {
|
||||
Bounds::Percentile { low, high }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_empty_data() {
|
||||
let data: Vec<f32> = vec![];
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds, false);
|
||||
|
||||
assert_eq!(stats.slider_min, 0.0);
|
||||
assert_eq!(stats.slider_max, 100.0);
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_single_value() {
|
||||
let data = vec![50.0_f32];
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds, false);
|
||||
|
||||
assert_eq!(stats.histogram.min, 50.0);
|
||||
assert_eq!(stats.histogram.max, 50.0);
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_uniform_distribution() {
|
||||
let data: Vec<f32> = (0..100).map(|i| i as f32).collect();
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds, false);
|
||||
|
||||
assert_eq!(stats.histogram.min, 0.0);
|
||||
assert_eq!(stats.histogram.max, 99.0);
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_with_nan_values() {
|
||||
let data = vec![10.0_f32, f32::NAN, 20.0, f32::NAN, 30.0];
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds, false);
|
||||
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 3);
|
||||
assert_eq!(stats.histogram.min, 10.0);
|
||||
assert_eq!(stats.histogram.max, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_all_nan() {
|
||||
let data = vec![f32::NAN, f32::NAN, f32::NAN];
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds, false);
|
||||
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_all_same_value() {
|
||||
let data = vec![42.0_f32; 1000];
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds, false);
|
||||
|
||||
assert_eq!(stats.histogram.min, 42.0);
|
||||
assert_eq!(stats.histogram.max, 42.0);
|
||||
assert_eq!(stats.histogram.p1, 42.0);
|
||||
assert_eq!(stats.histogram.p99, 42.0);
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_percentile_bounds() {
|
||||
let mut data: Vec<f32> = vec![0.0]; // Low outlier
|
||||
data.extend((1..99).map(|i| 50.0 + i as f32 * 0.01));
|
||||
data.push(1000.0); // High outlier
|
||||
|
||||
let bounds = make_percentile_bounds(2.0, 98.0);
|
||||
let stats = compute_feature_stats(&data, &bounds, false);
|
||||
|
||||
assert!(stats.slider_min > 0.0);
|
||||
assert!(stats.slider_max < 1000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_price_bounds_keep_slider_cap() {
|
||||
let data = vec![400_000.0_f32, 2_500_000.0, 3_750_000.0];
|
||||
let bounds = make_fixed_bounds(0.0, 2_500_000.0);
|
||||
let stats = compute_feature_stats(&data, &bounds, false);
|
||||
|
||||
assert_eq!(stats.slider_min, 0.0);
|
||||
assert_eq!(stats.slider_max, 2_500_000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_bin_for_value() {
|
||||
let hist = Histogram {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
p1: 10.0,
|
||||
p99: 90.0,
|
||||
counts: vec![0; 10],
|
||||
};
|
||||
|
||||
assert_eq!(hist.bin_for_value(5.0), 0); // Low outlier bin
|
||||
assert_eq!(hist.bin_for_value(95.0), 9); // High outlier bin
|
||||
|
||||
let mid_value = 50.0;
|
||||
let bin = hist.bin_for_value(mid_value);
|
||||
assert!((1..=8).contains(&bin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_middle_bin_width() {
|
||||
let hist = Histogram {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
p1: 10.0,
|
||||
p99: 90.0,
|
||||
counts: vec![0; 10],
|
||||
};
|
||||
|
||||
let expected_width = (90.0 - 10.0) / 8.0;
|
||||
assert!((hist.middle_bin_width() - expected_width).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_cardinality_caps_bins() {
|
||||
let data = vec![1.0_f32, 1.0, 2.0, 2.0, 3.0, 3.0];
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds, false);
|
||||
|
||||
assert_eq!(stats.histogram.counts.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_max_skips_nan() {
|
||||
let values = vec![10.0_f32, f32::NAN, 20.0, f32::NAN, 5.0];
|
||||
|
||||
let mut min = f32::INFINITY;
|
||||
let mut max = f32::NEG_INFINITY;
|
||||
for &v in &values {
|
||||
if v.is_finite() {
|
||||
if v < min {
|
||||
min = v;
|
||||
}
|
||||
if v > max {
|
||||
max = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(min, 5.0);
|
||||
assert_eq!(max, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_skips_nan() {
|
||||
let values = [1.0_f32, f32::NAN, 2.0, f32::NAN, 3.0];
|
||||
let count = values.iter().filter(|v| v.is_finite()).count();
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infinity_values_excluded() {
|
||||
let data = vec![f32::INFINITY, f32::NEG_INFINITY, 50.0];
|
||||
let bounds = Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
};
|
||||
let stats = compute_feature_stats(&data, &bounds, false);
|
||||
|
||||
assert_eq!(stats.histogram.min, 50.0);
|
||||
assert_eq!(stats.histogram.max, 50.0);
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_finite_values() {
|
||||
let data = vec![10.0_f32, 20.0, 30.0];
|
||||
let bounds = Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
};
|
||||
let stats = compute_feature_stats(&data, &bounds, false);
|
||||
|
||||
assert_eq!(stats.histogram.min, 10.0);
|
||||
assert_eq!(stats.histogram.max, 30.0);
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_outlier_does_not_destroy_quantization() {
|
||||
// Simulate floor area: 10k normal values (50-200 sqm) + one 317M outlier
|
||||
let mut data: Vec<f32> = (0..10_000).map(|i| 50.0 + (i % 150) as f32).collect();
|
||||
data.push(317_000_000.0); // Extreme outlier from web scraping
|
||||
|
||||
let bounds = make_percentile_bounds(0.0, 98.0);
|
||||
let stats = compute_feature_stats(&data, &bounds, false);
|
||||
|
||||
// After refinement, histogram range should be much tighter than 317M
|
||||
assert!(
|
||||
stats.histogram.max < 1_000_000.0,
|
||||
"histogram.max should be refined, got {}",
|
||||
stats.histogram.max,
|
||||
);
|
||||
// p1 should be near 50, not millions
|
||||
assert!(
|
||||
stats.histogram.p1 < 100.0,
|
||||
"p1 should be near real data, got {}",
|
||||
stats.histogram.p1,
|
||||
);
|
||||
// Slider min should reflect actual data range
|
||||
assert!(
|
||||
stats.slider_min < 100.0,
|
||||
"slider_min should be near real data, got {}",
|
||||
stats.slider_min,
|
||||
);
|
||||
|
||||
// Quantization using histogram.min/max should give usable range
|
||||
let qmin = stats.histogram.min;
|
||||
let qrange = stats.histogram.max - stats.histogram.min;
|
||||
assert!(qrange > 0.0 && qrange < 1_000_000.0);
|
||||
|
||||
// A typical floor area (100 sqm) should be distinguishable from min
|
||||
let normalized = (100.0 - qmin) / qrange;
|
||||
let encoded = (normalized * QUANT_SCALE).round() as u16;
|
||||
assert!(
|
||||
encoded > 100,
|
||||
"100 sqm should encode to a meaningful u16 value, got {}",
|
||||
encoded,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue