⚡ Swarm Architecture

Firmographic Cluster Semantic Labels — Design

# Firmographic Cluster Semantic Labels — Design

Date: 2026-06-11 Status: Approved (brainstorm) — pending spec review Repo: lead-contagion-project (Lyréco tenant)

Goal

Give each firmographic cluster (the KMeans communities in account_firmographic_community, k=60 per country) a human-readable name + a one-line "what it's made of" description, so marketers read *"Mid-spend South-East professional services" instead of "cluster 37."* Names/descriptions are hybrid-generated: facts computed deterministically, phrasing by the LLM.

Decision (brainstorm)

Naming method = hybrid. Compute each cluster's defining traits deterministically, then claude -p turns the factual summary into a polished name + description. Facts are reproducible; wording is human and marketer-facing.

Components

1. Stat profiler (deterministic — the "made of")

For each (source_country, community_id), aggregate its members from customer_firmographic_features (joined to account_firmographic_community on account_number, source_country) into a compact factual profile:

  • member_count — accounts in the cluster.
  • dominant_sector — the modal accounts_category (fallback sic_group) +
its share, with a human label via sic_meta_sectors. e.g. `"professional services" (62%)`.
  • spend_tier — banded from the cluster's median annual_spend:
micro < £1k · small £1k–10k · mid £10k–50k · large ≥ £50k (GB £; thresholds are module constants, tunable).
  • size_band — from median n_users: `solo 1 · small 2–5 · mid 6–20 ·
large 20+`.
  • dominant_region — top postcode_area (fallback region_key) + share.
  • churn_share — % of members with is_churn = true.

Reproducible and auditable: every claim in a description traces back to a number. Implemented as a pure function profile_cluster(member_rows) -> dict over the fetched rows, so it is hermetically testable and free of DB/LLM coupling.

2. LLM namer (phrasing)

One batched claude -p call for all clusters (not one per cluster). Input = JSON list of the stat-profiles. Output = JSON array of {community_id, name, description} where:

  • name ≤ 4 words, title-case, marketer-friendly;
  • description ≤ 1 sentence, plain language, only traits present in the
stats (no invented attributes).

Mechanics: claude -p with stdin=subprocess.DEVNULL (the console-less-service stdin-hang gotcha), subscription-billed (quota, not API). The prompt instructs strict JSON-only output. Validate the returned JSON (shape + every community_id present); if it fails or the CLI errors, fall back to a rule-based name/description assembled from the top features, so a row is never left unlabeled.

3. Storage

New Postgres table:

`sql CREATE TABLE IF NOT EXISTS firmographic_community_labels ( source_country TEXT NOT NULL, community_id INTEGER NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL, member_count INTEGER NOT NULL, top_sector TEXT, spend_tier TEXT, size_band TEXT, dominant_region TEXT, churn_share NUMERIC(5,2), generated_by TEXT NOT NULL DEFAULT 'llm', -- 'llm' | 'rule_fallback' last_computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (source_country, community_id) ); `

Stores the human label and the stats behind it (auditable + reusable).

4. Worker + cadence

workers/label_firmographic_communities.py --country GB: 1. fetch members + features per cluster, 2. profile_cluster each → stat-profiles, 3. batched claude -p → names/descriptions (with rule-based fallback), 4. idempotent write (delete-then-insert per source_country).

Runs after community_match_soft (the clusterer) — chained in daily_refresh when communities are recomputed, or run manually.

Reproducibility caveat (pin): KMeans community_id is not stable across rebuilds — a re-run of community_match_soft can renumber clusters. Labels therefore regenerate each time the clusters do; a label describes the current composition, not a durable cluster identity.

5. Surfacing

  • Cluster rollup report (scripts/export_buying_profiles.py): replace the
"Firmographic cluster {id}" heading with "{name} — {description}" via a LEFT JOIN to firmographic_community_labels (NULL-safe: fall back to the id).
  • Buying Profile panel (/panel/buying-profile): show the account's cluster
name in the header — join account_firmographic_community → labels for the resolved account.

6. Testing

  • Hermetic unit tests on profile_cluster (synthetic member rows → expected
spend_tier/size_band/dominant_sector/share/churn_share) and on the rule-based fallback namer.
  • The LLM step is integration-only (skipped without the CLI / in CI).

Data-flow notes

  • All inputs/outputs are Postgres (customer_firmographic_features,
account_firmographic_community, sic_meta_sectors, firmographic_community_labels) — single-DB, no cross-DB join.
  • claude -p billing = subscription quota; one batched call keeps it cheap.

YAGNI cuts

No manual-override UI (edit the row directly if a label is off — generated_by flags provenance). No per-cluster LLM calls. No FR until its communities exist (GB-only now). No durable cluster identity across rebuilds.

Open / tunable

  • Spend-tier £ thresholds (module constants) — first pass as above, tune on
inspection of the GB distribution.
  • Whether to add the panel-header cluster name now or defer — included now
(cheap once the table exists).