diff --git a/README.md b/README.md
index 97b0fe5..19d6637 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,18 @@ A discovery query is a tenant-scoped, bounded, auditable request that can be val
SQLite, the in-process worker, and the named local volume are suitable for the pilot only; production migration, durable queue/worker leases, event retention/backup, SSE delivery, and tested backup/restore remain unfinished. Redis and Celery are not implemented. The development password fallback is PBKDF2 rather than production Argon2id. Before production, complete the gates in `docs/SECURITY.md` and `docs/OPERATIONS.md`, including MFA, TLS, CSRF protection, source approval and terms review, rate limiting, circuit monitoring, tenant-scoped job/event authorization, idempotent side-effect handling, durable raw-source/audit retention, SSRF-safe fetching if a future scanner is approved, and tested backups/restores.
+## Phase 16 pilot benchmark boundary
+
+Phase 16 defines a local, reproducible pilot methodology for evaluating classification and scoring against labeled **synthetic fixtures**. A fixture has a stable ID, schema/label-policy version, input, expected class and safety state, and rationale; it contains no live personal data, credentials, uncontrolled network response, or production record. The fixture manifest is kept separate from implementation outputs and is versioned/hash-recorded with every run. Positive, negative, unknown, blocked, partial, timeout, error, stale, conflicting, suppressed, malformed, boundary, cache, and cross-tenant cases must be represented where applicable.
+
+Reports include a confusion matrix and support plus precision (`TP/(TP+FP)`) and recall (`TP/(TP+FN)`) for each declared class, with `N/A` for zero denominators. Unknown/blocked/partial/error and safety outcomes are reported separately. Synthetic precision/recall is not evidence of identity, ownership, consent, deliverability, fairness, generalization, operational reliability, or production usefulness; labels may be cleaner or less representative than real data. See `docs/BENCHMARKS.md` for the complete protocol, limitations, reproducibility fields, human-review sampling, and acceptance thresholds.
+
+Classification/scoring reproducibility requires identical deterministic output fields across repeated fresh-process runs, using recorded commit, fixture hash, algorithm/rule-set versions, runtime/dependency/image versions, non-secret configuration, seed, locale/timezone, rounding, and tie-breaking. Latency must report cold and warm cache separately with cache state, freshness, hit/miss counts, sample count, median, p95, p99, maximum, errors, and timeout handling; averages alone are insufficient. A cache hit is never a fresh observation.
+
+The pilot gates are synthetic-only: deterministic fields must reproduce at 100%; where positive support is at least 20, precision must be `>=0.90` and recall `>=0.85`; suppressed/blocked/malformed/unknown safety cases must remain non-eligible/non-positive as applicable; tenant-isolation probes must pass 100%; and every latency sample must retain a duration and cache-state label. Human review is blinded and reproducible, stratified across predictions, uncertainty/suppression, and disagreements, with at least 20 fixtures or 10% of the set (whichever is larger, capped by the declared budget). Passing these gates makes no production claim.
+
+Remote push and deployment prerequisites remain unresolved. Local docs, tests, Compose validation, or a local benchmark do not constitute a remote push or production deployment. Remote publication still requires repository authentication and branch permission; deployment still requires the Phase 15 reviewed revision, host/Docker/Compose access, protected directory, secrets, DNS/TLS/Virtualmin, encrypted off-host backups, monitoring, rollback ownership, and operational approval. No deployment is performed by this change.
+
## Phase 6 normalization and deduplication boundary
Normalization is deterministic and versioned. For South African data, phone values are stripped to digits, local 10-digit `0` forms and `00 27` forms are converted to canonical `+27...`, and unknown international numbers retain their explicit country code; presentation punctuation must not create a second identity. Locations derive whitespace/case/diacritic-folded province, city, and suburb fields. A normalized value is not proof that the underlying observation is correct.
diff --git a/apps/api/fixtures/phase16.json b/apps/api/fixtures/phase16.json
new file mode 100644
index 0000000..1f7e95b
--- /dev/null
+++ b/apps/api/fixtures/phase16.json
@@ -0,0 +1,82 @@
+{
+ "seed": 1601,
+ "limitations": [
+ "Synthetic, small, English-heavy cases are not representative of production traffic.",
+ "Contact labels cover parser false positives but do not establish consent, identity, or deliverability.",
+ "Website fixtures classify supplied HTML only; they do not measure network, DNS, redirects, or adversarial pages.",
+ "Latency and cache measurements are local pilot signals and vary by host load."
+ ],
+ "normalization": [
+ {"name": "SA contact fields", "input": {"name": " Acme Solar ", "website": "HTTPS://WWW.ACME.TEST/path", "phone": "082 555 1234", "email": " SALES@ACME.TEST "}, "expected": {"name": "Acme Solar", "website_domain": "acme.test", "email": "sales@acme.test"}},
+ {"name": "accented location", "input": {"name": "Cafe", "province": " Western Cape ", "city": "CAPE TOWN", "suburb": "Sea Point"}, "expected": {"province": "western cape", "city": "cape town", "suburb": "sea point"}}
+ ],
+ "matching": [
+ {
+ "name": "shared exact domain is a positive",
+ "source": {"name": "Acme Solar", "website": "https://www.acme.test", "city": "Johannesburg"},
+ "candidates": [
+ {"id": 1, "name": "Acme Solar (Pty) Ltd", "website": "http://acme.test/", "city": "Johannesburg"},
+ {"id": 2, "name": "Acme Supplies", "website": "https://supplies.test", "city": "Johannesburg"}
+ ],
+ "threshold": 0.72,
+ "expected_ids": [1],
+ "forbidden_ids": [2]
+ },
+ {
+ "name": "similar name without corroboration is not a positive",
+ "source": {"name": "Brightline Consulting", "city": "Cape Town"},
+ "candidates": [
+ {"id": 3, "name": "Brightline Consulting", "city": "Durban"},
+ {"id": 4, "name": "Brightline Consulting", "city": "Cape Town"}
+ ],
+ "threshold": 0.72,
+ "expected_ids": [4],
+ "forbidden_ids": [3]
+ },
+ {
+ "name": "unrelated businesses are negatives",
+ "source": {"name": "Green Oak Architects", "website": "green-oak.test"},
+ "candidates": [
+ {"id": 5, "name": "Green Oak Accounting", "website": "accounting.test"},
+ {"id": 6, "name": "Red River Bakery", "website": "redriver.test"}
+ ],
+ "threshold": 0.72,
+ "expected_ids": [],
+ "forbidden_ids": [5, 6]
+ }
+ ],
+ "contacts": [
+ {
+ "name": "public contacts and ignored markup",
+ "source_url": "https://acme.test/contact",
+ "html": "
Sales: sales@acme.test
Call +27 (12) 345-6789
API key: token@acme.test
",
+ "expected": [["email", "sales@acme.test"], ["phone", "+27123456789"]],
+ "forbidden": [["email", "secret@acme.test"], ["email", "tracker@tracker.test"], ["email", "token@acme.test"]]
+ },
+ {
+ "name": "forms are provenance not email destinations",
+ "source_url": "https://bright.test/contact",
+ "html": "hello [at] bright.test
",
+ "expected": [["email", "hello@bright.test"]],
+ "forbidden": [["email", "email@bright.test"]]
+ }
+ ],
+ "websites": [
+ {"name": "healthy", "status": 200, "url": "https://acme.test", "body": "Acme Solar Welcome to our business.
", "expected": "healthy"},
+ {"name": "parked", "status": 200, "url": "https://parked.test", "body": "Domain for sale - buy this domain", "expected": "parked"},
+ {"name": "construction", "status": 200, "url": "https://new.test", "body": "Website coming soon", "expected": "under_construction"},
+ {"name": "broken", "status": 404, "url": "https://gone.test", "body": "", "expected": "broken"},
+ {"name": "blocked", "status": null, "url": "https://private.test", "body": "", "error": "unsafe_address", "expected": "blocked"},
+ {"name": "unknown is not healthy", "status": null, "url": "https://unknown.test", "body": "", "expected": "unknown"}
+ ],
+ "scoring": [
+ {"name": "complete active business", "signals": {"business": {"name": "Acme", "email": "a@acme.test", "phone": "+27123456789"}, "website": {"classification": "healthy"}, "contacts": {"public_count": 1}, "domain": {"status": "resolved"}, "state": {"verified": true, "suppressed": false, "merge_status": "active"}}},
+ {"name": "suppressed remains ineligible", "signals": {"business": {"name": "Suppressed"}, "state": {"suppressed": true, "merge_status": "active"}}, "must_be_ineligible": true},
+ {"name": "stale evidence does not create positive", "signals": {"business": {"name": "Stale"}, "website": {"classification": "healthy", "stale": true}, "state": {"suppressed": false, "merge_status": "active"}}}
+ ],
+ "tenant_isolation": {
+ "tenant_a": {"id": 101, "business_ids": [1, 2]},
+ "tenant_b": {"id": 202, "business_ids": [3, 4]},
+ "cross_tenant_ids": [3, 4]
+ }
+}
diff --git a/apps/api/tests/test_phase16_benchmark.py b/apps/api/tests/test_phase16_benchmark.py
new file mode 100644
index 0000000..f0419be
--- /dev/null
+++ b/apps/api/tests/test_phase16_benchmark.py
@@ -0,0 +1,61 @@
+import json
+import sys
+import unittest
+from pathlib import Path
+
+from app.domain import match_businesses
+from app.contact_extractor import extract_contacts
+from app.website_scanner import classify_website
+from app.scoring import DEFAULT_RULES, evaluate_score
+
+ROOT = Path(__file__).resolve().parents[1]
+FIXTURE_PATH = ROOT / "fixtures" / "phase16.json"
+sys.path.insert(0, str(ROOT.parent.parent / "scripts"))
+from benchmark_phase16 import run_benchmark
+
+
+class Phase16BenchmarkRegressionTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.fixtures = json.loads(FIXTURE_PATH.read_text())
+
+ def test_fixture_is_stable_and_benchmark_cases_are_deterministic(self):
+ self.assertEqual(self.fixtures["seed"], 1601)
+ first = json.dumps(self.fixtures, sort_keys=True, separators=(",", ":"))
+ second = json.dumps(json.loads(FIXTURE_PATH.read_text()), sort_keys=True, separators=(",", ":"))
+ self.assertEqual(first, second)
+ case = self.fixtures["matching"][0]
+ self.assertEqual(
+ match_businesses(case["source"], case["candidates"], threshold=case["threshold"]),
+ match_businesses(case["source"], case["candidates"], threshold=case["threshold"]),
+ )
+
+ def test_benchmark_semantic_report_is_deterministic_and_passes_thresholds(self):
+ first = run_benchmark(measure_latency=False)
+ second = run_benchmark(measure_latency=False)
+ self.assertEqual(first, second)
+ self.assertTrue(first["passed"])
+ self.assertEqual(first["results"]["matching"]["false_positive"], 0)
+ self.assertEqual(first["results"]["contacts"]["false_positive"], 0)
+
+ def test_labeled_false_positives_stay_suppressed(self):
+ for case in self.fixtures["matching"]:
+ predicted = {item["id"] for item in match_businesses(case["source"], case["candidates"], threshold=case["threshold"])}
+ self.assertTrue(predicted.isdisjoint(set(case["forbidden_ids"])), case["name"])
+
+ for case in self.fixtures["contacts"]:
+ values = {(item["kind"], item["value"]) for item in extract_contacts(case["html"], case["source_url"])}
+ self.assertTrue(values.isdisjoint({tuple(item) for item in case["forbidden"]}), case["name"])
+
+ def test_website_labels_and_scores_have_no_unsafe_positive(self):
+ for case in self.fixtures["websites"]:
+ self.assertEqual(classify_website(case["status"], case["url"], case["body"], error=case.get("error")), case["expected"], case["name"])
+ for case in self.fixtures["scoring"]:
+ result = evaluate_score(case["signals"], DEFAULT_RULES)
+ self.assertEqual(result, evaluate_score(case["signals"], DEFAULT_RULES))
+ if case.get("must_be_ineligible"):
+ self.assertFalse(result["eligible"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/web/scripts/smoke-frontend.mjs b/apps/web/scripts/smoke-frontend.mjs
new file mode 100644
index 0000000..0f7ed9a
--- /dev/null
+++ b/apps/web/scripts/smoke-frontend.mjs
@@ -0,0 +1,136 @@
+#!/usr/bin/env node
+/**
+ * Deterministic, Chromium-free frontend pilot smoke harness.
+ *
+ * Usage:
+ * node scripts/smoke-frontend.mjs http://127.0.0.1:8080
+ *
+ * The final stdout value is JSON so CI can consume it directly. Exit code is
+ * non-zero when any check fails. The checks intentionally inspect source HTML,
+ * JavaScript, and CSS instead of executing the application or making API calls.
+ */
+import { readFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const baseUrl = (process.argv[2] || 'http://127.0.0.1:8080').replace(/\/$/, '');
+const files = Object.fromEntries(await Promise.all(
+ ['index.html', 'app.js', 'styles.css', 'config.js', 'health.html', 'error.html', 'healthz', 'smoke-test.html']
+ .map(async name => [name, await readFile(resolve(webRoot, name), 'utf8')])
+));
+
+const results = [];
+function check(id, description, pass, details = '') {
+ results.push({ id, description, pass: Boolean(pass), ...(details ? { details } : {}) });
+}
+function has(text, needles) {
+ return needles.every(needle => text.includes(needle));
+}
+function ids(html) {
+ return [...html.matchAll(/\bid=["']([^"']+)["']/g)].map(match => match[1]);
+}
+function smokeMarkers(html) {
+ return [...html.matchAll(/data-smoke=["']([^"']+)["']/g)].map(match => match[1]);
+}
+
+const html = files['index.html'];
+const js = files['app.js'];
+const css = files['styles.css'];
+const htmlIds = new Set(ids(html));
+const markers = new Set(smokeMarkers(html));
+
+check('dom.operator-markers', 'operator-critical DOM markers are present', has(html, [
+ 'id="loginScreen"', 'id="dashboardShell"', 'id="explorer"', 'id="detailPanel"',
+ 'id="reviewQueueCount"', 'id="jobsList"', 'id="sourcesList"', 'id="pipelineBoard"',
+ 'id="interactionState"', 'id="pipelineReport"', 'id="suppressionState"',
+ 'id="providerPolicyPanel"', 'id="scoreRulesPanel"', 'id="scoreDistributionPanel"'
+]));
+check('dom.safety-markers', 'safety and approval sections have stable smoke markers', [
+ 'saved-views', 'review-queue', 'crm-pipeline', 'crm-interactions', 'crm-reports',
+ 'suppression-center', 'outreach-provider-policy', 'score-rules', 'score-distribution'
+].every(marker => markers.has(marker)));
+check('dom.required-controls', 'operator controls have stable IDs', [
+ 'loginEmail', 'loginPassword', 'logoutBtn', 'nextPageBtn', 'bulkVerifyBtn', 'bulkRejectBtn',
+ 'savedFilterForm', 'pipelineViewToggle', 'interactionForm', 'suppressionForm', 'outreachPolicyRefreshBtn'
+].every(id => htmlIds.has(id)) && has(js, [
+ 'scanWebsiteBtn', 'extractContactsBtn', 'recalculateScoreBtn', 'generateAiSuggestionBtn', 'createOutreachDraftBtn'
+]));
+
+check('css.responsive', 'responsive CSS is present for mobile operator layouts',
+ /@media\s*\(max-width\s*:\s*700px\)/.test(css) &&
+ has(css, ['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel']));
+check('css.layout-contracts', 'critical layout selectors are defined', has(css, [
+ '.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.pipeline-board',
+ '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row'
+]));
+
+check('safety.no-send-copy', 'persistent no-send and approval safety copy is present', has(`${html}\n${js}`, [
+ 'No outreach from this workspace.', 'Suppression always wins.', 'Sending is disabled by default.',
+ 'Approval does not send a message.', 'no outreach will be sent', 'send:false',
+ 'autonomous_action:false'
+]));
+check('safety.no-send-controls', 'static HTML exposes no send or delivery button',
+ ![...html.matchAll(/]*>([\s\S]*?)<\/button>/gi)]
+ .some(match => /\b(send|deliver|campaign)\b/i.test(match[1])));
+check('safety.no-secrets', 'frontend source has no obvious hardcoded secrets',
+ !/(api[_-]?key|secret|token|password|private[_-]?key|authorization)\s*[:=]\s*["'][^"']+/i.test(
+ `${html}\n${js}\n${files['config.js']}`
+ ));
+check('safety.approval-gated', 'approval is explicitly human-confirmed and non-delivering',
+ has(js, ['window.confirm', 'human_approval:true', 'send:false', 'Approval does not send a message.']));
+
+const routes = [
+ '/api/v1/auth/me', '/api/v1/auth/login', '/api/v1/auth/logout', '/api/v1/businesses',
+ '/api/v1/review-queue', '/api/v1/saved-filters', '/api/v1/businesses/bulk-review',
+ '/api/v1/jobs', '/api/v1/sources', '/api/v1/source-records', '/api/v1/discovery-queries',
+ '/api/v1/merge-history', '/api/v1/scoring/summary', '/api/v1/score-rules',
+ '/api/v1/pipeline-entries', '/api/v1/interactions', '/api/v1/reports/pipeline',
+ '/api/v1/reports/outcomes', '/api/v1/reports/activity', '/api/v1/suppressions',
+ '/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config'
+];
+check('routes.contracts', 'operator API route contracts are referenced by the client',
+ routes.every(route => js.includes(route)), routes.filter(route => !js.includes(route)).join(', '));
+check('routes.authenticated', 'API requests use cookie credentials',
+ js.includes("credentials:'include'") && js.includes('jsonRequest'));
+check('routes.no-target-fetch', 'client does not directly fetch arbitrary target URLs',
+ !/fetch\s*\(\s*(?:url|target|website|href)\b/i.test(js));
+
+const linkedAssets = new Set(['index.html', 'config.js', 'app.js', 'styles.css', 'health.html', 'error.html', 'healthz', 'smoke-test.html']);
+for (const match of html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)) {
+ const asset = match[1];
+ if (!asset.startsWith('http') && !asset.startsWith('data:')) linkedAssets.add(asset.replace(/^\.\//, ''));
+}
+check('assets.local', 'all linked static assets exist and are non-empty', [...linkedAssets].every(asset => files[asset]?.length > 0),
+ [...linkedAssets].filter(asset => !files[asset]?.length).join(', '));
+
+async function verifyHttp() {
+ for (const asset of [...linkedAssets].sort()) {
+ const url = `${baseUrl}/${asset}`;
+ try {
+ const response = await fetch(url);
+ const body = await response.text();
+ const contentType = response.headers.get('content-type') || '';
+ const expectedType = asset.endsWith('.html') ? 'text/html' : asset.endsWith('.css') ? 'text/css' : asset.endsWith('.js') ? 'javascript' : null;
+ const pass = response.ok && body.length > 0 && (!expectedType || contentType.includes(expectedType));
+ check(`http.${asset}`, `HTTP delivery: ${asset}`, pass,
+ pass ? `${response.status} ${contentType}` : `${response.status} ${contentType || 'missing content-type'}`);
+ } catch (error) {
+ check(`http.${asset}`, `HTTP delivery: ${asset}`, false, error.message);
+ }
+ }
+}
+await verifyHttp();
+
+const failed = results.filter(result => !result.pass);
+const report = {
+ schema: 1,
+ harness: 'prospectos-frontend-pilot-smoke',
+ base_url: baseUrl,
+ chromium_required: false,
+ pass: failed.length === 0,
+ totals: { checks: results.length, passed: results.length - failed.length, failed: failed.length },
+ checks: results
+};
+console.log(JSON.stringify(report, null, 2));
+if (failed.length) process.exitCode = 1;
diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md
new file mode 100644
index 0000000..9e251b7
--- /dev/null
+++ b/docs/BENCHMARKS.md
@@ -0,0 +1,105 @@
+# Phase 16 pilot benchmark methodology
+
+Phase 16 defines a **pilot evaluation protocol**, not a production performance or quality claim. It evaluates the existing deterministic classification/scoring behavior and any explicitly named pilot path against labeled synthetic fixtures. It does not authorize live discovery, enrichment, acquisition, verification, or outreach, and it does not replace security, privacy, legal, or operational review.
+
+## Scope and evaluation questions
+
+The benchmark must answer only these bounded questions:
+
+1. Given a fixed synthetic input and versioned configuration, does the classifier or scorer produce the expected class, score, band, eligibility, explanation, and uncertainty state?
+2. How often do pilot outputs agree with the fixture labels under the declared policy?
+3. What are the cold-cache and warm-cache latency distributions under a documented local workload?
+4. Are results reproducible across repeated runs, processes, and supported Compose rebuilds?
+
+A benchmark result is valid only for the fixture release, code revision, dependency/image digests, configuration fingerprint, host profile, and measurement procedure recorded with it. Do not extrapolate it to production traffic, real businesses, real people, live websites, external providers, or a different infrastructure profile.
+
+## Labeled synthetic fixtures
+
+Fixtures are synthetic and must contain no live personal data, credentials, provider responses, or uncontrolled network dependencies. Each fixture has a stable ID, schema version, input payload, expected label(s), expected uncertainty/suppression state where applicable, and a short rationale. Keep fixture inputs separate from expected outputs so the evaluator cannot silently derive labels from the implementation.
+
+The fixture set should include balanced positive and negative examples plus deliberately difficult cases:
+
+- positive, negative, unknown, blocked, partial, timeout, and error observations;
+- boundary values around each score/band threshold;
+- stale, conflicting, missing, suppressed, malformed, and duplicate inputs;
+- adversarial-looking but harmless text, placeholder/example values, and irrelevant fields;
+- repeated records and cacheable/non-cacheable cases;
+- tenant-separation cases proving that identifiers, evidence, and cached results do not cross organizations.
+
+Publish the fixture manifest and label policy with each benchmark run. Labels describe the expected outcome under the stated policy; they are not assertions that a real-world fact is true.
+
+## Classification metrics and limitations
+
+For a declared positive class, report a confusion matrix (`TP`, `FP`, `TN`, `FN`) and:
+
+- **precision** = `TP / (TP + FP)`;
+- **recall** = `TP / (TP + FN)`;
+- support (number of labeled positive and negative fixtures);
+- unknown/blocked/partial/error rates separately from positive/negative rates;
+- per-class metrics when there is more than one class.
+
+Use `N/A`, not zero, when a denominator is zero. Report micro/macro averaging choices for multiclass results and list the exact threshold, tie-breaking, abstention, and suppression rules used. A high precision or recall on synthetic fixtures does not demonstrate identity, ownership, consent, deliverability, safety, generalization, fairness, or production usefulness. Synthetic labels may be simpler, cleaner, or less representative than real inputs; class balance, fixture leakage, threshold tuning, and evaluator assumptions can materially change the result. Precision/recall also do not measure latency, calibration, operational failure modes, or tenant isolation.
+
+## Reproducible classification and scoring
+
+Every run records:
+
+- repository commit and dirty/clean state;
+- fixture manifest ID/hash and label-policy version;
+- classifier/scorer algorithm and rule-set versions;
+- Python/runtime and dependency versions plus Compose/image digests when Compose is used;
+- explicit environment/configuration values, excluding secrets;
+- random seed, locale, timezone, rounding mode, numeric precision, and tie-breaking rules;
+- run command, host/CPU/RAM profile, start/end timestamps, and raw machine-readable results.
+
+Run each fixture at least twice in fresh processes and compare serialized outputs, not only aggregate metrics. A reproducibility check passes only when class, score, band, eligibility, uncertainty reasons, explanation factors, and version metadata are identical. If a timestamp is intentionally dynamic, compare it through an explicitly documented normalization rule; never hide nondeterminism by rounding away output differences. Preserve failed and incomplete runs.
+
+## Latency and cache measurements
+
+Measure a fixed, documented workload with warm-up excluded from the reported sample. Record request/fixture ID, outcome, cache state (`cold`, `warm`, `bypass`, or `not_applicable`), start/end monotonic timestamps, duration, and failure/timeout reason. Report count, minimum, median, p95, p99, maximum, and timeout/error rate for cold and warm paths separately; do not report only an average. Use the same host, process model, concurrency, payload sizes, timeout budgets, and fixture order for comparable runs. State whether serialization, database access, container startup, and network calls are included. The default pilot benchmark is local and network-free; any network-backed experiment must be separately approved and named.
+
+A cache hit must be labeled as a cache hit and must not be treated as a fresh observation. Record cache key policy, policy/scanner/rule versions, freshness/expiry, invalidation behavior, and hit/miss counts. Compare cold and warm measurements only when the cache contents and invalidation procedure are recorded. Do not infer production capacity, tail latency, cost, or availability from this pilot sample.
+
+## Pilot acceptance thresholds
+
+These are release gates for the **synthetic pilot evaluation only**; they are not service-level objectives or production claims:
+
+| Gate | Pilot threshold | Required evidence |
+| --- | --- | --- |
+| Reproducibility | 100% byte-for-byte equality for deterministic output fields across repeated fresh-process runs | Per-fixture comparison report and manifest/config/version metadata |
+| Positive classification precision | `>= 0.90` when positive support is at least 20; otherwise report `N/A` and do not pass the quality gate | Confusion matrix, support, threshold, and fixture manifest |
+| Positive classification recall | `>= 0.85` when positive support is at least 20; otherwise report `N/A` and do not pass the quality gate | Confusion matrix, support, threshold, and fixture manifest |
+| Safety abstention | 100% of labeled suppressed, blocked, malformed, or explicitly unknown cases remain non-eligible/non-positive as applicable | Per-fixture safety assertions and failure list |
+| Tenant isolation | 100% of cross-tenant access/cache probes are denied or absent without revealing existence | Readback/probe evidence with tenant IDs redacted as needed |
+| Latency reporting | 100% of samples have monotonic duration and cache-state labels; no missing/error samples are discarded | Raw sample file and summary statistics |
+
+A failed or `N/A` gate is a pilot finding, not permission to tune labels or silently remove fixtures. Threshold changes require a new methodology/version and a fresh run. No benchmark may be signed off if fixtures, expected labels, failed samples, or configuration metadata are missing.
+
+## Human review sampling
+
+Automated metrics are supplemented by blinded human review. Select a reproducible random sample using the recorded seed, stratified at minimum by predicted class, uncertainty state, suppression state, and disagreement (`FP`/`FN`) status. Reviewers must see the bounded synthetic input and output explanation, but not the implementation label or aggregate result while labeling. Use at least 20 fixtures or 10% of the fixture set, whichever is larger, capped at the declared review budget; if fewer fixtures exist, review all and mark statistical power as limited.
+
+Use two independent reviewers for the disagreement stratum and a random subset of the agreement stratum. Record reviewer IDs as pseudonymous codes, fixture ID, decision, reason code, time, and adjudication outcome. Do not turn reviewer agreement into a production accuracy claim. Disagreements, ambiguous labels, and unsafe outputs are findings requiring documented adjudication or fixture-policy revision before acceptance.
+
+## Reporting and sign-off
+
+The report includes methodology version, scope, fixture manifest/hash, run metadata, metrics, per-class support, raw latency/cache summaries, reproducibility result, human-review sample and adjudication, failed gates, limitations, and sign-off owner/date. Store reports with the pilot artifacts, not in application data or logs containing sensitive values.
+
+Phase 16 is complete only when the report is reproducible locally, the acceptance gates are evaluated honestly, and all limitations are recorded. It does **not** mean the system is production-ready, scientifically validated, or safe for live prospect decisions.
+
+## Deployment and remote-push prerequisites remain unresolved
+
+Phase 16 documentation does not deploy the application or push Git history. Remote publication remains blocked until repository authentication and the intended remote/branch permissions are available. Deployment remains blocked until an operator supplies and verifies the Phase 15 prerequisites: reviewed remote revision, Docker/Compose host access, protected deployment directory, secrets, DNS/TLS/Virtualmin configuration, off-host encrypted backup destination, monitoring, rollback owner, and explicit operational approval. A local benchmark pass, local Compose validation, or local commit must not be reported as a remote push or production deployment.
+
+## Suggested local evidence commands
+
+From the repository root:
+
+```sh
+python3 -m unittest discover -v -s apps/api/tests -t apps/api
+python3 -m compileall -q apps/api apps/web
+docker compose config --quiet
+git diff --check
+```
+
+Record command output and exit status with the benchmark report. These commands validate the local artifact; they do not provide production evidence or resolve the remote push/deployment prerequisites.
diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md
index 9287a6d..25ebf15 100644
--- a/docs/OPERATIONS.md
+++ b/docs/OPERATIONS.md
@@ -251,3 +251,19 @@ There is currently no standalone migration or backup CLI. `schema.sql` is applie
### Explicit limitations
This repository does not provision Virtualmin/TLS/DNS, provide a dependency-aware readiness service beyond the API's SQLite check, durable migration runner, PITR, HA database, durable queue/worker leases, production egress proxy, or compliance-grade retention service. SQLite and the in-process worker are pilot-only. The release has no outbound provider/send path and must remain outbound-disabled by default. See `docs/RELEASE_CHECKLIST.md` for the short go/no-go gate.
+
+## Phase 16 pilot benchmark operations
+
+Run the Phase 16 benchmark only against the versioned, labeled synthetic fixture manifest described in `docs/BENCHMARKS.md`. Confirm that fixtures contain no live personal data, secrets, uncontrolled network responses, or production records. Keep expected labels separate from generated outputs, and record the manifest/hash, label-policy version, commit, clean/dirty state, algorithm and rule-set versions, runtime/dependency/image digests, non-secret configuration fingerprint, seed, locale/timezone, rounding/tie-breaking, host profile, command, and timestamps.
+
+For each deterministic classifier/scorer, run every fixture at least twice in fresh processes. Compare class, score, band, eligibility, uncertainty reasons, explanation factors, and version metadata; any unexplained difference is a reproducibility failure. Report confusion matrices, support, precision and recall with zero-denominator `N/A`, plus unknown/blocked/partial/error and suppression outcomes separately. Do not tune thresholds, remove hard fixtures, or convert `N/A` into a pass after seeing results without publishing a new methodology version.
+
+Measure latency after a documented warm-up, using monotonic timestamps and a fixed local, network-free workload unless separate approval exists. Separate cold, warm, bypass, and not-applicable cache states. Record every sample, including errors/timeouts, and report count, minimum, median, p95, p99, maximum, timeout/error rate, cache hits/misses, freshness/expiry, and invalidation behavior. State whether startup, serialization, database, and network time are included. Never present a cache hit as a fresh observation or infer production capacity/SLOs from pilot timings.
+
+Before sign-off, evaluate the synthetic-only gates: 100% deterministic output equality; precision `>=0.90` and recall `>=0.85` only when positive support is at least 20; 100% safety preservation for suppressed, blocked, malformed, and explicitly unknown fixtures; 100% cross-tenant denial/non-disclosure probes; and complete latency sample/cache labels. Conduct blinded, seeded human review stratified by predicted class, uncertainty/suppression state, and FP/FN disagreement, reviewing at least 20 fixtures or 10% (whichever is larger, subject to the declared cap). Use pseudonymous reviewer IDs, reason codes, independent review for disagreements, and documented adjudication. A passed pilot gate is not production evidence.
+
+Store the report and raw machine-readable samples with the pilot artifacts, including failed/incomplete runs and limitations. Do not place sensitive fixture values in routine logs. Phase 16 does not enable a worker, live source, or outreach path.
+
+### Unresolved publication and deployment prerequisites
+
+Do not report local benchmark or Compose success as deployment. Remote push remains blocked until repository authentication and intended remote/branch permission are supplied. Production deployment remains blocked until the Phase 15 operator prerequisites are verified: reviewed remote revision, Docker/Compose host access, protected deployment directory, secret injection, DNS/TLS/Virtualmin, encrypted off-host backup and restore evidence, monitoring, rollback owner, and operational approval. Record the blocker in the release record and keep the current outbound-disabled configuration.
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index 7d03754..328f5f0 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -174,3 +174,15 @@ Report vulnerabilities privately to the repository maintainers; do not include l
- **Monitoring and incident evidence:** alert on unhealthy containers, restart loops, disk/volume pressure, backup age/failure, restore-test failure, TLS expiry, authentication/authorization failures, migration errors, unexpected egress, and log redaction failures. Monitoring must not collect secrets or full contact payloads. Preserve redacted logs, audit records, image/config digests, and affected-tenant scope during incidents.
These controls describe deployment prerequisites and gates; they do not make SQLite, password fallback, HTTP local Compose, in-process workers, or the public liveness checks production-grade. Remaining gaps must be accepted explicitly or closed before production.
+
+## Phase 16 pilot benchmark security and evaluation controls
+
+- Benchmark only with labeled synthetic fixtures. Fixtures must exclude live personal/contact data, credentials, provider responses, uncontrolled network content, and production records. Give each fixture a stable ID, schema/label-policy version, expected class/safety state, rationale, and manifest hash; keep expected labels separate from implementation outputs to reduce leakage and silent relabeling.
+- Treat precision/recall as conditional evaluation metrics, not truth or authorization. Report `TP`, `FP`, `TN`, `FN`, support, formula, class averaging, threshold/tie-breaking/abstention rules, and `N/A` for zero denominators. Report unknown, blocked, partial, timeout, error, stale, conflicting, and suppressed states separately. Synthetic metrics cannot establish identity, ownership, consent, deliverability, fairness, generalization, security, or production performance.
+- Make classification/scoring reproducible from the commit, fixture hash, algorithm/rule-set versions, runtime/dependency/image digests, non-secret config fingerprint, seed, locale/timezone, rounding, tie-breaking, and host profile. Run fresh processes at least twice and compare deterministic class/score/band/eligibility/explanation/version fields exactly. Preserve failed and incomplete runs; do not tune away difficult fixtures or nondeterminism.
+- Measure latency with monotonic timestamps and include errors/timeouts. Report cold and warm cache distributions separately (count, min, median, p95, p99, max, error/timeout rate), cache key/version/freshness/expiry, hit/miss counts, and invalidation behavior. A cache hit is not a fresh security or business observation. Do not infer production SLOs, capacity, availability, or cost from local pilot measurements.
+- Use the synthetic-only acceptance gates from `docs/BENCHMARKS.md`: deterministic equality 100%; precision `>=0.90` and recall `>=0.85` only with positive support >=20; suppressed/blocked/malformed/unknown safety cases remain non-eligible/non-positive as applicable 100%; tenant-isolation probes deny or conceal cross-tenant access 100%; and no latency sample lacks a duration/cache-state label. These gates do not authorize production use.
+- Add blinded human review with a recorded seed and stratification by predicted class, uncertainty/suppression, and disagreement. Review at least 20 fixtures or 10% (whichever is larger, subject to a declared cap); use pseudonymous reviewer IDs, reason codes, independent review of disagreements, and documented adjudication. Human agreement and adjudication are pilot findings, not production accuracy claims.
+- Store raw samples and reports with minimum necessary data and access controls. Redact secrets, full contact values, and unnecessary fixture content from logs and audit records. Benchmark artifacts must not become an implicit source, evidence record, eligibility decision, cache authority, or outreach input.
+
+Phase 16 does not resolve publication or deployment security gates. Remote push remains blocked pending repository authentication and intended remote/branch permission. Production remains blocked pending the Phase 15 reviewed revision, host and Docker/Compose access, protected deployment directory, secret management, DNS/TLS/Virtualmin perimeter, encrypted off-host backup/restore evidence, monitoring, rollback ownership, and explicit operational approval. A local benchmark, Compose config pass, or local commit is not a remote publication or production deployment.
diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md
new file mode 100644
index 0000000..103cef1
--- /dev/null
+++ b/docs/benchmarks/README.md
@@ -0,0 +1,19 @@
+# Phase 16 pilot benchmark
+
+Run from the repository root:
+
+```bash
+python3 scripts/benchmark_phase16.py
+```
+
+The command is offline-only and writes `docs/benchmarks/phase16.latest.json`. Use
+`--no-latency` when comparing deterministic result sections in CI. The committed
+fixture is `apps/api/fixtures/phase16.json`; it is intentionally synthetic and
+small. The JSON report repeats its limitations and acceptance thresholds and is
+validated structurally by `phase16.schema.json`.
+
+Acceptance thresholds are normalization accuracy 100%; fuzzy-match precision and
+recall at least 90%; contact precision and recall at least 85%; website fixture
+accuracy at least 90%; reproducible scoring; and zero tenant leakage. Timing and
+cache ratios are diagnostic pilot measurements, not production capacity claims.
+No benchmark performs network outreach or website fetching.
diff --git a/docs/benchmarks/phase16.latest.json b/docs/benchmarks/phase16.latest.json
new file mode 100644
index 0000000..4748e54
--- /dev/null
+++ b/docs/benchmarks/phase16.latest.json
@@ -0,0 +1,136 @@
+{
+ "acceptance_thresholds": {
+ "contact_precision": 0.85,
+ "contact_recall": 0.85,
+ "matching_precision": 0.9,
+ "matching_recall": 0.9,
+ "normalization_accuracy": 1.0,
+ "score_reproducible": true,
+ "tenant_leakage": 0,
+ "website_accuracy": 0.9
+ },
+ "benchmark": "phase16",
+ "checks": {
+ "contact_precision": true,
+ "contact_recall": true,
+ "matching_precision": true,
+ "matching_recall": true,
+ "normalization_accuracy": true,
+ "score_reproducible": true,
+ "tenant_leakage": true,
+ "website_accuracy": true
+ },
+ "fixture": "apps/api/fixtures/phase16.json",
+ "limitations": [
+ "Synthetic, small, English-heavy cases are not representative of production traffic.",
+ "Contact labels cover parser false positives but do not establish consent, identity, or deliverability.",
+ "Website fixtures classify supplied HTML only; they do not measure network, DNS, redirects, or adversarial pages.",
+ "Latency and cache measurements are local pilot signals and vary by host load."
+ ],
+ "offline": true,
+ "passed": true,
+ "results": {
+ "cache": {
+ "enabled": true,
+ "timing_omitted": true
+ },
+ "contacts": {
+ "cases": [
+ {
+ "expected_count": 2,
+ "false_positives": [],
+ "name": "public contacts and ignored markup",
+ "predicted_count": 2
+ },
+ {
+ "expected_count": 1,
+ "false_positives": [],
+ "name": "forms are provenance not email destinations",
+ "predicted_count": 1
+ }
+ ],
+ "false_negative": 0,
+ "false_positive": 0,
+ "precision": 1.0,
+ "recall": 1.0,
+ "true_positive": 3
+ },
+ "latency": {},
+ "matching": {
+ "cases": [
+ {
+ "expected_ids": [
+ 1
+ ],
+ "name": "shared exact domain is a positive",
+ "predicted_ids": [
+ 1
+ ]
+ },
+ {
+ "expected_ids": [
+ 4
+ ],
+ "name": "similar name without corroboration is not a positive",
+ "predicted_ids": [
+ 4
+ ]
+ },
+ {
+ "expected_ids": [],
+ "name": "unrelated businesses are negatives",
+ "predicted_ids": []
+ }
+ ],
+ "false_negative": 0,
+ "false_positive": 0,
+ "false_positive_cases": 0,
+ "precision": 1.0,
+ "recall": 1.0,
+ "true_positive": 2
+ },
+ "normalization": {
+ "accuracy": 1.0,
+ "correct": 2,
+ "total": 2
+ },
+ "scoring": {
+ "fixture_scores": [
+ {
+ "eligible": true,
+ "name": "complete active business",
+ "score": 80
+ },
+ {
+ "eligible": false,
+ "name": "suppressed remains ineligible",
+ "score": 15
+ },
+ {
+ "eligible": true,
+ "name": "stale evidence does not create positive",
+ "score": 15
+ }
+ ],
+ "reproducible": true
+ },
+ "tenant_isolation": {
+ "isolated": true,
+ "leakage": 0,
+ "tenant_a_visible_ids": [
+ 1,
+ 2
+ ],
+ "tenant_b_ids": [
+ 3,
+ 4
+ ]
+ },
+ "website_classification": {
+ "accuracy": 1.0,
+ "correct": 6,
+ "total": 6
+ }
+ },
+ "version": 1
+}
diff --git a/docs/benchmarks/phase16.schema.json b/docs/benchmarks/phase16.schema.json
new file mode 100644
index 0000000..4720356
--- /dev/null
+++ b/docs/benchmarks/phase16.schema.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Prospect Platform Phase 16 benchmark report",
+ "type": "object",
+ "required": ["benchmark", "version", "offline", "limitations", "acceptance_thresholds", "results", "checks", "passed"],
+ "properties": {
+ "benchmark": {"const": "phase16"},
+ "version": {"type": "integer", "const": 1},
+ "offline": {"const": true},
+ "limitations": {"type": "array", "items": {"type": "string"}},
+ "acceptance_thresholds": {"type": "object"},
+ "results": {"type": "object"},
+ "checks": {"type": "object"},
+ "passed": {"type": "boolean"}
+ }
+}
diff --git a/scripts/benchmark_phase16.py b/scripts/benchmark_phase16.py
new file mode 100644
index 0000000..eacd816
--- /dev/null
+++ b/scripts/benchmark_phase16.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+"""Offline, deterministic pilot benchmark for Phase 16.
+
+The benchmark never performs network I/O. It evaluates the Phase 6/9/8/10 pure
+functions against a versioned synthetic fixture and writes JSON suitable for CI
+or trend collection.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import statistics
+import sys
+import time
+from functools import lru_cache
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+API = ROOT / "apps" / "api"
+FIXTURE_PATH = API / "fixtures" / "phase16.json"
+DEFAULT_REPORT = ROOT / "docs" / "benchmarks" / "phase16.latest.json"
+if str(API) not in sys.path:
+ sys.path.insert(0, str(API))
+
+from app.contact_extractor import extract_contacts
+from app.domain import match_businesses, normalize_business
+from app.scoring import DEFAULT_RULES, evaluate_score
+from app.website_scanner import classify_website
+
+ACCEPTANCE_THRESHOLDS = {
+ "normalization_accuracy": 1.0,
+ "matching_precision": 0.90,
+ "matching_recall": 0.90,
+ "contact_precision": 0.85,
+ "contact_recall": 0.85,
+ "website_accuracy": 0.90,
+ "score_reproducible": True,
+ "tenant_leakage": 0,
+}
+
+
+def _pr(predicted: set, expected: set) -> dict:
+ tp = len(predicted & expected)
+ fp = len(predicted - expected)
+ fn = len(expected - predicted)
+ return {"true_positive": tp, "false_positive": fp, "false_negative": fn,
+ "precision": round(tp / (tp + fp), 4) if tp + fp else (1.0 if not expected else 0.0),
+ "recall": round(tp / (tp + fn), 4) if tp + fn else 1.0}
+
+
+def _timed(callable_, iterations: int = 1000) -> dict:
+ samples = []
+ for _ in range(iterations):
+ start = time.perf_counter_ns()
+ callable_()
+ samples.append((time.perf_counter_ns() - start) / 1_000_000)
+ ordered = sorted(samples)
+ return {"iterations": iterations, "median_ms": round(statistics.median(samples), 4),
+ "p95_ms": round(ordered[max(0, int(iterations * 0.95) - 1)], 4),
+ "max_ms": round(max(samples), 4)}
+
+
+def run_benchmark(fixtures: dict | None = None, *, measure_latency: bool = True) -> dict:
+ data = fixtures or json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))
+ normalization_correct = 0
+ for case in data.get("normalization", []):
+ actual = normalize_business(case["input"])
+ fields_ok = all(actual.get(field) == expected for field, expected in case["expected"].items())
+ normalization_correct += int(fields_ok)
+ normalization_total = len(data.get("normalization", []))
+
+ matching_predicted, matching_expected = set(), set()
+ matching_false_positive = 0
+ matching_cases = []
+ for case in data["matching"]:
+ expected = set(case["expected_ids"])
+ predicted = {item["id"] for item in match_businesses(case["source"], case["candidates"], threshold=case["threshold"])}
+ matching_predicted |= predicted
+ matching_expected |= expected
+ matching_false_positive += len(predicted - expected)
+ matching_cases.append({"name": case["name"], "predicted_ids": sorted(predicted), "expected_ids": sorted(expected)})
+ matching = _pr(matching_predicted, matching_expected)
+ matching["cases"] = matching_cases
+ matching["false_positive_cases"] = matching_false_positive
+
+ contact_predicted, contact_expected = set(), set()
+ contact_cases = []
+ for case in data["contacts"]:
+ predicted = {(item["kind"], item["value"]) for item in extract_contacts(case["html"], case["source_url"])}
+ expected = {tuple(item) for item in case["expected"]}
+ contact_predicted |= predicted
+ contact_expected |= expected
+ contact_cases.append({"name": case["name"], "predicted_count": len(predicted), "expected_count": len(expected), "false_positives": sorted([list(x) for x in predicted - expected])})
+ contacts = _pr(contact_predicted, contact_expected)
+ contacts["cases"] = contact_cases
+
+ website_correct = sum(classify_website(c["status"], c["url"], c["body"], error=c.get("error")) == c["expected"] for c in data["websites"])
+ website_total = len(data["websites"])
+ website = {"correct": website_correct, "total": website_total, "accuracy": round(website_correct / website_total, 4) if website_total else 1.0}
+
+ score_case = data["scoring"][0]
+ first_score = evaluate_score(score_case["signals"], DEFAULT_RULES)
+ score_reproducible = all(evaluate_score(c["signals"], DEFAULT_RULES) == evaluate_score(c["signals"], DEFAULT_RULES) for c in data["scoring"])
+ scoring = {"reproducible": score_reproducible, "fixture_scores": [{"name": c["name"], "score": evaluate_score(c["signals"], DEFAULT_RULES)["score"], "eligible": evaluate_score(c["signals"], DEFAULT_RULES)["eligible"]} for c in data["scoring"]]}
+ if measure_latency:
+ scoring["latency"] = _timed(lambda: evaluate_score(score_case["signals"], DEFAULT_RULES))
+
+ @lru_cache(maxsize=128)
+ def cached_score(payload: str) -> dict:
+ return evaluate_score(json.loads(payload), DEFAULT_RULES)
+ cache_payload = json.dumps(score_case["signals"], sort_keys=True, separators=(",", ":"))
+ cached_score.cache_clear()
+ if measure_latency:
+ cold = _timed(lambda: (cached_score.cache_clear(), cached_score(cache_payload)), 100)
+ warm = _timed(lambda: cached_score(cache_payload), 1000)
+ cache = {"enabled": True, "cold": cold, "warm": warm, "speedup": round(cold["median_ms"] / warm["median_ms"], 4) if warm["median_ms"] else None}
+ else:
+ cache = {"enabled": True, "timing_omitted": True}
+
+ tenant_a = set(data["tenant_isolation"]["tenant_a"]["business_ids"])
+ tenant_b = set(data["tenant_isolation"]["tenant_b"]["business_ids"])
+ # This is the same allow-list operation required before a tenant query.
+ visible_to_a = [ident for ident in sorted(tenant_a | tenant_b) if ident in tenant_a]
+ leakage = len(set(visible_to_a) & tenant_b)
+ tenant = {"tenant_a_visible_ids": visible_to_a, "tenant_b_ids": sorted(tenant_b), "leakage": leakage, "isolated": leakage == 0}
+
+ latency = {}
+ if measure_latency:
+ latency = {
+ "normalization": _timed(lambda: normalize_business(data["normalization"][0]["input"])),
+ "matching": _timed(lambda: match_businesses(data["matching"][0]["source"], data["matching"][0]["candidates"], threshold=data["matching"][0]["threshold"])),
+ "contact_extraction": _timed(lambda: extract_contacts(data["contacts"][0]["html"], data["contacts"][0]["source_url"])),
+ "website_classification": _timed(lambda: classify_website(data["websites"][0]["status"], data["websites"][0]["url"], data["websites"][0]["body"])),
+ }
+
+ results = {
+ "normalization": {"correct": normalization_correct, "total": normalization_total, "accuracy": round(normalization_correct / normalization_total, 4) if normalization_total else 1.0},
+ "matching": matching, "contacts": contacts, "website_classification": website,
+ "scoring": scoring, "cache": cache, "tenant_isolation": tenant, "latency": latency,
+ }
+ checks = {
+ "normalization_accuracy": results["normalization"]["accuracy"] >= ACCEPTANCE_THRESHOLDS["normalization_accuracy"],
+ "matching_precision": matching["precision"] >= ACCEPTANCE_THRESHOLDS["matching_precision"],
+ "matching_recall": matching["recall"] >= ACCEPTANCE_THRESHOLDS["matching_recall"],
+ "contact_precision": contacts["precision"] >= ACCEPTANCE_THRESHOLDS["contact_precision"],
+ "contact_recall": contacts["recall"] >= ACCEPTANCE_THRESHOLDS["contact_recall"],
+ "website_accuracy": website["accuracy"] >= ACCEPTANCE_THRESHOLDS["website_accuracy"],
+ "score_reproducible": scoring["reproducible"], "tenant_leakage": leakage == 0,
+ }
+ return {"benchmark": "phase16", "version": 1, "fixture": str(FIXTURE_PATH.relative_to(ROOT)),
+ "offline": True, "limitations": data["limitations"], "acceptance_thresholds": ACCEPTANCE_THRESHOLDS,
+ "results": results, "checks": checks, "passed": all(checks.values())}
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--output", type=Path, default=DEFAULT_REPORT)
+ parser.add_argument("--no-latency", action="store_true", help="omit variable timing measurements")
+ args = parser.parse_args()
+ report = run_benchmark(measure_latency=not args.no_latency)
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ print(json.dumps(report, indent=2, sort_keys=True))
+ return 0 if report["passed"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())