Module: Benchmark Scoring Engine (Phase 1)
The crown jewel. One score, computed once, drives sorting in Outreach, Connect call prioritization, and Shift assignment. It must be ours, and it must be versioned.
Responsibilities
- Compute a normalized 0–100 benchmark for a target from its enrichment signals.
- Store a breakdown (per-dimension contribution) so we can show why a target scored high.
- Be versioned — re-scoring never silently rewrites history; supports A/B of weightings.
- Be pluggable — weightings configurable per campaign/product without code changes.
The 5 weighted dimensions
The benchmark is a weighted score across five dimensions:
- Segment fit — industry / branch, legal form.
- Size & hiring pressure — headcount, hiring intensity, hiring trend.
- Growth & dynamics — location expansion, org growth, activity.
- Marketing / sales intent — paid-ads activity, employer-brand signal.
- Contactability & data quality — contact coverage, channel quality, confidence.
Inputs (enrichment signals feeding the dimensions)
- Paid Meta ads present (Meta Ad Library) → marketing/sales intent.
- Website age / staleness (Wayback Machine) → growth & dynamics, data quality.
- Hiring activity (jobsuche.api.bund.dev, TheirStack) → size & hiring pressure.
- Branch, size proxy, founded date, company-register data (handelsregister.ai) → segment fit, growth.
- Presence/quality of website & contacts, confidence of each fact → contactability & data quality.
- (Extensible — a new signal is a new weighted input, not a rewrite.)
Design
- Implemented as a benchmark service in the domain layer; weightings are data
(a
benchmark_versions.weightsJSON row), not code branches. - Output:
score(0–100) +breakdown(JSON: dimension → contribution). - Versioning: each score row carries
benchmark_version_id. Thetargetstable denormalizes the latestbenchmark_score+benchmark_version_id+benchmark_breakdownfor fast sorting; full history lives inbenchmark_scores. - Re-scoring a target under a new version adds a score row and updates the denormalized fields; historical rows are immutable (append-only, never mutated).
Computation & refresh policy
- Computed once per target at the end of research — no automatic refresh.
- The only re-research trigger is a stale target (research data > 60 days old) being assigned to a campaign; that re-research produces fresh signals and a new score version, leaving the prior version's history intact.
Where the score flows
- Outreach: the email sequence is ordered by benchmark (highest first).
- Connect: on shift activation, the top-N unworked targets by benchmark are locked to the shift's packages.
- Closing: the benchmark travels with the target as context in the pipeline.
- Control / BI: score distribution + yield-by-score-band analytics.
Key services
ScoreTarget— run the active benchmark configuration for a target, write abenchmark_scoresrow, update the denormalized fields ontargets.RescoreCampaign— re-run scoring for all targets under a chosen version (A/B or weight change), writing new versioned rows.
Acceptance
- A target with known signals produces a deterministic 0–100 score + breakdown across the 5 dimensions.
- Changing weights (new version) produces a new score row without mutating the old one.
- The target list sorts by benchmark; the breakdown explains the score.
- Tests cover scoring determinism, versioning immutability, and weight configurability (Jest/Vitest), with no live API calls.
Open questions
- ⚠️ Needs client sign-off: initial dimension weights — seed from the domain experts' 15+ years of experience.
- Per-campaign vs. per-product weight overrides — confirm granularity with the product owner.