provenance

Provenance

Every fact about a Norwegian company, with its source.

A keyless Norwegian company-research agent for the Builderr “Signalpost” competition. Given a batch of Norwegian organisation numbers, it produces one evidence-backed JSON envelope per company: legal identity, financials, leadership, workplaces, group structure, verified website, company-owned social profiles, a site-derived description, job postings, dated public activity, and official food-hygiene inspection ratings (Mattilsynet) where they exist — every available claim carries a source URL, retrieval time, content hash and the exact supporting text or JSON path. Re-running against the same state directory detects real changes, keeps prior evidence, and never invents a false change.

Built on the Builderr Signalpost starter kit (its SSRF guard and social-URL normaliser live on in src/signalpost/urls.py; the kit’s prototype scripts are not part of this agent).

Find, compare and verify (after a run)

Every signalpost run also writes a browsable, offline site to <output-dir>/site/. Open <output-dir>/site/index.html in any browser (a plain file:// URL works; no server, no internet). The 100-company smoke test’s copy is in smoke-test/site/.

Without a browser: envelopes.jsonl has one JSON object per company; each claim’s evidence_ids point into that company’s evidence list (source URL, retrieval time, hash and supporting text).

What it actually does

Quickstart

Requires Python 3.12+ and uv.

uv sync

Data prerequisites

The pipeline needs the Brønnøysund bulk registry CSV (gzip-compressed despite the .csv name). The run downloads it itself; to fetch it by hand:

mkdir -p data
curl -L 'https://data.brreg.no/enhetsregisteret/api/enheter/lastned/csv' -o data/brreg-enheter.csv

signalpost run looks for ./data/brreg-enheter.csv.gz or ./data/brreg-enheter.csv by default (or pass --bulk <path> explicitly). If neither is found, or the copy is more than 20 hours old (the register publishes it nightly), it downloads the file itself, up to 3 attempts within at most 10 minutes in all (the register serves it without resume, and on a slow day at a few hundred KB/s), each counted as a setup request; a failed download falls back to the older copy, or to the live registry API alone. No API key is required — the registry API is keyless and public.

Caches are built automatically. When --caches is omitted, signalpost run uses ./cache and, if it is empty, builds the two identity-critical parts itself before the batch starts: the shared-domain table from the bulk file (0 requests, a few seconds) and the Wikidata table (copied from the bundled CC0 snapshot, 0 requests, so two runs read the same Wikidata facts; SIGNALPOST_WIKIDATA_LIVE=1 queries the live SPARQL endpoint instead). A clean clone therefore needs only uv sync and the run command below; the first run also downloads the bulk file, indexes the bundled open-places and NAV snapshots, and reads the NAV job feed published since the NAV snapshot (a few requests per day of snapshot age, reused from --state-dir by later runs). Setup requests are reported separately in run-report.json (setup_requests, total_requests_including_setup).

Optional: the fuller pre-built offline caches (shared/service-provider email and website domains, subunit trade-name aliases, Wikidata company profiles, and a NAV job-feed index). These are built once, outside the timed run, and reused across many batches:

uv run python -m signalpost prepare \
  --cache-dir cache/ \
  --bulk data/brreg-enheter.csv

(Equivalently: uv run python -m signalpost.caches.prepare --cache-dir cache/ --bulk data/brreg-enheter.csv — the signalpost prepare subcommand simply delegates to this module.) The NAV part of prepare is a slow, resumable walk of a full event-log feed (measured ~3.7s/page; see docs/caches.md) — it can be capped with --nav-max-seconds <N> and re-invoked later to continue from its saved cursor, or skipped entirely with --skip-nav for a first run. --skip-email-domains / --skip-aliases / --skip-wikidata skip the other parts individually. The library code handles missing caches; the run command always builds or reuses ./cache as described above.

The evaluator command

uv run python -m signalpost run \
  --organisations <path/to/organisation-numbers.jsonl|.json|.txt> \
  --output-dir out/ \
  --state-dir state/ \
  --run-id <run-id> \
  [--bulk data/brreg-enheter.csv] \
  [--caches cache/] \
  [--max-requests <N>] \
  [--deadline-seconds 2400] \
  [--workers 24]

--organisations accepts a .jsonl file (one organisation number, or {"organisation_number": "..."}, per line), a .json file (a JSON list, or {"organisation_numbers": [...]}), or a plain .txt file (one number per line). Every input must be a 9-digit Norwegian organisation number with no duplicates.

--bulk is optional (see “Data prerequisites” above for the default-path/auto-download behaviour). --caches is optional; when omitted, ./cache is used and built automatically (see above).

Run budget (the official batch is 1,000 companies in one run, and may grow to 1,100; the scored reference set is 1,500):

Measured on 19 Oct 2026 (with group structure and earlier filed years): 1,000 companies drawn like the scored population took 29.7 minutes and 14,063 requests in one run with the defaults, 0 deadline or budget hits, 998 completed and 2 partial, peak memory 699 MB. Earlier: 1,500 companies drawn like the scored population (account filers, 60% with registered staff) took 28 minutes and about 19,700 requests (13 per company) in one run with the defaults, with no deadline or budget hits and all 1,500 completed. 1,500 random register companies took 25–28 minutes and about 14,500 requests (peak memory 738 MB); a second, separate run of those gave identical facts for 1,497, the 3 differences being websites that were down in one run or changed their own text between runs.

Output, written to --output-dir:

State, written to --state-dir (reused across runs — see “Refresh” below):

Validate

uv run python -m signalpost validate --envelopes out/envelopes.jsonl [--organisations <path>]

Checks the envelope-invariants contract: exactly one envelope per organisation number (when --organisations is given), every family present, every available claim has a value and evidence, no dangling evidence references. Exits non-zero on failure.

Build the viewer

signalpost run builds it into <output-dir>/site/ automatically. To rebuild it from any envelopes file:

uv run python -m signalpost.viewer.build --envelopes out/envelopes.jsonl --out out/site

Produces a fully static, offline-capable site (out/site/index.html) — a directory with search/filter/ sort/CSV export, a pre-rendered profile page per company with an evidence drawer on every fact, and a 2–4-company compare view. No CDN, no external scripts or fonts; works from a plain file:// URL or any static host. See docs/viewer.md.

Refresh

Refresh is not a separate command: rerun signalpost run with the same --state-dir (a new --run-id, and optionally a changed --organisations list). Each company’s new envelope is merged against its stored profile; envelope.changes lists what genuinely differs since the previous run, prior evidence for superseded claims is retained in --state-dir, and a source that could not be re-checked this run carries its previous claim forward unchanged rather than dropping it or inventing a change. See REFRESH.md.

Tests

uv run --with pytest pytest -q tests/signalpost

Cost

$0 per run. No LLM, no paid API, no required secrets. Every source used (Brønnøysund, NAV, Wikidata, Overture Maps and OpenStreetMap snapshots, Mattilsynet, company websites) is keyless and free. The only “spend” possible is bandwidth/compute on the runner itself.

Models / APIs / licences

Component What Licence / terms
Language model none — every claim and summary sentence is built deterministically from fetched evidence; no LLM is called anywhere in the pipeline n/a
Brønnøysund Enhetsregisteret (bulk + live) company identity, roles, subunits, group structure (konsernstruktur) NLOD 2.0
Brave Search API (optional, only with BRAVE_SEARCH_API_KEY) website candidates only, never evidence; results kept in memory only Brave Search API terms; key supplied by the evaluator
Brønnøysund Regnskapsregisteret annual accounts NLOD 2.0
NAV pam-stilling-feed (bundled active-ads snapshot + live catch-up) job postings NLOD; keyless public feed token. The arbeidsplassen search fallback is off by default (SIGNALPOST_NAV_SEARCH_MAX)
Wikidata (bundled snapshot; live SPARQL opt-in) company websites/social profile cache CC0
Overture Maps Places (bundled snapshot) website candidates CDLA-Permissive-2.0 / Apache-2.0 / CC0
OpenStreetMap ref:NO:orgnr (bundled snapshot) website candidates ODbL 1.0, © OpenStreetMap contributors
Mattilsynet smilefjes food-hygiene inspection ratings public authority data
Company websites verified official site content robots.txt respected per host; see SOURCES.md
YouTube feeds/videos.xml channel activity, only for channels linked from a verified site attempted, respect_robots=True; currently blocked by YouTube’s own robots.txt — see LIMITATIONS.md

Full source-by-source detail, robots handling and licence basis: see SOURCES.md.

Request budgeting

Every request made during the batch — including every redirect hop and every retry — is charged against a single global Budget (src/signalpost/http.py), hard-capped at --max-requests (default 26 per input company, overridable by the evaluator). Each company gets a soft per-company allowance from planner.classify (14/24/ 36/55 requests for tiers T0–T3, based only on bulk-registry signals — staff count, NACE code, legal form, presence of a registered site/domain), but may borrow from the shared pool up to the hard cap. Every company’s official-registry calls are reserved up front so optional sources (website discovery, jobs, activity) can never starve them. Robots.txt is honoured per host for every source except the Brønnøysund registry APIs (company sites, NAV, Mattilsynet, YouTube all check it). All batch requests go through an SSRF guard (assert_public_url) that resolves DNS and rejects private/loopback/link-local/reserved addresses before connecting. The one-time setup downloads (bulk file, and Wikidata only when run live) go to fixed official URLs and are counted separately as setup_requests in run-report.json.

Expected cost per run

$0 for a 1,000-company batch or any other size without a search key: no third-party API spend, and run-report.json’s third_party_cost_usd is 0.0. With the optional BRAVE_SEARCH_API_KEY, at most one search per company (only companies with no org-number-keyed website candidate), so at most about $5 per 1,000 companies at Brave’s list price; the report states the actual figure.