This commit is contained in:
Andras Schmelczer 2026-06-28 11:59:44 +01:00
parent f1601257c7
commit c2070693fb
68 changed files with 2305 additions and 212 deletions

View file

@ -33,13 +33,13 @@ pub const AI_FILTERS_WEEKLY_TOKEN_LIMIT: u64 = 10_000_000;
pub const SERVICE_CALL_TIMEOUT: u64 = 120;
/// Anonymous (logged-out) users may apply at most this many filters at a time. This
/// is the only gate on the demo: there is no region lock they get the full
/// is the only gate on the demo: there is no region lock: they get the full
/// dashboard everywhere, just capped on simultaneous filters. Enforced both
/// client-side (UX) and server-side (defense-in-depth); must match the frontend
/// constant `DEMO_MAX_FILTERS`.
pub const DEMO_MAX_FILTERS: usize = 3;
/// Registered but non-paying accounts get a higher filter allowance than anonymous
/// visitors the incentive to create a free account. Paying/licensed users are
/// visitors, the incentive to create a free account. Paying/licensed users are
/// uncapped. Must match the frontend constant `REGISTERED_MAX_FILTERS`.
pub const REGISTERED_MAX_FILTERS: usize = 5;
/// Sliding-window rate limit for unlicensed traffic to `/api/` endpoints, keyed by

View file

@ -70,7 +70,7 @@ pub struct FeatureGroup {
/// Expand each crime type into its two filterable features: a 7-year and a
/// 2-year window. Each is the average number of recorded incidents per year (the
/// raw, absolute count no per-area or per-capita normalisation). The names must
/// raw, absolute count: no per-area or per-capita normalisation). The names must
/// match the `"{type} (/yr, 7y|2y)"` columns written by `crime_spatial`. The
/// per-incident records are NOT a feature (they are a display-only side table the
/// server loads directly), so they never appear here and are not filterable.
@ -81,12 +81,12 @@ macro_rules! crime_features {
name: concat!($base, " (/yr, 7y)"),
bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
step: 0.1,
description: concat!($blurb, " average recorded incidents per year (last 7 years)"),
description: concat!($blurb, ": average recorded incidents per year (last 7 years)"),
detail: concat!(
$blurb,
", as the average number of recorded incidents per year, over the last \
7 years. Counted from police.uk street-level crime points (anonymised, \
snapped to nearby map points) that fall near the postcode boundary \
snapped to nearby map points) that fall near the postcode boundary: \
the raw, absolute count, with no per-area or per-capita adjustment. \
Computed over the months the local police force actually published; \
known force gaps (e.g. Greater Manchester since mid-2019) are excluded, \
@ -102,11 +102,11 @@ macro_rules! crime_features {
name: concat!($base, " (/yr, 2y)"),
bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
step: 0.1,
description: concat!($blurb, " average recorded incidents per year (last 2 years)"),
description: concat!($blurb, ": average recorded incidents per year (last 2 years)"),
detail: concat!(
$blurb,
", as the average number of recorded incidents per year, over the last \
2 years a more recent but noisier window than the 7-year figure. From \
2 years: a more recent but noisier window than the 7-year figure. From \
police.uk street-level crime points near the postcode boundary (the raw, \
absolute count), over the months the local force published (gaps \
excluded, not zeroed)."
@ -248,7 +248,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
step: 1.0,
description: "Maximum transport noise level near the postcode in decibels (Lden)",
detail: "Loudest of road, rail, or airport noise in decibels (Lden, a 24-hour day-evening-night weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Covers England only; rail noise dominates the value at ~120k postcodes and airport noise at ~4k. Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Blank means no mapped data in the source (Wales, Scotland and areas away from major roads/railways/airports all return blank) — not necessarily quiet. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.",
detail: "Loudest of road, rail, or airport noise in decibels (Lden, a 24-hour day-evening-night weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Covers England only; rail noise dominates the value at ~120k postcodes and airport noise at ~4k. Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Blank means no mapped data in the source (Wales, Scotland and areas away from major roads/railways/airports all return blank). It does not necessarily mean the area is quiet. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.",
source: "noise",
prefix: "",
suffix: " dB",
@ -528,11 +528,11 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
features: crime_features![
(
"Serious crime",
"Serious crime — violence, robbery, burglary and weapons possession — near the postcode"
"Serious crime (violence, robbery, burglary and weapons possession) near the postcode"
),
(
"Minor crime",
"Lower-severity crime — anti-social behaviour, theft, criminal damage, drugs and public order — near the postcode"
"Lower-severity crime (anti-social behaviour, theft, criminal damage, drugs and public order) near the postcode"
),
(
"Violence and sexual offences",
@ -638,7 +638,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of residents (16+) whose highest qualification is A-levels (Level 3)",
detail: "From the 2021 Census (TS067). Highest qualification is A-levels, AS-levels, T-levels, an advanced apprenticeship, or equivalent typically studied after 16 and before a degree. The ONS calls this 'Level 3'.",
detail: "From the 2021 Census (TS067). Highest qualification is A-levels, AS-levels, T-levels, an advanced apprenticeship, or equivalent, typically studied after 16 and before a degree. The ONS calls this 'Level 3'.",
source: "census-2021",
prefix: "",
suffix: "%",
@ -650,7 +650,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of residents (16+) with a degree-level or higher qualification",
detail: "From the 2021 Census (TS067). Highest qualification is degree level or above a Bachelor's, Master's or PhD, foundation degree, HNC/HND, NVQ 4-5, or higher professional qualification. The census does not separate undergraduate from postgraduate degrees. The ONS calls this 'Level 4 or above'.",
detail: "From the 2021 Census (TS067). Highest qualification is degree level or above: a Bachelor's, Master's or PhD, foundation degree, HNC/HND, NVQ 4-5, or higher professional qualification. The census does not separate undergraduate from postgraduate degrees. The ONS calls this 'Level 4 or above'.",
source: "census-2021",
prefix: "",
suffix: "%",
@ -662,7 +662,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of residents (16+) with other qualifications, including vocational or overseas ones",
detail: "From the 2021 Census (TS067). Highest qualification is classed as 'other' vocational or professional qualifications not mapped to a UK level, and qualifications gained outside the UK.",
detail: "From the 2021 Census (TS067). Highest qualification is classed as 'other': vocational or professional qualifications not mapped to a UK level, and qualifications gained outside the UK.",
source: "census-2021",
prefix: "",
suffix: "%",
@ -673,8 +673,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
// shares sum to ~100% per neighbourhood (LSOA) and render as a
// stacked composition (see STACKED_GROUPS["Neighbours"] in the
// frontend), like the ethnicity, qualifications and vote-share bars.
// Unlike those each folded into a single dropdown filter that
// selects one band the three tenure shares are offered as
// Unlike those (each folded into a single dropdown filter that
// selects one band), the three tenure shares are offered as
// individual filters, so users can target e.g. owner-occupier-heavy
// areas.
Feature::Numeric(FeatureConfig {
@ -713,6 +713,37 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
// EPC-derived council-housing footprint for the neighbourhood (LSOA).
// Aggregated in the pipeline from the per-property "Former council
// house" flag and the latest certificate tenure. The denominator is
// ALL dwellings in the LSOA (homes with no EPC count as not council),
// so these are EPC-coverage-limited lower bounds, NOT a Census
// household share. These two plus the Census "% Social rent" back the
// frontend "Council housing" filter's Current / Ex / Both pill toggle.
Feature::Numeric(FeatureConfig {
name: "% Council housing",
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of nearby homes ever recorded as council or social housing",
detail: "Estimated from EPC tenure records across the neighbourhood (LSOA): the share of homes whose Energy Performance Certificate history shows the property was council or social housing at some point, whether it is still social housing today or has since been sold (for example under Right to Buy). Homes with no EPC are counted as not council, so this is a lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.",
source: "epc",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "% Ex-council",
bounds: Bounds::Fixed { min: 0.0, max: 100.0 },
step: 1.0,
description: "Share of nearby homes that were council housing but are no longer",
detail: "Estimated from EPC tenure records across the neighbourhood (LSOA): the share of homes once recorded as council or social housing whose most recent Energy Performance Certificate shows a different tenure, typically homes sold under Right to Buy. Homes with no EPC are counted as not council, so this is a lower bound that reflects EPC coverage and is not directly comparable to the Census social-rent share.",
source: "epc",
prefix: "",
suffix: "%",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "% White",
bounds: Bounds::Fixed {

View file

@ -23,7 +23,7 @@ pub fn build_system_prompt(
- Leave out any filter the user did not mention. Empty arrays are fine.\n\
- Each numeric filter sets ONE bound only: \"min\" (at least this value) \
or \"max\" (at most this value). Never set two filters on the same feature.\n\
- Use EXACT feature names from the list spelling, capitalisation, and punctuation must match.\n\
- Use EXACT feature names from the list: spelling, capitalisation, and punctuation must match.\n\
- \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\
- \"low crime\" / \"safe\" = low values on the Serious crime (/yr, 7y) and Minor crime (/yr, 7y) \
features (average recorded incidents per year near the postcode, last 7 years). Prefer these aggregates for broad \
@ -39,7 +39,9 @@ pub fn build_system_prompt(
% Other parties, or Voter turnout (%) when the user asks for political character.\n\
- Housing tenure is a normal filter in the Neighbours group. \"lots of homeowners\" / \
\"owner-occupied area\" = high % Owner occupied; \"rental area\" / \"lots of renters\" = \
high % Private rent; \"social housing\" / \"council housing\" = high % Social rent.\n\
high % Private rent; \"social housing\" / \"council housing\" = high % Social rent. \
\"ex-council\" / \"former council\" / \"right to buy\" = high % Ex-council; \"council \
estate\" character whether current or former = high % Council housing.\n\
- Education level is a normal filter in the Neighbours group. \"well-educated\" / \
\"graduate\" / \"professional\" / \"university-educated area\" = high % Degree or higher; \
\"few qualifications\" = high % No qualifications.\n\
@ -88,7 +90,7 @@ pub fn build_system_prompt(
\n\
When the user mentions a specific place, you MUST call the search_destinations \
tool to find the exact slug. Use the name and slug from the search results.\n\
If search_destinations returns an empty array, the destination is not available \
If search_destinations returns an empty array, the destination is not available; \
mention it in \"notes\" (e.g. \"No travel data for: Gatwick Airport\") and do NOT \
include a travel_time_filter for it.\n\
\n\

View file

@ -69,7 +69,7 @@ pub(super) async fn fetch_ai_usage(
}
/// Update the user's AI token usage in PocketBase.
/// Best-effort logs warnings on failure but does not propagate errors.
/// Best-effort: logs warnings on failure but does not propagate errors.
async fn update_ai_usage(state: &AppState, user_id: &str, tokens_used: u64, week: u64) {
let token = match get_superuser_token(state).await {
Ok(tk) => tk,

View file

@ -142,7 +142,7 @@ pub fn compute_feature_stats(
if value.is_finite() {
// Reject negatives, NaN-via-large-cast, and any out-of-range
// index. A schema/data mismatch is a critical data-integrity
// bug skip the row, count it, and surface as error so
// bug: skip the row, count it, and surface as error so
// monitoring catches it.
let len = value_counts.len();
let idx_ok = value >= 0.0 && (value as usize) < len;
@ -154,7 +154,7 @@ pub fn compute_feature_stats(
feature = feature_names[fi].as_str(),
value,
max = len,
"Enum index out of bounds data/schema mismatch"
"Enum index out of bounds: data/schema mismatch"
);
}
}
@ -296,7 +296,7 @@ pub fn compute_enum_feature_counts(
/// Denominators are COVERAGE-AWARE: police.uk has multi-year publication gaps
/// for whole forces (e.g. Greater Manchester from 2019-07), and the pipeline
/// emits a `covered_years` calendar per postcode. A postcode only counts toward
/// a year's denominator if its force published that year and only then does
/// a year's denominator if its force published that year, and only then does
/// its missing bar mean a genuine zero. Years no selected postcode covers are
/// omitted entirely (charted as gaps, not zeros). Postcodes without coverage
/// info (legacy parquet without the column) count toward every year, restoring
@ -337,7 +337,7 @@ pub fn compute_crime_by_year(
// A postcode with a row but no series for a given type had no recorded
// incidents of that type: it contributes 0 to the sums, and its covered
// years still count in the denominator a genuine zero. Uncovered
// years still count in the denominator: a genuine zero. Uncovered
// years are excluded via the denominators instead.
if let Some(series_list) = crime_by_year.series_by_postcode.get(postcode) {
for series in series_list {
@ -575,7 +575,7 @@ mod tests {
assert_eq!(all.get("Yes"), Some(&2));
assert_eq!(all.get("No"), Some(&1));
// A filter-matching subset would yield a different tally confirming
// A filter-matching subset would yield a different tally, confirming
// the count is driven purely by the rows passed in (so callers can pass
// the full area to make it filter-independent).
let subset = compute_enum_feature_counts(&[0, 3], &data, 0).unwrap();
@ -621,7 +621,7 @@ mod tests {
let robbery = out.iter().find(|c| c.name == "Robbery (/yr, 7y)").unwrap();
assert_eq!(robbery.national, Some(6.0));
// The outcode value was NaN dropped to None; the sector value is finite.
// The outcode value was NaN, dropped to None; the sector value is finite.
assert_eq!(robbery.outcode, None);
assert_eq!(robbery.sector, Some(7.0));
}

View file

@ -69,7 +69,7 @@ pub async fn get_travel_destinations(
// Sort: type rank asc, population desc, name length asc
matches.sort_unstable_by(|a, b| a.2.cmp(&b.2).then(b.3.cmp(&a.3)).then(a.4.cmp(&b.4)));
// Deduplicate by slug multiple places can share a name/slug
// Deduplicate by slug: multiple places can share a name/slug
// (e.g. "Richmond" as city + suburb), keep the best-ranked one
let mut seen_slugs = FxHashSet::default();
matches.retain(|(_, slug, ..)| seen_slugs.insert(slug.clone()));

View file

@ -139,7 +139,7 @@ impl GridIndex {
}
/// Count the number of row indices within the given bounds without allocating.
/// O(grid cells in bounds) much cheaper than query() for threshold decisions.
/// O(grid cells in bounds): much cheaper than query() for threshold decisions.
pub fn count_in_bounds(&self, south: f64, west: f64, north: f64, east: f64) -> usize {
let Some((row_min, row_max, col_min, col_max)) =
self.clamp_bounds(south, west, north, east)