# City+ metric registry.
#
# Hand-authored, load-bearing metadata for the City+ peer-benchmarking pages.
# The improver-ranking engine reads its guardrails from here: polarity,
# discontinuities, uncertainty model, and the named tunables. Nothing in the
# engine hardcodes a metric or a threshold; tuning is a data change here, not
# a code change.
#
# Coverage claims (grains, year spans, statvar existence) are cross-checked
# against coverage_manifest.json and the local store by
# custom_dc/tools/build_city_plus.py --lint. Polarity, discontinuities, and
# uncertainty cannot be derived and are asserted here on purpose.

version: 1

# Tract drill-down ("Where is this changing?") coverage is data-driven: the
# fold renders wherever a built shard exists (tracts/<dcid>.json). Build with
# --tracts (all roster cities) or --tracts --city geoId/N (one city).

# Engine thresholds. Any metric may override any of these under `tunables:`.
tunables:
  window_years: 6      # trend window in annual points; 6 points = a 5-year span
  min_points: 4        # minimum usable points inside the window segment
  noise_k: 1.5         # fitted change must exceed noise_k x the proxy noise floor
  moe_k: 1.0           # with published MOEs: change must exceed moe_k x the
                       # 90% margin of error of the endpoint difference
  sustain_frac: 0.7    # share of year-over-year deltas agreeing with slope sign
  effect_iqr_frac: 0.25  # change must reach this fraction of the peer-frame IQR
  effect_rel_floor: 0.05 # or this fraction of the window-start value
  tract_small_denom: 400 # tract rows with an end-year denominator below this
                         # carry a "small tract" noise flag
  ladder_gap: 5          # survey pace: end labels this many years apart
                         # share zero collection years (5-year pools)
  ladder_overlap_max: 1  # one shared survey year allowed when history is a
                         # release short - flagged with the asterisk seam
  recent_k: 3            # early-signal: this many consecutive same-direction
                         # vintage-over-vintage moves (quasi-independent
                         # year-pair comparisons)...
  recent_moe_frac: 1.0   # ...whose cumulative size reaches this fraction of
                         # the newest vintage's published margin
  cv_flag: 0.30          # endpoint CV above this = low-reliability pace
  turn_k: 2.0            # robust-fit turn detection: prominence must clear
                         # turn_k x the piecewise residual floor...
  turn_frac: 0.35        # ...and turn_frac x the window's value range (the
                         # eye test); a detected turn replaces the slope
                         # sentence with the visible shape
  star_gold_pct: 90      # GOLD star (National leader): at or above this
                         # polarity-aligned percentile of the city's
                         # national size tier (current value; crime
                         # metrics use 12-month change instead)
  star_silver_pct: 80    # SILVER star (Strong performer): at or above
                         # this percentile, below gold
  star_min_cohort: 30    # no star from a cohort smaller than this
                         # (Census reliability convention)

metrics:
  - id: median_household_income
    name: Median household income
    short: Household income
    domain: Economy
    statvar: Median_Income_Household
    source: base
    source_id: census_acs_5yr
    source_name: Census ACS 5-year survey
    source_kind: survey
    facet_hint: ACS5Year
    polarity: higher_better
    unit: usd
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    moe_statvar: dw/MarginOfError_CityPlus_MedianHouseholdIncome
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    denominator: null
    uncertainty:
      model: published_moe
      note: >-
        Published ACS margins for this indicator are computed from the
        source-table cell margins and stored as a local twin variable
        (moe_statvar); trend claims are gated by them.
    caveats:
      - >-
        ACS 5-year estimates pool overlapping survey windows, which smooths and
        lags real change; recent shifts show up slowly.
      - >-
        Values are nominal dollars, not inflation adjusted; incomes rise most
        years in most places, so weigh the peer-relative pace of change, not
        the raw climb.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: unemployment_rate
    name: Unemployment rate
    short: Unemployment
    domain: Economy
    statvar: UnemploymentRate_Person
    source: base
    source_id: bls_laus
    source_name: BLS Local Area Unemployment Statistics
    source_kind: administrative
    facet_hint: BLS
    period_hint: P1Y
    latest_monthly: true
    polarity: lower_better
    unit: percent
    cadence: annual
    series_smoothing: none
    pace_class: admin_annual
    annualize: null
    grains: [City]
    denominator: null
    uncertainty:
      model: noise_proxy
      note: >-
        LAUS is a model-assisted administrative series without published local
        margins of error; trend gates use a noise proxy.
    caveats:
      - >-
        Trends are fitted only to years after the 2020-2021 pandemic shock;
        a decline measured from the 2020 spike is recovery, not improvement,
        and is never presented as one.
    discontinuities:
      - {date: "2021", kind: pandemic_shock, note: "2020 spike and 2021
          rebound; trend windows start at 2022 so recovery from the shock is
          never scored as improvement"}
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: poverty_rate
    name: Share of people below the poverty level
    short: Poverty
    domain: Economy
    statvar: Count_Person_BelowPovertyLevelInThePast12Months
    source: base
    source_id: census_acs_5yr
    source_name: Census ACS 5-year survey
    source_kind: survey
    facet_hint: ACS5Year
    polarity: lower_better
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    moe_statvar: dw/MarginOfError_CityPlus_PovertyRate
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    denominator: Count_Person
    uncertainty:
      model: published_moe
      note: >-
        Published ACS margins for this indicator are computed from the
        source-table cell margins and stored as a local twin variable
        (moe_statvar); trend claims are gated by them.
    caveats:
      - ACS 5-year pooling smooths and lags real change.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  # Child poverty follows the CityHealth Dashboard construction exactly:
  # numerator = the three under-18 below-poverty cells summed, denominator =
  # those plus their at-or-above complements (B17001 publishes no direct
  # under-18 universe). Every statvar was API-verified at city AND tract
  # level (gen_breakdowns probing, 2026-07-27); the same cells power the
  # poverty card's age breakdown, so the two can never disagree.
  - id: children_in_poverty
    name: Share of children below the poverty level
    short: Child poverty
    domain: Economy
    statvar: Count_Person_Upto6Years_BelowPovertyLevelInThePast12Months
    add_statvars:
      - Count_Person_6To11Years_BelowPovertyLevelInThePast12Months
      - Count_Person_12To17Years_BelowPovertyLevelInThePast12Months
    source: base
    source_id: census_acs_5yr
    source_name: Census ACS 5-year survey
    source_kind: survey
    facet_hint: ACS5Year
    polarity: lower_better
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    moe_statvar: dw/MarginOfError_CityPlus_ChildPovertyRate
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    denominator: Count_Person_Upto6Years_BelowPovertyLevelInThePast12Months
    denominator_add:
      - Count_Person_6To11Years_BelowPovertyLevelInThePast12Months
      - Count_Person_12To17Years_BelowPovertyLevelInThePast12Months
      - Count_Person_Upto6Years_AbovePovertyLevelInThePast12Months
      - Count_Person_6To11Years_AbovePovertyLevelInThePast12Months
      - Count_Person_12To17Years_AbovePovertyLevelInThePast12Months
    uncertainty:
      model: published_moe
      note: >-
        Published ACS margins for this indicator are computed from the
        source-table cell margins and stored as a local twin variable
        (moe_statvar); trend claims are gated by them.
    caveats:
      - ACS 5-year pooling smooths and lags real change.
      - >-
        Children for whom poverty status is determined; kids in group
        quarters are outside the universe.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  # S2801 subscription data starts with the 2017 ACS vintage, which fully
  # covers the 5-year trend window; the 10-year "longer view" line simply
  # does not render (window_points finds no earlier points). City and tract
  # coverage API-verified through 2024 (2026-07-27).
  - id: broadband
    name: Households with a broadband internet subscription
    short: Broadband
    domain: Economy
    statvar: Count_Household_WithInternetSubscription
    source: base
    source_id: census_acs_5yr
    source_name: Census ACS 5-year survey
    source_kind: survey
    facet_hint: ACS5Year
    polarity: higher_better
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    moe_statvar: dw/MarginOfError_CityPlus_BroadbandRate
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    denominator: Count_Household
    uncertainty:
      model: published_moe
      note: >-
        Published ACS margins for this indicator are computed from the
        source-table cell margins and stored as a local twin variable
        (moe_statvar); trend claims are gated by them.
    caveats:
      - ACS 5-year pooling smooths and lags real change.
      - >-
        Counts any broadband subscription (cable, fiber, DSL, satellite,
        cellular-only); it says nothing about speed or reliability.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: bachelors_or_higher
    name: Adults with a bachelor's degree or higher
    short: College attainment
    domain: Education
    statvar: Count_Person_EducationalAttainmentBachelorsDegree
    # "Or higher" = bachelor's + graduate/professional, summed year-aligned.
    add_statvars: [Count_Person_EducationalAttainmentGraduateOrProfessionalDegree]
    source: base
    source_id: census_acs_5yr
    source_name: Census ACS 5-year survey
    source_kind: survey
    facet_hint: s0701
    denominator: Count_Person_25OrMoreYears
    denominator_facet_hint: s0701
    polarity: higher_better
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    moe_statvar: dw/MarginOfError_CityPlus_BachelorsOrHigherRate
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    uncertainty:
      model: published_moe
      note: >-
        Published ACS margins for this indicator are computed from the
        source-table cell margins and stored as a local twin variable
        (moe_statvar); trend claims are gated by them.
    caveats:
      - >-
        Share of residents 25 and older. Rising attainment can reflect
        educating residents or attracting graduates; the number does not say
        which.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: preschool_enrollment
    name: Children ages 3-4 enrolled in preschool
    short: Preschool enrollment
    domain: Education
    # Universe published only in pieces: numerator = the four enrolled cells
    # (public/private x M/F), denominator = those plus the two not-enrolled.
    statvar: Count_Person_3To4Years_Female_EnrolledInPublicSchool
    add_statvars:
      - Count_Person_3To4Years_Male_EnrolledInPublicSchool
      - Count_Person_3To4Years_Female_EnrolledInPrivateSchool
      - Count_Person_3To4Years_Male_EnrolledInPrivateSchool
    denominator: Count_Person_3To4Years_Female_EnrolledInPublicSchool
    denominator_add:
      - Count_Person_3To4Years_Male_EnrolledInPublicSchool
      - Count_Person_3To4Years_Female_EnrolledInPrivateSchool
      - Count_Person_3To4Years_Male_EnrolledInPrivateSchool
      - Count_Person_3To4Years_Female_NotEnrolledInSchool
      - Count_Person_3To4Years_Male_NotEnrolledInSchool
    source: base
    source_id: census_acs_5yr
    source_name: Census ACS 5-year survey
    source_kind: survey
    facet_hint: acs5year
    denominator_facet_hint: acs5year
    polarity: higher_better
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    moe_statvar: dw/MarginOfError_CityPlus_PreschoolEnrollmentRate
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    tunables: {tract_small_denom: 100}
    uncertainty:
      model: published_moe
      note: >-
        Published ACS margins for this indicator are computed from the
        source-table cell margins and stored as a local twin variable
        (moe_statvar); trend claims are gated by them.
    caveats:
      - >-
        Counts public and private preschool together; it measures enrollment,
        not program quality or affordability.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: uninsured_rate
    name: People without health insurance
    short: Uninsured
    domain: Health
    statvar: Count_Person_NoHealthInsurance
    source: base
    source_id: census_acs_5yr
    source_name: Census ACS 5-year survey
    source_kind: survey
    facet_hint: s2701
    denominator: Count_Person
    denominator_facet_hint: acs5year
    polarity: lower_better
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    moe_statvar: dw/MarginOfError_CityPlus_UninsuredRate
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    uncertainty:
      model: published_moe
      note: >-
        Published ACS margins for this indicator are computed from the
        source-table cell margins and stored as a local twin variable
        (moe_statvar); trend claims are gated by them.
    caveats:
      - >-
        Expressed as a share of all residents. Coverage is shaped by state
        policy (Medicaid expansion) as well as local action; a referral
        candidate's gain may be a state story.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: violent_crime_rate
    name: Violent crime rate
    short: Violent crime
    domain: Public Safety
    statvar: dw/Rate_CriminalActivities_ViolentCrime_RTCI_Per100KTrailing12Mo
    source: local
    source_id: rtci
    # National reference: RTCI's own full-sample aggregate (the whole
    # reporting sample), already in the local store. RTCI publishes no
    # state series, so these metrics carry no state reference row.
    # Rates derive from incident COUNTS over population, so a 12-month
    # change can be tested against chance fluctuation (Poisson).
    sig_test: poisson_counts
    national_ref_entity: dw/rtci/agg/FullSample
    national_ref_label: U.S. (RTCI sample)
    source_name: Real-Time Crime Index
    source_kind: administrative
    facet_hint: null
    polarity: lower_better
    # FBI guidance: crime rankings are not effectiveness measures, so
    # these metrics carry no level-vs-peers presentation (no today's-rank
    # or latest-vs-peers columns, no level-standing chip); peer comparison
    # is change-based only. Values themselves stay visible.
    peer_levels: hide
    unit: per_100k
    cadence: monthly
    series_smoothing: none
    pace_class: admin_annual
    # Trailing-12-month rolling rate: every monthly point is a full annual
    # rate, so the series anchors to the freshest month with broad coverage
    # (same month across all cities) and takes that month in every year.
    # Never sum monthly values.
    annualize: latest_month_anchor
    grains: [City]
    denominator: null
    uncertainty:
      model: noise_proxy
      note: >-
        Agency-reported counts with no published uncertainty; trend gates use a
        noise proxy. Reporting practice changes can move the series.
    caveats:
      - >-
        Counts are preliminary, unofficial, and subject to agency
        revision; reporting methodologies vary by agency, so cross-city
        comparisons are indicative rather than definitive, and the FBI
        cautions against reading crime rankings as measures of
        law-enforcement effectiveness.
      - >-
        Covers roughly 600 cities whose agencies report to the Real-Time Crime
        Index; coverage is broad but not universal.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: property_crime_rate
    name: Property crime rate
    short: Property crime
    domain: Public Safety
    statvar: dw/Rate_CriminalActivities_PropertyCrime_RTCI_Per100KTrailing12Mo
    source: local
    source_id: rtci
    # National reference: RTCI's own full-sample aggregate (the whole
    # reporting sample), already in the local store. RTCI publishes no
    # state series, so these metrics carry no state reference row.
    # Rates derive from incident COUNTS over population, so a 12-month
    # change can be tested against chance fluctuation (Poisson).
    sig_test: poisson_counts
    national_ref_entity: dw/rtci/agg/FullSample
    national_ref_label: U.S. (RTCI sample)
    source_name: Real-Time Crime Index
    source_kind: administrative
    facet_hint: null
    polarity: lower_better
    # FBI guidance: crime rankings are not effectiveness measures, so
    # these metrics carry no level-vs-peers presentation (no today's-rank
    # or latest-vs-peers columns, no level-standing chip); peer comparison
    # is change-based only. Values themselves stay visible.
    peer_levels: hide
    unit: per_100k
    cadence: monthly
    series_smoothing: none
    pace_class: admin_annual
    annualize: latest_month_anchor
    grains: [City]
    denominator: null
    uncertainty:
      model: noise_proxy
      note: >-
        Agency-reported counts with no published uncertainty; trend gates use a
        noise proxy. Reporting practice changes can move the series.
    caveats:
      - >-
        Counts are preliminary, unofficial, and subject to agency
        revision; reporting methodologies vary by agency, so cross-city
        comparisons are indicative rather than definitive, and the FBI
        cautions against reading crime rankings as measures of
        law-enforcement effectiveness.
      - Covers roughly 600 reporting cities; broad but not universal.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: homicide_rate
    name: Homicide rate
    short: Homicide
    domain: Public Safety
    statvar: dw/Rate_CriminalActivities_Murder_RTCI_Per100KTrailing12Mo
    # Topline in COUNTS (Brek, 2026-08-17): mayors and councilors speak in
    # homicides, not homicides per 100k. The card face, sparkline, and
    # recent-reading line use this trailing-12 count series; every
    # comparison surface (peer table, change ranks, multi-year checks)
    # stays on the per-100k rate, the only cross-city-comparable form.
    topline:
      statvar: dw/Count_CriminalActivities_Murder_RTCI_Trailing12Mo
      noun: homicides
    source: local
    source_id: rtci
    # National reference: RTCI's own full-sample aggregate (the whole
    # reporting sample), already in the local store. RTCI publishes no
    # state series, so these metrics carry no state reference row.
    # Rates derive from incident COUNTS over population, so a 12-month
    # change can be tested against chance fluctuation (Poisson).
    sig_test: poisson_counts
    national_ref_entity: dw/rtci/agg/FullSample
    national_ref_label: U.S. (RTCI sample)
    source_name: Real-Time Crime Index
    source_kind: administrative
    facet_hint: null
    polarity: lower_better
    # FBI guidance: crime rankings are not effectiveness measures, so
    # these metrics carry no level-vs-peers presentation (no today's-rank
    # or latest-vs-peers columns, no level-standing chip); peer comparison
    # is change-based only. Values themselves stay visible.
    peer_levels: hide
    unit: per_100k
    cadence: monthly
    series_smoothing: none
    pace_class: admin_annual
    annualize: latest_month_anchor
    grains: [City]
    denominator: null
    uncertainty:
      model: noise_proxy
      note: >-
        Homicide counts are small even in large cities, so year-to-year swings
        are often noise; the trend checks are doing real work here, and many
        genuine declines will sit below the noise bar for a while.
    caveats:
      - >-
        Counts are preliminary, unofficial, and subject to agency
        revision; reporting methodologies vary by agency, so cross-city
        comparisons are indicative rather than definitive, and the FBI
        cautions against reading crime rankings as measures of
        law-enforcement effectiveness.
      - Covers roughly 600 reporting cities; broad but not universal.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: mvt_rate
    name: Motor vehicle theft rate
    short: Vehicle theft
    domain: Public Safety
    statvar: dw/Rate_CriminalActivities_MotorVehicleTheft_RTCI_Per100KTrailing12Mo
    source: local
    source_id: rtci
    # National reference: RTCI's own full-sample aggregate (the whole
    # reporting sample), already in the local store. RTCI publishes no
    # state series, so these metrics carry no state reference row.
    # Rates derive from incident COUNTS over population, so a 12-month
    # change can be tested against chance fluctuation (Poisson).
    sig_test: poisson_counts
    national_ref_entity: dw/rtci/agg/FullSample
    national_ref_label: U.S. (RTCI sample)
    source_name: Real-Time Crime Index
    source_kind: administrative
    facet_hint: null
    polarity: lower_better
    # FBI guidance: crime rankings are not effectiveness measures, so
    # these metrics carry no level-vs-peers presentation (no today's-rank
    # or latest-vs-peers columns, no level-standing chip); peer comparison
    # is change-based only. Values themselves stay visible.
    peer_levels: hide
    unit: per_100k
    cadence: monthly
    series_smoothing: none
    pace_class: admin_annual
    annualize: latest_month_anchor
    grains: [City]
    denominator: null
    uncertainty:
      model: noise_proxy
      note: >-
        Agency-reported counts with no published uncertainty; trend gates use
        a noise proxy.
    caveats:
      - >-
        Counts are preliminary, unofficial, and subject to agency
        revision; reporting methodologies vary by agency, so cross-city
        comparisons are indicative rather than definitive, and the FBI
        cautions against reading crime rankings as measures of
        law-enforcement effectiveness.
      - >-
        The 2022-2023 national spike (keyless-theft methods spreading on
        social media) and its reversal are real events shared across many
        cities; the peer-relative pace of change matters more than the raw swing.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: home_value
    name: Typical home value
    short: Home value
    domain: Housing
    statvar: dw/ZillowHomeValueIndex_AllHomes
    source: local
    source_id: zillow_housing
    source_name: Zillow Home Value Index
    source_kind: modeled
    facet_hint: null
    # Rising home values cut both ways (wealth for owners, cost for buyers),
    # so this metric never enters improver rankings.
    polarity: neutral
    no_referral_note: >-
      Rising values build wealth for owners but raise the wall for buyers, and
      falling values often signal distress rather than success. Neither
      direction is a playbook another city should copy, so no referral list is
      made here.
    unit: usd
    cadence: monthly
    series_smoothing: none
    pace_class: admin_annual
    annualize: latest_month_anchor
    grains: [City]
    denominator: null
    uncertainty:
      model: modeled
      note: >-
        Zillow's smoothed, seasonally adjusted model of typical home value
        (35th to 65th percentile); an estimate, not a transaction record.
    caveats: []
    discontinuities: []
    comparison: {improver_eligible: false, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: starter_home_value
    name: Starter home value (bottom tier)
    short: Starter homes
    domain: Housing
    statvar: dw/ZillowHomeValueIndex_BottomTier
    source: local
    source_id: zillow_housing
    source_name: Zillow Home Value Index
    source_kind: modeled
    facet_hint: null
    polarity: neutral
    no_referral_note: >-
      Like all home values, entry-level prices cut both ways: rising values
      reward current owners and lock out first-time buyers. Shown for the
      story it tells about the bottom rung of the ladder, with no referral
      list.
    unit: usd
    cadence: monthly
    series_smoothing: none
    pace_class: admin_annual
    annualize: latest_month_anchor
    grains: [City]
    denominator: null
    uncertainty:
      model: modeled
      note: >-
        Zillow's smoothed model of typical value in the bottom third (5th to
        35th percentile) of a market; an estimate, not a transaction record.
    caveats: []
    discontinuities: []
    comparison: {improver_eligible: false, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: homeownership_rate
    name: Homeownership rate
    short: Homeownership
    domain: Housing
    statvar: Count_HousingUnit_OwnerOccupied
    source: base
    source_id: census_acs_5yr
    source_name: Census ACS 5-year survey
    source_kind: survey
    facet_hint: acs5year
    denominator: Count_HousingUnit_OccupiedHousingUnit
    denominator_facet_hint: acs5year
    polarity: higher_better
    no_referral_note: >-
      Higher ownership is generally read as wealth-building, but a fast rise
      can also mean renters being displaced or converted units, not new owners
      arriving. Level and velocity are shown; no referral list is made from
      this number alone.
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    moe_statvar: dw/MarginOfError_CityPlus_HomeownershipRate
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    uncertainty:
      model: published_moe
      note: >-
        Published ACS margins for this indicator are computed from the
        source-table cell margins and stored as a local twin variable
        (moe_statvar); trend claims are gated by them.
    caveats: []
    discontinuities: []
    comparison: {improver_eligible: false, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: rent
    name: Typical asking rent
    short: Rent
    domain: Housing
    statvar: dw/ZillowObservedRentIndex_AllHomes
    source: local
    source_id: zillow_housing
    source_name: Zillow Observed Rent Index
    source_kind: modeled
    facet_hint: null
    polarity: neutral
    no_referral_note: >-
      Fast-rising rent is real pain for renters. But a list of "cities where
      rent fell" would mostly surface places where demand collapsed, shrinking
      cities are not a playbook, so raw rent gets no referral list. The
      affordability question is answered by "Housing cost-burdened
      households", which does carry referrals.
    unit: usd
    cadence: monthly
    series_smoothing: none
    pace_class: admin_annual
    annualize: latest_month_anchor
    grains: [City]
    denominator: null
    uncertainty:
      model: modeled
      note: >-
        Zillow's repeat-listing model of market-rate asking rent; an estimate,
        not a lease record.
    caveats:
      - Covers fewer cities than the home value index (larger rental markets).
    discontinuities: []
    comparison: {improver_eligible: false, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: population
    name: Population
    short: Population
    domain: Demographics
    statvar: Count_Person
    source: base
    source_id: census_acs_5yr
    source_name: Census ACS 5-year survey
    source_kind: survey
    facet_hint: ACS5Year
    polarity: neutral
    no_referral_note: >-
      Population growth is context, not an achievement in itself; cities are
      not ranked as "improvers" on headcount.
    unit: people
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    moe_statvar: dw/MarginOfError_CityPlus_Population
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    denominator: null
    uncertainty:
      model: published_moe
      note: >-
        Published ACS margins for this indicator are computed from the
        source-table cell margins and stored as a local twin variable
        (moe_statvar); trend claims are gated by them.
    caveats: []
    discontinuities: []
    comparison: {improver_eligible: false, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: children_share
    name: Children's share of the population
    short: Children
    domain: Demographics
    statvar: Count_Person_Upto18Years
    source: base
    source_id: census_acs_5yr
    source_name: Census ACS 5-year survey
    source_kind: survey
    facet_hint: s0101
    denominator: Count_Person
    denominator_facet_hint: acs5year
    polarity: neutral
    no_referral_note: >-
      A rising share of children can mean families arriving or elders leaving;
      a falling one can mean aging in place or families priced out. It is
      context about who a city serves, not a score, so no referral list.
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    moe_statvar: dw/MarginOfError_CityPlus_ChildrenShare
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    uncertainty:
      model: published_moe
      note: >-
        Published ACS margins for this indicator are computed from the
        source-table cell margins and stored as a local twin variable
        (moe_statvar); trend claims are gated by them. Share of all residents under 18.
    caveats: []
    discontinuities: []
    comparison: {improver_eligible: false, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: adult_obesity
    name: Adult obesity
    short: Adult obesity
    domain: Health
    statvar: Percent_Person_Obesity
    source: base
    source_id: cdc_places
    source_name: CDC PLACES
    source_kind: modeled
    facet_hint: null
    polarity: lower_better
    no_referral_note: >-
      Lower obesity is clearly better, but these numbers are statistical
      estimates projected from a national survey, not local measurements.
      Year-to-year movement mostly reflects the model's inputs, so ranking
      cities by "improvement" here would reward modeling artifacts. When a
      directly measured local health source is added, referrals turn on.
    unit: percent
    cadence: annual
    series_smoothing: none
    pace_class: modeled
    annualize: null
    grains: [City, CensusTract]
    decomposition: none
    denominator: null
    uncertainty:
      model: modeled
      note: >-
        Model-based small-area estimate projected from the national BRFSS
        survey. By design it moves slowly and carries no usable trend signal,
        so City+ shows the level only and makes no trend or improver claims.
    caveats:
      - Modeled estimate, not a direct measurement.
    discontinuities: []
    # Level-only card: no trend, no improver claims.
    comparison: {improver_eligible: false, trend_eligible: false, min_pop: 0}
    adapter: null

  # ── City+ ACS expansion batch (2026-07-30) ────────────────────────────
  # Six variables emitted first-hand from published ACS detail tables
  # (data_compiler/pipeline/emit_acs_cityplus.py) plus one base-DC series.
  # Shares are computed at emit time with published 90% margins of error
  # shipped as dw/MarginOfError_ twins, so uncertainty gates use real MOEs.

  - id: cost_burdened
    name: Housing cost-burdened households
    short: Cost burden
    domain: Housing
    # Replaces the Zillow rent-vs-income proxy: the ACS measures what
    # households actually PAY (rent, or mortgage+taxes+fees) against their
    # own income, renters and owners alike, with published margins.
    statvar: dw/Percent_Household_CostBurdened
    source: local
    source_id: acs_cityplus
    source_name: Census ACS 5-year survey (tables B25070 + B25091)
    source_kind: survey
    facet_hint: null
    polarity: lower_better
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    denominator: null
    uncertainty:
      model: published_moe
      note: >-
        Share of households spending 30% or more of income on housing,
        among households whose burden could be computed. Published ACS
        margins of error ship with every estimate.
    caveats:
      - >-
        The 30% threshold is HUD's standard affordability line; the measure
        counts actual payments, so sitting tenants with old leases and
        owners without mortgages are represented, unlike asking-rent
        indexes.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: no_vehicle_households
    name: Households without a vehicle
    short: No vehicle
    domain: Housing
    statvar: dw/Percent_Household_NoVehicle
    source: local
    source_id: acs_cityplus
    source_name: Census ACS 5-year survey (table B08201)
    source_kind: survey
    facet_hint: null
    polarity: lower_better
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    denominator: null
    uncertainty:
      model: published_moe
      note: >-
        Published ACS margins of error ship with every estimate.
    caveats:
      - >-
        In transit-rich cities a low-car share can reflect choice rather
        than hardship; read alongside transit access, not alone.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: gini_index
    name: Income inequality (Gini index)
    short: Inequality
    domain: Economy
    statvar: dw/GiniIndex_HouseholdIncome
    source: local
    source_id: acs_cityplus
    source_name: Census ACS 5-year survey (table B19083)
    source_kind: survey
    facet_hint: null
    polarity: lower_better
    unit: index
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    annualize: null
    # City grain only: tract-level Gini margins are enormous, so a tract
    # inequality map would be mostly noise dressed as insight.
    grains: [City]
    denominator: null
    uncertainty:
      model: published_moe
      note: >-
        Gini index of household income, 0 (everyone equal) to 1 (one
        household has everything). Published ACS margins of error ship
        with every estimate.
    caveats:
      - >-
        A citywide summary: it says how spread out incomes are, not who is
        poor. Read with the income and poverty cards.
    discontinuities: []
    no_referral_note: >-
      No referral list for this metric: inequality moves with metro labor
      markets more than any one city policy, so naming "peers worth a
      call" would overclaim.
    comparison: {improver_eligible: false, trend_eligible: true, min_pop: 0}
    adapter: null
    # Deterministic hypothesis tests for the "What's behind this?" brief:
    # each check is computed from held (base ACS) series over the card's
    # own window, so the chat VALIDATES its distribution hypotheses
    # instead of hand-waving them. Declared here, never in code.
    hypothesis_checks:
      - id: skew
        label: Mean-to-median income ratio
        kind: ratio
        numerator: [Mean_Income_Household]
        denominator: Median_Income_Household
        nature: inflation_invariant
        interpretation: >-
          Higher means the top of the distribution is pulling further
          from the middle. A ratio of two same-year dollar figures, so
          inflation cancels out - this is the most reliable single
          distribution signal here.
      - id: top_share
        label: Share of households with income $200k or more
        kind: share
        numerator: [Count_Household_IncomeOf200000OrMoreUSDollar]
        denominator: Count_Household
        nature: nominal_threshold
        caution: >-
          Fixed $200k line in nominal dollars: citywide nominal income
          growth (inflation plus real gains) mechanically pushes
          households over it even with NO distributional change. Weak
          evidence of rising inequality on its own - never lead with it.
        interpretation: >-
          A rising share means more households above a fixed nominal
          $200k line.
      - id: bottom_share
        label: Share of households with income under $25k
        kind: share
        numerator:
          - Count_Household_IncomeOfUpto10000USDollar
          - Count_Household_IncomeOf10000To14999USDollar
          - Count_Household_IncomeOf15000To19999USDollar
          - Count_Household_IncomeOf20000To24999USDollar
        denominator: Count_Household
        nature: nominal_threshold
        caution: >-
          Fixed $25k line in nominal dollars: nominal income growth
          mechanically lifts households over it, so a falling share is
          partly bracket creep, not only real improvement.
        interpretation: >-
          A falling share means fewer households below a fixed nominal
          $25k line.
      - id: tract_income_divergence
        label: Neighborhood income divergence
        kind: tract_divergence
        metric: median_household_income
        interpretation: >-
          Whether the city's highest-income neighborhoods pulled further
          away from its lowest-income neighborhoods over the window.
      - id: race_income_gap
        label: Income gap between racial groups
        kind: group_gap
        metric: median_household_income
        dim: race
        interpretation: >-
          Whether the gap between the highest- and lowest-income racial
          groups widened over the window.

  - id: snap_households
    name: Households receiving SNAP
    short: SNAP
    domain: Economy
    statvar: Count_Household_WithFoodStampsInThePast12Months
    source: base
    source_id: census_acs_5yr
    source_name: Census ACS 5-year survey
    source_kind: survey
    facet_hint: ACS5Year
    polarity: lower_better
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    moe_statvar: dw/MarginOfError_CityPlus_SnapHouseholdsRate
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    denominator: Count_Household
    uncertainty:
      model: published_moe
      note: >-
        Published ACS margins for this indicator are computed from the
        source-table cell margins and stored as a local twin variable
        (moe_statvar); trend claims are gated by them.
    caveats:
      - >-
        Receipt measures hardship AND program reach at once: a falling
        share can mean fewer struggling households or worse enrollment.
        Read with the poverty cards.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: single_parent_children
    name: Children in single-parent families
    short: Single-parent
    domain: Demographics
    statvar: dw/Percent_Child_SingleParentFamily
    source: local
    source_id: acs_cityplus
    source_name: Census ACS 5-year survey (table B09002)
    source_kind: survey
    facet_hint: null
    # Context about family structure, not a score: no better direction.
    polarity: neutral
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    denominator: null
    uncertainty:
      model: published_moe
      note: >-
        Share of own children under 18 living with an unmarried
        householder. Published ACS margins of error ship with every
        estimate.
    caveats:
      - >-
        Own children of the householder; children living with
        grandparents or other relatives are outside this universe.
    discontinuities: []
    no_referral_note: >-
      No referral list for this metric: neither direction is inherently
      better here.
    comparison: {improver_eligible: false, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: disconnected_youth
    name: Teens not in school and not working
    short: Disconnected youth
    domain: Education
    statvar: dw/Percent_Youth16To19_NotInSchoolNotWorking
    source: local
    source_id: acs_cityplus
    source_name: Census ACS 5-year survey (table B14005)
    source_kind: survey
    facet_hint: null
    polarity: lower_better
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    denominator: null
    uncertainty:
      model: published_moe
      note: >-
        Share of 16-19 year olds neither enrolled in school nor employed.
        A small universe, so margins are wider than for all-ages measures;
        published ACS margins of error ship with every estimate.
    caveats:
      - >-
        Ages 16-19 only; the broader 18-24 "opportunity youth" definition
        runs higher.
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: child_uninsured
    name: Children without health insurance
    short: Child uninsured
    domain: Health
    statvar: dw/Percent_Child_NoHealthInsurance
    source: local
    source_id: acs_cityplus
    source_name: Census ACS 5-year survey (table B27001)
    source_kind: survey
    facet_hint: null
    polarity: lower_better
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    denominator: null
    uncertainty:
      model: published_moe
      note: >-
        Share of children under 19 with no health insurance coverage.
        Published ACS margins of error ship with every estimate.
    caveats: []
    discontinuities: []
    comparison: {improver_eligible: true, trend_eligible: true, min_pop: 0}
    adapter: null

  - id: children_working_parents
    name: Young children with all parents in the labor force
    short: Working parents
    domain: Demographics
    statvar: dw/Percent_ChildUnder6_AllParentsInLaborForce
    source: local
    source_id: acs_cityplus
    source_name: Census ACS 5-year survey (table B23008)
    source_kind: survey
    facet_hint: null
    # A childcare-demand signal, not a score: no better direction.
    polarity: neutral
    unit: percent
    cadence: annual
    series_smoothing: pooled_5yr
    pace_class: survey_overlap
    annualize: null
    grains: [City, CensusTract]
    decomposition: change
    denominator: null
    uncertainty:
      model: published_moe
      note: >-
        Share of children under 6 whose resident parents are all in the
        labor force - the young children who need care from someone other
        than a stay-at-home parent. Published ACS margins of error ship
        with every estimate.
    caveats: []
    discontinuities: []
    no_referral_note: >-
      No referral list for this metric: neither direction is inherently
      better here.
    comparison: {improver_eligible: false, trend_eligible: true, min_pop: 0}
    adapter: null
