The placegraph contract
Every package builds against this. It is stable; if you need something that is not here, add it to placegraph-core and update this document in the same change.
1. Layout
placegraph/
packages/placegraph-<name>/
pyproject.toml # name = "placegraph-<name>"
src/placegraph/<name>/__init__.py # module = placegraph.<name>
PEP 420 implicit namespace packages: there is no src/placegraph/__init__.py in any package. Adding one breaks every other package's imports.
placegraph-core depends only on the standard library. Other packages may depend on placegraph-core and, where genuinely needed, httpx; placegraph-web adds fastapi, uvicorn, jinja2, python-multipart.
2. Configuration
There is no module-level config and no default site. Everything reads the active SiteConfig:
from placegraph.core import settings
cfg = settings.active() # raises settings.NotConfigured if unset
cfg.slug, cfg.site_name, cfg.base_url, cfg.data_dir, cfg.db_path
cfg.target_lads, cfg.neighbour_lads, cfg.all_lads, cfg.listed_lads
cfg.postcode_areas, cfg.postcode_districts, cfg.towns
cfg.taxonomy, cfg.score_profile, cfg.headline_score
cfg.demo_mode, cfg.publish_live_data, cfg.publish_thresholds
cfg.env("SOME_EXTRA", default, cast=int) # read {PREFIX}_SOME_EXTRA
Never read os.environ["KILN_..."] or any hardcoded prefix. Never cache settings.active() at import time in a module-level constant — tests swap the config with settings.using(...), and a captured value defeats that. Read it inside the function.
3. Core API
from placegraph.core.db import (
connect, session, init_db,
record_fact, facts_for, facts_by_key,
find_entity, upsert_entity, attach_address, recompute_entity_lad,
suppression_keys, is_suppressed, add_suppression,
set_score, get_score, scores_for, record_signals, signals_for,
rebuild_fts, start_run, finish_run, now_iso, hash_ip, slugify,
LAD_ROLE_PRIORITY,
)
from placegraph.core.normalise import (
now_iso, normalise_name, normalise_postcode, postcode_district,
postcode_area, normalise_domain, normalise_phone, format_phone,
clean_display_name, slugify, hash_ip, in_scope,
has_legal_suffix, has_company_token, looks_personal_name,
find_company_number,
)
from placegraph.core import joblog, migrate, sources as source_registry
Signatures you will use constantly:
upsert_entity(conn, *, display_name, source_id, source_ref=None, kind="unknown",
company_number=None, website=None, phone=None, email=None,
postcode=None, address_lines=None, lat=None, lng=None,
category_hint=None, extra_facts=None, confidence=0.6,
fetched_at=None, address_role="primary") -> (entity_id|None, created)
record_fact(conn, entity_id, key, value, *, source_id, source_ref=None,
fetched_at=None, confidence=0.5, ttl_days=None) -> None
attach_address(conn, entity_id, *, postcode, lines=None, lat=None, lng=None,
role="primary", source_id="crawl") -> address_id|None
set_score(conn, entity_id, name, value)
record_signals(conn, entity_id, score, [(signal, weight, detail), ...])
upsert_entity returns (None, False) for a suppressed record. Always check for None before using the id — a suppressed record silently skipped is the correct behaviour, an AttributeError is not.
4. Schema names
The graph is vertical-neutral. Use these, never craft/sector/sole-trader names:
| Concept | Table / column |
|---|---|
| Classification | entity.category_primary, entity.category_confidence, table category_taxonomy |
| Named scores | entity_score(entity_id, score, value); entity.headline_score is a denormalised copy of the site's primary score |
| Score explanation | entity_signal(entity_id, score, signal, weight, detail) |
| Address authority | entity_location.role, ranked by db.LAD_ROLE_PRIORITY |
| Council area | entity.lad_code, address.lad_code, table lad(is_target, is_listed) |
| Public-form abuse counting | rate_limit(actor, action, created_at); actor is a hashed IP, never an address |
Platform migrations are 0001–0999 in placegraph/core/migrations/. Site migrations start at 1000 and live in the site package; both streams merge by version number.
Do not add CREATE TABLE IF NOT EXISTS schema evolution anywhere. New tables are new numbered migrations.
5. Taxonomy (placegraph.scoring.taxonomy)
@dataclass(frozen=True)
class Category:
code: str
label: str
sic: tuple[str, ...] # codes SPECIFIC to this category, may be empty
keywords: tuple[str, ...]
blurb: str = ""
class Taxonomy:
def __init__(self, categories, *, broad_sic={}, excluded_sic={},
reconciliation_name=""): ...
categories: list[Category]
def label_for(code) -> str
def get(code) -> Category | None
def sic_to_category(sic) -> str | None # None unless SPECIFIC
def is_broad_sic(sic) -> bool # corroborates, never classifies
def is_excluded_sic(sic) -> bool
def is_in_scope_sic(sic) -> bool # the single ingestion-time test
def discovery_sic_codes() -> set[str]
def classify(name, *texts) -> (code|None, confidence, hits)
The two rules that must survive: SIC lookup checks five digits then four (47781 art galleries vs 47789 everything else), and a broad code never assigns a category on its own.
6. Scores (placegraph.scoring.engine)
@dataclass(frozen=True)
class ScoreDefinition:
name: str
question: str # plain-English, shown on the profile
weights: dict[str, float] # signal name -> weight
collector: Callable[[SignalContext], list[Signal]]
squash: str = "logistic" # logistic | clamp | raw
gain: float = 3.0
class ScoreProfile:
scores: list[ScoreDefinition]
def run(conn, entity_id) -> dict[str, float]
def run_all(conn) -> int
@dataclass
class SignalContext:
conn; entity: sqlite3.Row; facts: dict[str, list[str]]; sources: set[str]
audit: sqlite3.Row | None; pages: list[sqlite3.Row]; accounts: sqlite3.Row | None
premises: list[sqlite3.Row]; taxonomy; config
def signal(name, detail) -> Signal # weight looked up from the definition
A collector returns only the signals that fired; the engine looks up weights, squashes, persists the value with set_score and the explanation with record_signals.
7. Publication
placegraph.scoring.publish.publish(conn) is the only thing that sets entity.is_public. It enforces, in order:
demo_modepublishes only synthetic records;publish_live_datapublishes only real ones; with both off nothing is published.- Publishing live data with
cfg.missing_controller_fields()non-empty raisesSystemExit— a notice that cannot name the controller is not a notice. - Every threshold in
cfg.publish_thresholdsmust be met (>=for amin_*-style score,<=for one prefixedmax_). status in ('candidate','verified','claimed'), alad_codeincfg.listed_lads, and a category whencfg.require_category_to_publish.status='claimed'overrides the thresholds but never the gates.removed,suppressed,removal_pendingare always hidden.
8. Sources
Each source module exposes SOURCE_ID and an ingest(...)/run(...) returning a JSON-serialisable dict of counts. Every one:
- opens a run with
start_run/ closes it withfinish_run; - commits incrementally (every ~250 rows) and prints progress;
- skips work it has already done, so re-running is cheap and safe;
- honours its cap from config in code, not by convention, so a loop bug costs nothing.
9. Web
placegraph.web.create_app(config=None) -> FastAPI. Templates resolve through ChoiceLoader([FileSystemLoader(cfg.theme_dir), FileSystemLoader(base)]), so a site overrides home.html by shipping its own and inherits everything else. Routes are registered by small register_*(app, deps) functions in placegraph/web/routes/, so a site can add its own without forking the factory.
10. House style
Match the code this was extracted from:
- Module docstring explaining why the module exists and what it got wrong before. Comments explain reasoning, not mechanism.
from __future__ import annotations, modern typing, 4-space indent, ~88 cols.- No new dependencies without a reason that survives being said out loud.
- Never log or store a raw IP;
hash_ipexists for that. - Every metered call is capped in code.
- British English in user-facing copy.