TAM scoring audit and distribution fix
Use this skill when a client's account scoring model piles everyone at the same score and the agency needs to audit it against the data, simulate revised rubrics and recommend bands, weights and multipliers that make accounts stack-rank, with a DQ cutoff that means something. Works on a Clay or spreadsheet TAM export plus its rubric. Also use for "rescore this list", "fix the scoring", "why is everyone scoring the same", "distribution is compressed", "twin-firm test".
Garrett WolfeFounder, OneGTMScoring Analysis & Distribution Skill
End-to-end workflow for auditing a scoring rubric, simulating revisions on the actual data, and recommending changes that widen the spread so accounts stack-rank usefully. Output is a single boss-forwardable doc set plus a before/after comparison.
Before you start: read references/scoring-tables-primer.md for the full conceptual model (mechanics, levers, architectures, Client A worked example). Everything in this SKILL.md is the enforcement layer on top of that primer.
The non-negotiable workflow order
Do these phases in this order. Skipping or reordering creates rework.
-
Phase 0: Architecture decision (FIRST, before any band design). Ask the client one question: "In your scoring tool, does DQ stop enrichment from running on a row?"
- Yes (Clay default) → use pure-positive + smart Hard DQ architecture. This is the default for any Clay GTM engagement.
- No → ask if they'll tolerate cross-axis formula columns. If yes, use with-multipliers. If no, use pure-card with negatives.
This decision determines which levers you can pull. Don't propose bands or weights until this is settled.
-
Phase 1: Profile + bug-audit (BEFORE touching weights). Coverage per column. Distribution stats on existing Final %. All 4 bug detectors (default-when-null, normalization-cap, band-gap, empty enrichment). A single bug fix often moves stdev more than any weight change.
-
Phase 2: Apply levers within the chosen architecture. See lever table below. Pull only the ones available to your architecture.
-
Phase 3: Simulate side-by-side. Original vs proposed, on the same row pool. Report stdev, histogram, twin-firm spread per axis, tier counts.
-
Phase 4: Validate against the 6 gates. Fail any gate → iterate within Phase 2.
-
Phase 5: Write up. Boss-forwardable. TL;DR table at top. Paste-ready bands for the client's tool.
Input contract
The skill expects three things from the client (or the working directory):
| Input | Required | Form |
|---|---|---|
| Accounts database | yes | CSV / Clay export / DB query: one row per company, all enrichment columns + current scoring columns |
| Per-criterion scores | yes | Already-computed score columns per row (the *_Score, *_Golden, Grand Total, Final % columns Clay produces, or equivalent) |
| Scoring rubric definition | yes | The bands per card + per-criterion weight + Grand Total formula. Either documented (table) or inferable from the score columns + screenshots |
| Client context | recommended | What they sell, ICP definition, named good-fit firms |
| DQ threshold target | recommended | The Final % below which they want to disqualify (e.g. 40%). Default: pick from histogram. |
Output contract
The skill produces these artifacts in Clients/<Client>/scoring/analysis/:
| File | Contents |
|---|---|
RECOMMENDATIONS.md | TL;DR table (original vs proposed stdev + twin-firm spread + DQ-threshold check), list of bugs found, full revised rubric (bands + weights), 4-lever rationale |
simulate_v[N].py | Per-row simulation script |
scored_v[N].csv | Per-row score + breakdown columns for the revised model |
summary_v[N].txt | Histogram, percentiles, top-K, twin-firm spread, tier counts |
display_score_lookup.txt | GT → Display Score mapping table + paste-ready Clay IF formula (only when bell curve normalization is applied) |
(optional) COST_PLAN.md | Only if new enrichment is required to close real data gaps |
Every recommendation must include:
- Proposed new distribution (stdev + histogram shape + twin-firm spread)
- Quantified improvement over the original
- The new bands/scores/weights/multiplier formulas in copy-paste form for the client's tool (Clay scoring cards or formula columns)
- Impact on the Final % for a small set of named firms (top 5, bottom 5, 3 known-good targets)
- If bell curve normalization is applied: the GT → Display Score lookup formula, the Display Score histogram, and the recalibration trigger criteria
The two metrics that matter
| Metric | What it measures | Target depends on architecture |
|---|---|---|
| stdev of Final % (within DQ=False pool) | Overall spread across the list | Pure-positive: ≥ 0.20 / Pure-card w/negs: ≥ 0.27 / With-multipliers: ≥ 0.35 |
| Twin-firm spread | Δ between two identical firms differing only in axis X. Run on every weighted axis. | ≥ 10 pts on the top 3 axes regardless of architecture |
Optimize both. stdev alone can be flattered by a long penalty tail without giving you adjacent-row resolution. Twin-firm spread alone can be flattered by one outsized axis weight that compresses the rest.
The stdev target scales with architecture choice: don't compare a pure-positive 0.22 to a with-multipliers 0.37 and call it failure. Compare to the right baseline.
The five levers that actually widen distribution
These are the ONLY five levers. Anything else is a permutation of them.
| Lever | What it does | Free in Clay? | Pure-card OK? |
|---|---|---|---|
| 1. More bands per card | Increases resolution within an axis. A 3-band card can emit 3 values; a 9-band card can emit 9. Required prerequisite for any weight adjustment to matter. | Yes | Yes |
| 2. Balance weights across axes | Flatter weight distribution → more independent contributors summing → bell-shaped histogram. Don't pile weight onto correlated axes: it creates 5%-of-list pile-ups. | Yes | Yes |
| 3. Deep negative band values within cards | A card with bands going from −25 to +22 has more variance than one going 0 to 22. The negative tail extends Grand Total downward and the misfit pile separates cleanly from the candidate pile. In pure-card architecture, this is the primary spread driver. | Yes | Yes |
| 4. Widen Fit_Multiplier swing (cross-axis) | Compounds top-tier fit. Range 0.4-1.8 stretches the top of the histogram. Only works if client allows a separate Multiplier formula column. | Yes (one formula column) | No: breaks the architecture |
| 5. Deepen Negative_Penalty as separate column (cross-axis) | Like Lever 3 but cross-axis. Catches misfits that span multiple axes (e.g. DTC consumer brands via name regex). | Yes (one formula column) | No |
What does NOT widen distribution (common traps)
| Tempting move | Why it fails |
|---|---|
| Push more weight into the top axes (Industry/Reps/PE/etc.) | Those axes correlate: firms hitting all of them already top out together. More weight just creates pile-ups at the joint maximum. Tested: pushed Industry 22→28, stdev moved +0.007 but largest bucket grew from 3.15% to 5.93%. |
| Raise a card's raw max from 1 to 10 without adding bands | Mathematically identical to original: Golden = score/max → same 0-1 output. Resolution is set by band COUNT, not max value. |
| Add weight to an axis with low coverage | If only 5% of rows have data for axis X, bumping its weight from 5 to 15 only differentiates that 5%. Doesn't widen the overall histogram. |
| Add weight to an axis derived from another already-weighted axis (e.g. Fragmentation as Industry lookup, Rev/Rep as Revenue ÷ Reps) | Double-counts the same signal. Keep redundant-but-useful axes as tie-breakers (weight 2-5). |
Architecture choice: three options, asked first
Ask the client which architecture they want before designing bands. The choice is operational, not mathematical:
| Architecture | What you can use | What you give up | Realistic stdev gain |
|---|---|---|---|
| Pure-positive + smart Hard DQ (RECOMMENDED for Clay-based GTM teams where DQ stops enrichment) | All scores ≥ 0. Expanded Hard DQ catches objectively-wrong-fit categories. Soft-fit firms get low positive scores. | Negative scores. Cross-axis multiplier. | +15% to +35% over baseline (Client A v10d est: 0.178 → ~0.22) |
| Pure-card with negatives | Each card max = weight. Negative bands within cards for misfit categories. No multipliers. Grand Total can go negative. | Cross-axis compounding. Cross-axis name regex. Operator must accept negative scores in the UI. | +30% to +60% over baseline (Client A v10c: 0.178 → 0.279) |
| With-multipliers | Per-card weights + Fit_Multiplier formula column + Completeness_Multiplier + Negative_Penalty column. Most aggressive. | 3 new formula columns. Most complex Grand Total formula. Cross-axis multiplier is harder to explain to a non-technical reviewer. | +50% to +110% over baseline (Client A v9: 0.178 → 0.367) |
The deciding question: does DQ stop enrichment in the client's setup?
If yes (Clay default behavior): pure-positive + smart Hard DQ is correct. Negative scores feel like "soft-DQ" but actually keep the row in the enrichment loop. That's not what DQ semantically means in Clay. Use Hard DQ for "objectively wrong, never spend another credit," score-low-positive for "weak fit, keep enriching, might surface later via signals."
If no (custom workflow where DQ is just a filter for the active outbound list, not a stop-enrichment flag): pure-card with negatives gives more stdev.
How to decide which categories belong in Hard DQ vs score-low-positive
- Hard DQ: the firm's industry/type is fundamentally wrong (software company, bank, school, restaurant). No future enrichment refinement makes them a fit.
- Score-low-positive: the firm might be misclassified, weak-fit, or have wrong data that better enrichment could refine. Keep in pool with score 0-2.
Within whichever architecture you pick, push every lever
To maximize stdev within the chosen architecture:
- Wide band gaps between tiers (e.g. Tier 1 = 22, Tier 2 = 16, Tier 3 = 8: 6+ pt gaps do the discrimination)
- Tight bullseyes (e.g. Field Reps peak only at 60-89, with sharp taper outside)
- Intra-card tie-breakers when an axis has a clear primary signal + secondary modifier (e.g. PE = 10 base for any PE firm + 0-4 bonus from Fundraised $ tier; max 14)
- Pure-card with negatives only: deep negative bands (e.g. Industry "Other" = −25)
- Pure-positive only: expanded Hard DQ list catches the misfits; soft-fit categories score 0-2
Operational tradeoff to surface in the writeup
The architecture choice has real operating consequences:
| Architecture | Enrichment cost trajectory | Stack-rank cleanness |
|---|---|---|
| Pure-positive + smart Hard DQ | Lower (no enrichment on wrong-fit) | Cleanest (0-100% range, no negatives to explain) |
| Pure-card with negatives | Higher (everything gets enriched, even -25-scoring rows) | Negative scores in UI |
| With-multipliers | Highest + 3 formula columns to maintain | Multiplier values in UI; harder to QA |
Clay mechanics
Scoring cards hold the per-criterion bands; formula columns (Clayscript) hold DQ logic, Grand Total, Display Score and tiers. Never write a scoring card as a formula, and always wrap null guards in parentheses: ({{Col}} || 0) >= 50. Card syntax, the paste-ready documentation format and the Clayscript precedence trap are in references/clay-scoring-cards.md.
Process
Phase 0: Workspace
Create Clients/<Client>/scoring/analysis/. List the phases as tasks: profile → audit bugs → simulate → validate → write up.
Phase 1: Profile coverage and existing distribution
Use references/profile_data.py adapted to the client's columns. Compute:
- Per-column coverage (% non-null)
- Distribution stats on Final %: mean, median, stdev, P10/25/50/75/90/95/99
- Histogram (0.05-pct bins)
- Largest 0.01-pct bucket (the pile-up metric)
- Per-axis Golden column: min, max, mode, mode share
Phase 1.1: Bug-hunt the existing scoring (always run all 4)
- Default-when-null detector: for each derived score column, compare its nonzero rate to the coverage of its source columns. If derived nonzero >> source coverage, a default-when-null bug is feeding constant values to most rows.
- Normalization-cap check (for each
*_Goldencolumn, find the observed max. If it caps well below 1.0, the denominator is larger than realistically achievable raw max) the top of that axis is unreachable. - Band-gap detector: inspect keyword bands (
1-5, 11-25, 26-50, ...). Firms whose raw value falls in a gap silently score 0. - Empty enrichment check: any column referenced in the rubric but <2% populated is dead weight.
Bug fixes are Phase 0: execute these before iterating on weights. A single default-when-null bug often does more damage to the distribution than any weight imbalance. See references/common_pitfalls.md for worked examples.
Phase 2: Translate the rubric and apply the 4 levers
For each card and each weight, decide which lever applies:
| Symptom from Phase 1 | Lever | Concrete fix |
|---|---|---|
| Card has ≤4 bands but covers wide value range | 1 (bands) | Add bands; log-shape for right-tailed numerics |
| Two firms identical except axis X differ by <5 pts | 2 (weights) | Bump axis X weight (only if uncorrelated with top axes) |
| Largest 0.01-pct bucket > 5% | 2 (weights) | Trim top-axis weight; spread to mid-tier axes |
| Top-K firms tied at the cap | 3 (multiplier) | Widen Fit_Multiplier swing; remove cap on Final % |
| DQ rows pile at exactly 0 (clamp) | 4 (clamp) | Drop MAX(0, ...) on Grand Total |
| Bad-fit firms scoring same as low-data correct-fit | 4 (penalty) | Add Negative_Penalty formula column; make penalties ≥-50 for hard misfits |
| Histogram is bimodal or step-shaped | 2 (weights) | More balanced weights → smoother curve |
| Many firms at exactly the median | sourcing | Re-check the underlying TAM filter; rubric can't fix sourcing gaps |
See references/distribution_techniques.md for the formulas behind each lever. See references/levers.md for the empirical results from past engagements showing which lever produced which delta.
Phase 3: Simulate the revised rubric
Use references/simulate_scoring.py adapted to the client's columns. Always run at least two versions side-by-side: v[N] (current state, baseline) and v[N+1] (proposed). Report:
- stdev (target ≥ 0.30)
- Median, P25, P75
- Histogram (5-pt bins on raw final pts, 0.05 bins on Final %)
- Largest 0.01-pct bucket (target ≤ 5%)
- Tier distribution (A/B/C/D/F)
- Twin-firm spread for the top 3 axes by weight (synthesize identical firms varying one axis)
- Top-15 and bottom-10 named lists
Phase 3a to 3c: Display Score, raw max and tiers
When raw stdev plateaus below target because of data coverage, add bell curve normalization as a display layer (mean 50, std 20) on top of the raw Grand Total, which stays the source of truth. A raw max of 85 to 95 is correct for a rubric with tight bullseyes; do not loosen them to reach 100. Tier on Display Score: 20 tiers at 5% intervals (T1 best), plus a coarse 5-tier column for reporting. Process, formulas and the recalibration trigger are in references/display-score-and-tiers.md.
Phase 4: Validate against gates
| Gate | Target | If fails |
|---|---|---|
| Raw stdev (DQ=False pool) | ≥ 0.15 (raw), or ≥ 0.20 (after bell curve) | Apply more aggressive levers or bell curve normalization |
| Largest 0.01-pct bucket | ≤ 5% | Weights too top-heavy: flatten across mid-tier axes |
| Twin-firm spread on top 3 axes | ≥ 10 pts each | Weight too low on the failing axis; bump it (only if uncorrelated) |
| DQ threshold separation | DQ rows median ≤ threshold − 10 pts; real candidates median ≥ threshold + 10 pts | Negative_Penalty too shallow; deepen |
| Top-K named good-fit firms | ≥ 80% appear in A or B tier | Sourcing gap OR specific bug: re-run Phase 1.1 |
| Histogram shape (Display Score) | Bell-shaped around 50; 20 tiers at 5% intervals with predictable populations (T20 ≈ 2.4%, T11 ≈ 9.2%, T1 ≈ 2.5%) | Rubric iteration or recalibrate bell curve lookup |
| Raw Grand Total max | ~85-95 (NOT 100) for a well-designed rubric. Don't loosen bullseyes to reach 100: that reduces discrimination. Use Display Score for the 0-100 UX. | If raw max < 80, bullseyes may be too tight. If raw max = 100 on many rows, bullseyes are too loose. |
| Rows at Display Score = 100 | ≤ 3% of non-DQ pool (the true top tail). | If >3% pile at exactly 100, suspect the inv_norm_cdf function has the operator-precedence bug (numerator polynomial not wrapped in outer parens: c[5] / denom parses as a separate term). See references/bell_curve.py for the correct implementation. |
| Rows at Display Score = 0 | ≤ 3% of non-DQ pool. | Same bug as above (tail branch broken). Or DQ list is letting too many true zero-fit rows through. |
Phase 5: Write up
RECOMMENDATIONS.md template:
# TL;DR: table comparing original vs proposed on:
# stdev | mean | median | largest 0.01-pct bucket | A-tier % | F-tier %
# twin-firm spread for top 3 axes
# DQ threshold (e.g. "all 5,362 F-tier firms below -10 pts, all real candidates above 25 pts")
# Bugs found in current model (Phase 1.1 output)
# Revised rubric (paste-ready bands + weights + formulas)
# 4-lever rationale: which lever was pulled, why, expected stdev delta
# Validation gate results
# Open questions (sourcing gaps, data-gap fixes that need budget: defer to COST_PLAN.md)
What good looks like
- The architecture question is asked first, and bugs are found and fixed before any weight changes.
- Baseline and proposed models always appear side by side, with the twin-firm test run on every axis weighted 5 or more.
- The proposal passes the gates: stdev at the target for its architecture, largest 0.01 bucket at 5% or less, twin-firm spread of 10 points or more on the top 3 axes.
- Red flag: pushing more weight into correlated top axes. It barely moves stdev and creates pile-ups at the joint maximum.
Key conventions
- Always run the twin-firm test. Synthesize one row from your top-15, clone it 8-10 times varying ONE axis, score all of them, report the spread between #1 and #10. Repeat for each axis with weight ≥ 5.
- Two versions side-by-side, minimum. Never report a proposed model without showing the baseline numbers next to it.
- Bugs before weights. A default-when-null bug or normalization-cap bug undoes any weight tuning. Find them first.
- Bands first, weights second, shape third. Number of bands sets the upper bound on resolution. Weights determine each axis's share. Multiplier/penalty stretch the top and bottom. Always in that order.
- The bottom matters as much as the top. Negative_Penalty + no-clamp on the Grand Total formula is responsible for ~half of any meaningful stdev improvement. Don't skip it.
- Boss-forwardable means one number at the top. TL;DR has the headline (stdev gain + twin-firm spread gain). Detail goes below.
- No emojis in docs.
When NOT to use this skill
- Building a scoring model from scratch with no existing data
- Pure outreach copy or enrichment with no scoring question
References
references/scoring-tables-primer.md: READ FIRST. Full conceptual model: mechanics, 6 levers, 3 architectures, validation gates, Client A empirical track (v4 through v10d). Everything in this SKILL.md is the enforcement layer on top of that primer.references/levers.md: empirical results table for all 6 levers + architectural decision flowchartreferences/distribution_techniques.md: formulas: log transforms, multiplicative gates, type-aware scoring, percentile normalization, completeness multipliersreferences/common_pitfalls.md: failure modes and their fixes
Templates
references/profile_data.py: coverage + distribution profiler + 4 bug detectors (run in Phase 1)references/simulate_scoring.py: simulation harness withARCHITECTUREtoggle, twin-firm test, DQ-threshold validation, side-by-side comparison report. Defaults to pure-positive architecture.references/bell_curve.py: Phase 3a Display Score normalization. Reads the scored CSV, builds the GT→Display lookup, emits paste-ready Clayscript IF formula. Includes built-in saturation gates (warns if >3% of rows pile at Display=100 or =0, which indicates the inv_norm_cdf operator-precedence bug).
references/bell_curve.py
""" Phase 3a: Bell curve normalization template.
Drop-in starter for the bell curve transform documented in SKILL.md Phase 3a.
Loads scored_v[N].csv (from simulate_scoring.py), computes percentile rank
per non-DQ row, maps to a normal distribution (mean=50, stdev=20), and emits:
- display_score_lookup.txt: paste-ready Clayscript IF formula for the Display Score column in Clay
- scored_v[N]_with_display.csv: per-row CSV with Display Score appended
What you adapt per client (the CONFIGURE block):
- SCORED_CSV: path to the simulate_scoring output
- GT_COLUMN: name of the Grand Total column in that CSV (e.g. 'v11_gt')
- DQ_COLUMN: name of the DQ flag column (e.g. 'v11_dq')
Usage: python3 bell_curve.py
Validation:
- Display Score stdev should hit ≥ 20 (the 0.20 target on a 0-100 scale)
- Display Score mean should be ~50
- Histogram should look bell-shaped (modest piles at 0 and 100 are OK - those are the very top and bottom percentiles clamped)
Recalibration trigger: Recompute whenever DQ criteria, card bands, or card weights change. Quarterly sanity recalibration even if no structural changes: new enrichment data may have shifted the GT distribution. """ import csv, math, os, statistics, sys from collections import Counter, defaultdict
csv.field_size_limit(sys.maxsize)
============================================================
CONFIGURE PER CLIENT
============================================================
HERE = os.path.dirname(os.path.abspath(file)) SCORED_CSV = os.path.join(HERE, 'scored_v[N].csv') # adapt per client GT_COLUMN = 'v[N]_gt' # adapt per client DQ_COLUMN = 'v[N]_dq' # adapt per client VERSION = 'N' # adapt per client
DISPLAY_SCALE: 100 → Display Score on 0-100 scale (mean=50, stdev=20)
1 → Display Score on 0-1 scale (mean=0.50, stdev=0.20)
Use 1 if Display Score is going into a Clay column with type=Percentage,
or if you want it to match Final Score Percentage convention.
Use 100 if Display Score is going into a Clay column with type=Number
and you want literal 0-100 values shown.
DISPLAY_SCALE = 1
============================================================
INVERSE NORMAL CDF (Beasley-Springer-Moro approximation)
IMPORTANT: This is the CORRECT implementation. A previous version had an
operator-precedence bug where c[5] / denominator was parsed as a separate
term, causing the tail branches to return garbage for percentiles outside
[0.025, 0.975]. That bug made the top/bottom ~2.5% of rows saturate at
Display = 100 or 0. Numerators are now wrapped in outer parens.
============================================================
def inv_norm_cdf(p): """Inverse normal CDF. Returns z such that Phi(z) = p.""" if p <= 0 or p >= 1: return -8 if p <= 0 else 8 a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00] b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, 6.680131188771972e+01, -1.328068155288572e+01] c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00] d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00] plow = 0.02425 phigh = 1 - plow if p < plow: q = math.sqrt(-2 * math.log(p)) num = (((((c[0]*q + c[1])*q + c[2])*q + c[3])*q + c[4])*q + c[5]) den = ((((d[0]*q + d[1])*q + d[2])*q + d[3])*q + 1) return num / den if p <= phigh: q = p - 0.5 r = q * q num = ((((((a[0]*r + a[1])*r + a[2])*r + a[3])*r + a[4])*r + a[5]) * q) den = (((((b[0]*r + b[1])*r + b[2])*r + b[3])*r + b[4])*r + 1) return num / den q = math.sqrt(-2 * math.log(1 - p)) num = (((((c[0]*q + c[1])*q + c[2])*q + c[3])*q + c[4])*q + c[5]) den = ((((d[0]*q + d[1])*q + d[2])*q + d[3])*q + 1) return -num / den
def main(): # 1. Load non-DQ Grand Total values gts = [] with open(SCORED_CSV) as f: r = csv.DictReader(f) for row in r: if (row.get(DQ_COLUMN) or '').lower() in ('true', '1'): continue try: gts.append(float(row.get(GT_COLUMN))) except (TypeError, ValueError): continue n = len(gts) print(f"Loaded {n} non-DQ rows from {SCORED_CSV}", file=sys.stderr) print(f"Raw GT: mean={statistics.mean(gts):.2f} stdev={statistics.stdev(gts):.2f} " f"min={min(gts):.1f} max={max(gts):.1f}")
# 2. Compute midrank per unique GT (handles ties)
gt_counts = Counter(gts)
gt_avg_rank = {}
cum = 0
for g in sorted(gt_counts):
c = gt_counts[g]
gt_avg_rank[g] = (cum + c / 2) / n
cum += c
# 3. Map each unique GT → Display via inv_norm_cdf
# On 0-100 scale: mean=50, stdev=20
# On 0-1 scale: mean=0.50, stdev=0.20
mean = 0.50 * DISPLAY_SCALE
stdev = 0.20 * DISPLAY_SCALE
cap = 1.0 * DISPLAY_SCALE
digits = 3 if DISPLAY_SCALE == 1 else 1
gt_to_display = {}
for g, r in gt_avg_rank.items():
eps = 0.5 / n
rc = min(1 - eps, max(eps, r))
z = inv_norm_cdf(rc)
display = max(0, min(cap, mean + stdev * z))
gt_to_display[g] = round(display, digits)
# 4. Validation: sanity check (thresholds adapt to DISPLAY_SCALE)
display_per_row = [gt_to_display[g] for g in gts]
d_mean = statistics.mean(display_per_row)
d_stdev = statistics.stdev(display_per_row)
near_max = 0.995 * DISPLAY_SCALE
near_min = 0.005 * DISPLAY_SCALE
d_max_count = sum(1 for x in display_per_row if x >= near_max)
d_min_count = sum(1 for x in display_per_row if x <= near_min)
print(f"\nDisplay Score (scale 0-{DISPLAY_SCALE}): mean={d_mean:.3f} stdev={d_stdev:.3f}")
print(f" rows at Display ≥ {near_max}: {d_max_count} ({100*d_max_count/n:.2f}%)")
print(f" rows at Display ≤ {near_min}: {d_min_count} ({100*d_min_count/n:.2f}%)")
print()
# SANITY GATES (per SKILL.md Phase 3a):
expected_mean = 0.50 * DISPLAY_SCALE
expected_stdev_floor = 0.18 * DISPLAY_SCALE
if 100 * d_max_count / n > 3:
print(f"WARNING: {100*d_max_count/n:.1f}% of rows at Display = max. "
f"Suspect inv_norm_cdf precedence bug. Check the function.",
file=sys.stderr)
if abs(d_mean - expected_mean) > 0.02 * DISPLAY_SCALE:
print(f"WARNING: Display mean = {d_mean:.3f}, expected ~{expected_mean}.",
file=sys.stderr)
if d_stdev < expected_stdev_floor:
print(f"WARNING: Display stdev = {d_stdev:.3f}, target ≥ {0.20 * DISPLAY_SCALE}.",
file=sys.stderr)
# Histogram
bh = Counter()
for x in display_per_row:
bh[round(x / 5) * 5] += 1
scale = max(1, max(bh.values()) // 50) if bh else 1
print('Display Score histogram (5-pt bins):')
for k in sorted(bh):
bar = '#' * (bh[k] // scale)
print(f' {k:>3}: {bh[k]:>6} {bar}')
# 5. Write display_score_lookup.txt
lookup_lines = [
f'# Display Score Lookup: v{VERSION} (scale 0-{DISPLAY_SCALE})',
'',
f'Display Score output range: 0 to {DISPLAY_SCALE}. '
f'Mean ≈ {0.50 * DISPLAY_SCALE}, stdev ≈ {0.20 * DISPLAY_SCALE}.',
'',
'## Mapping table (Grand Total → Display Score)',
'',
'| Grand Total | Display Score | Row count |',
'|---:|---:|---:|',
]
d_fmt = '.3f' if DISPLAY_SCALE == 1 else '.1f'
for g in sorted(gt_to_display):
if gt_counts[g] >= 5:
lookup_lines.append(f'| {g:.1f} | {gt_to_display[g]:{d_fmt}} | {gt_counts[g]} |')
lookup_lines.append('')
lookup_lines.append('## Paste-ready Clayscript formula for `Display Score` column')
lookup_lines.append('Type: Number formula column.')
lookup_lines.append('')
lookup_lines.append('```')
# Build nested IF chain: round GTs to nearest 0.5 to consolidate
grouped = defaultdict(list)
for g, d in gt_to_display.items():
key = round(g * 2) / 2
grouped[key].append(d)
gt_to_display_rounded = {k: round(sum(vs)/len(vs), digits) for k, vs in grouped.items()}
sorted_keys = sorted(gt_to_display_rounded.keys())
expr = str(gt_to_display_rounded[sorted_keys[-1]])
for k in reversed(sorted_keys[:-1]):
d = gt_to_display_rounded[k]
expr = f'(({{{{Grand Total Score}}}} || 0) <= {k} ? {d} : {expr})'
lookup_lines.append(expr)
lookup_lines.append('```')
lookup_lines.append('')
lookup_lines.append('## Paste-ready Tier Grade formula (T1 = top, T20 = bottom)')
lookup_lines.append('Type: Text formula column.')
lookup_lines.append('')
lookup_lines.append('```')
if DISPLAY_SCALE == 1:
lookup_lines.append('"T" + (20 - Math.floor(Math.min(0.99, ({{Display Score}} || 0)) / 0.05))')
else:
lookup_lines.append('"T" + (20 - Math.floor(Math.min(99, ({{Display Score}} || 0)) / 5))')
lookup_lines.append('```')
lookup_lines.append('')
lookup_lines.append(f'Produces T1 (Display {0.95 * DISPLAY_SCALE}-{DISPLAY_SCALE}, best) '
f'through T20 (Display 0-{0.05 * DISPLAY_SCALE}, worst).')
lookup_lines.append('')
lookup_lines.append('Convention: T1 = top tier (best fit), T20 = bottom tier (worst fit).')
lookup_lines.append('If you prefer T20 = top, change `(20 - Math.floor(...))` to `(Math.floor(...) + 1)`.')
lookup_lines.append('')
lookup_lines.append('## Recalibration trigger')
lookup_lines.append('')
lookup_lines.append('Recompute the lookup whenever:')
lookup_lines.append('- DQ criteria change (Hard DQ logic, wrong-fit industry list)')
lookup_lines.append('- Card bands change (Keywords or Scores on any card)')
lookup_lines.append('- Card weights change (per-card max values)')
lookup_lines.append('- A scoring card is added or removed')
lookup_lines.append('')
lookup_lines.append('Quarterly sanity recalibration even without structural changes,')
lookup_lines.append('in case new enrichment data has shifted the GT distribution.')
lookup_lines.append('')
lookup_lines.append('## Validation')
lookup_lines.append('')
lookup_lines.append(f'Raw GT stdev (non-DQ pool, n={n}): {statistics.stdev(gts):.3f}')
lookup_lines.append(f'Display Score stdev: {d_stdev:.3f} ← target ≥ 20')
lookup_lines.append(f'Display Score mean: {d_mean:.2f} ← target ≈ 50')
lookup_lines.append(f'Rows at Display = 100 (true top tail): {d_max_count} ({100*d_max_count/n:.2f}%)')
out_path = os.path.join(HERE, 'display_score_lookup.txt')
with open(out_path, 'w') as f:
f.write('\n'.join(lookup_lines))
print(f'\nWrote {out_path}')
# 6. Write per-row CSV with Display Score column appended
out_csv = os.path.join(HERE, f'scored_v{VERSION}_with_display.csv')
with open(SCORED_CSV) as f_in, open(out_csv, 'w', newline='') as f_out:
r = csv.reader(f_in)
w = csv.writer(f_out)
header = next(r)
w.writerow(header + [f'v{VERSION}_display_score'])
gt_idx = header.index(GT_COLUMN)
for row in r:
try:
d = gt_to_display.get(float(row[gt_idx]), '')
except (ValueError, IndexError):
d = ''
w.writerow(row + [d])
print(f'Wrote {out_csv}')
if name == 'main': main()
references/clay-scoring-cards.md
Clay scoring cards vs formula columns
Clay has two distinct column types for scoring. Use the right one.
Scoring cards (band-based UI)
Used for: individual criterion scores (Headcount Score, FTE Score, etc.).
Clay's scoring card UI defines bands declaratively: no code. Each card has one or more Scoring Criteria, and their scores sum within the card.
Each scoring criteria has:
- Values to Score: the input column
- Comparison Type:
Contains (Text)orBetween (Number) - Keywords: comma-separated values/ranges to match
- Scores: comma-separated scores, one per keyword
Between (Number) rules:
- Every keyword must be a range, not a bare number. Use
0-0.9not0. - Open-ended upper bounds:
251-999999not251+. - Ranges are inclusive on both ends.
Contains (Text) rules:
- Keywords match if the cell value contains the keyword text (case-insensitive).
- First matching keyword wins. Order keywords from most-specific to least.
- Empty cells match no keyword and score 0 by default.
- Exhaustive keyword lists required. Any value not matching a keyword silently scores 0. This is especially dangerous for Country/Geography cards: if Germany isn't in the keyword list, all German firms score 0 with no error. Always validate coverage (% nonzero) after creating or editing a Contains (Text) card.
Multi-criteria cards:
A single scoring card can have multiple criteria (e.g., Domain Experience
has Criteria 1 on Domain Expertise Classification and Criteria 2 on
Service Count). The scores from all criteria in the same card sum.
Documenting scoring cards in RECOMMENDATIONS.md: Use this format for each card so the operator can paste directly into Clay:
> **Scoring Criteria 1**
> Values to Score: `Column Name`
> Comparison Type: Between (Number)
> Keywords: `0-0.9, 1-5, 6-10, 11-999999`
> Scores: `0, 3, 6, 10`
Formula columns (Clayscript)
Used for: DQ logic, Grand Total (sum of card scores), Display Score (bell curve lookup), Tier labels, and any cross-column computation.
Clayscript is JavaScript. Column references use {{Column Name}} syntax.
Guard against null with || 0 for numbers, || "" for strings.
Critical: Clay strips const/let/var declarations and inlines the
expression. This breaks operator precedence. Never write const gt = {{Col}} || 0; gt >= 50 ? ...: Clay will render it as {{Col}} || 0 >= 50
which parses as {{Col}} || (0 >= 50) due to >= binding tighter than ||.
Always wrap the null guard in parentheses: ({{Col}} || 0) >= 50 ? ...
Scoring cards should NEVER be written as Clayscript formulas. Clayscript is only for columns that need cross-column logic (sums, lookups, conditionals across multiple scoring card outputs).
references/common_pitfalls.md
Common Pitfalls: Scoring Model Audits
Compiled from past client engagements. Check each before declaring an audit complete.
Derived score bugs (Phase 1.1: most-common, highest-leverage)
Default-when-null on a "Contains Text" formula
Symptom: A derived score (e.g. Domain Experience Golden) is nonzero on 98%+ of rows, but its source column has data on a tiny fraction (5% or less).
Root cause: Clay-style scoring nodes with "Contains (Text)" comparisons silently match a default-bucket keyword (often "none") against empty cells, returning a non-zero score to every empty row. The result is a constant added to every Grand Total, squashing the distribution.
Detection: Compare nonzero_rate(derived) to max(coverage_rate(source_cols)). If derived nonzero is dramatically larger than source coverage, this bug is live. The DERIVED_SCORE_AUDIT config in profile_data.py automates this.
Fix: In the scoring formula, explicitly check for empty input and return 0. Don't rely on "Contains" semantics for the empty case.
Example: Domain Experience Golden was 0.4 (40% of max) on 30,242 of 30,671 rows because empty Domain Expertise Classification strings matched the "none" keyword. After fixing the bug, the median dropped from 18% to 13%: accurate distribution shape was hidden under a +1.5pt fixed offset on every row.
Normalization denominator larger than realistic max
Symptom: A *_Golden / *_Pct column maxes out at 0.5 or 0.6 across the entire dataset: no row ever hits 1.0.
Root cause: The percentage formula's denominator is larger than any combination of bands can actually reach. Top firms lose 40% of their credit on that axis automatically.
Detection: For every supposedly-0-to-1 column, check max(column). If < 0.95, the denominator is wrong.
Fix: Either reduce the denominator to match the realistic top, OR raise the top-tier raw band so it can hit the cap.
Example: Country Score Golden capped at 0.60 (raw scores 0-3, denominator 5). Global Presence Golden capped at 0.50 for the same reason. Both were silently bottlenecking top firms.
Band gap in keyword rubric
Symptom: A specific numeric range silently scores 0 because the rubric's bands skip it.
Root cause: Keyword bands like 1-5, 11-25, 26-50, ... miss 6-10 (typo/oversight at rubric setup). Firms with values in the gap fall through to the default 0.
Detection: Eyeball the band definitions for off-by-one gaps. For numeric inputs, verify for v in 1..max_observed: at least one band matches v.
Fix: Add the missing band.
Example: Specialist bands skipped 6-10. ~3.7% of firms with specialist data fell in the gap and scored 0 on their strongest signal.
Dead column referenced in rubric
Symptom: A scoring criterion references a column that's 0% populated across the entire dataset.
Root cause: The enrichment step that populates the column never ran, or got disconnected from the workflow.
Detection: profile_data.py auto-flags any column with <2% coverage. Cross-reference against the rubric: any rubric criterion sourced from a dead column is dead weight.
Fix: Either repair the enrichment step or remove the criterion from the rubric.
Example: Distinct Company Countries was an input to Global Presence Scoring but populated 0/30,671 rows. Half of Global Presence's signal was structurally unreachable.
Sourcing gap masquerading as scoring problem
Symptom: Top of new score list looks reasonable but is missing the obvious canonical leaders for the domain. Root cause: The source list (HubSpot, Apollo, manually-curated CSV) never had those entities to begin with. No scoring tweak can fix this. Detection: Maintain a per-domain "canonical top 20" list. Grep source CSV for each. Surface missing entities as sourcing issues, not scoring issues. Fix: Fix the source list before continuing to iterate on scoring. Wrong list + perfect rubric = wrong answer. Example: the top 20 by new score looked legitimate, but 20+ canonical leaders in the vertical were entirely missing from the 30K source. It needed a CRM list fix, not a rubric fix.
Mixed-population list with single rubric
Symptom: Two structurally different entity types in the same list (service firms + in-house teams), both clustering at low scores despite legitimate good-fit candidates in both.
Root cause: The rubric is built around one type's axes. The other type gets zeros on every type-specific axis. Adding a "default to average" papers over the issue but kills signal for both types.
Detection: Cross-tab score median by entity type / classification. If two populations have very different medians AND very different axis-coverage profiles, single-rubric scoring is mis-handling at least one of them.
Fix: Type-aware scoring with three axis groups (universal / type-A-only / type-B-only) and parallel rubrics. See references/distribution_techniques.md §3.
Example: Corporate / In-House (21K rows) clustered at median 11%; Service firms (1.5K rows) clustered at median 20%. Single rubric was scoring corporates on service-firm axes (practitioners, specialists) where they structurally have zero.
Data quality
"Global 51+ FTEs" list with median FTE of 7
Symptom: Source list claims a filter applied; actual data violates it. Root cause: The filter ran at source-pull time but enrichment overwrote the FTE column with a different (often wrong) value. Fix: Surface the discrepancy in the audit. Recommend re-applying the filter post-enrichment, with a carve-out for known-good exceptions (e.g., small specialist boutiques).
Small-entity matched to giant-corp via fuzzy LinkedIn match
Symptom: Row shows 79K FTEs at a one-person local firm. Root cause: LinkedIn-enrichment fuzzy-matched a small name to a big company with overlapping tokens . Fix: Entity-mismatch heuristic: when small-entity cue (LLC, P.C., "Offices of") + giant FTE + LinkedIn name diverges from source name, suppress derived fields.
Defunct entities still in the list
Symptom: Top of new score list contains firms that merged or dissolved years ago. Root cause: Source list never refreshed; CRM has stale records. Fix: Cross-reference a public list of defunct companies for the vertical against the name column. Filter or flag.
Inconsistent column sources
Symptom: FTE column says 1, Practitioners column says 1136 for the same firm. Root cause: FTE came from LinkedIn (which sometimes captures local-office headcount only), Practitioners came from people-search (which captured firm-wide). Fix: Document the source per column. Don't compare across columns without first asserting same-source.
Rubric design
Highest-weight criterion has lowest coverage
Symptom: Specialist Count weighted 28%, populated on 5% of rows. Root cause: Client designed the rubric around the ICP-defining dimension, but enrichment never filled it. Fix: Either spend to enrich (Phase 2 in the standard flow), or switch to proportional weighting so unenriched rows aren't all clustered at the floor.
Saturated criterion adds noise to the floor
Symptom: Country weighted 20% with 60% US + 35% other tier-1 → 95% of rows score 0.95+ on country. Root cause: Tier-1 includes ~80% of the list, so country adds the same constant to almost every row instead of differentiating. Fix: Cap the country weight; convert to a binary qualifier (in-region or not); or split into finer tiers (e.g., separate US from EU).
Bin boundaries on dense data
Symptom: Histogram of raw values shows median at 3 practitioners; rubric bin boundary is at 3 practitioners (1-3 → 0.2, 4-10 → 0.4). Root cause: Round-number bin design without inspecting the actual data distribution. Fix: Sigmoid centered on the data median, or move bin boundaries to natural breaks (10th, 25th, 50th, 75th, 90th percentile).
Contains (Text) keyword list incomplete: silent coverage drop
Symptom: A scoring card that had 98% coverage suddenly shows 48% nonzero after editing keywords. No error: companies just silently score 0.
Root cause: Clay's Contains (Text) comparison only matches values explicitly listed in the Keywords field. Any country, classification, or category not in the keyword list scores 0 with no warning. When switching from a broad keyword list to a tiered one (e.g., giving different countries different scores), it's easy to omit countries that were previously covered by a catch-all.
Detection: After any edit to a Contains (Text) card, immediately check the nonzero rate of the output score column. If it dropped significantly from the previous version, keywords are missing.
Fix: Build exhaustive keyword lists. For Country cards, include all countries present in the data: even low-value ones at a baseline score (e.g., 0.5). For Classification cards, include every distinct value from the column. Use a SELECT DISTINCT / Counter() on the source column to generate the complete list before configuring the card.
Example: Country Score card was edited to differentiate jurisdictions (US=3, UK=2.5, etc.). Germany (822 rows), India (3,832 rows), China (1,953 rows), and 20+ other countries were omitted from the keyword list. Coverage dropped from 98% to 48%: 15,579 companies silently scored 0 on Country. No Clay error surfaced.
Missing data → zero (the silent killer)
Symptom: Rows with sparse data all collapse to the same low score, indistinguishable from rows that genuinely score low. Root cause: "Missing = 0" treats unknown as bad, instead of as unknown. Fix: Proportional weighting. Re-normalize the score over the criteria that have data when row has ≥50% of total weight populated. This is the single highest-impact distribution fix on most TAMs.
Scoring fitness
Top of list contains non-ICP rows
Symptom: A US oil-and-gas conglomerate scored 0.76 in a "service firm TAM." Root cause: No fitness gate. The rubric scores anything that has the right enriched columns, regardless of whether it's actually the buyer type. Fix: Add a fitness gate keyed on industry / classification / description language. Zero out scores for rows that fail the gate. Be explicit about what "fit" means for this client.
Top-N validation never done
Symptom: Cost plan approved on the strength of distribution stats alone. Root cause: Easier to compute stdev than to eyeball top-25 against actual closed-won. Fix: Always include manual top-25 inspection in acceptance gates. If closed-won data exists, validate against it. If not, validate against the client's stated top-20 known wins as a proxy.
Time-sensitive vs. structural signals
Recent activity as time-decay
Symptom: Rubric has "days since last event" scored on recency. Root cause: Recency is a signal (good for outreach trigger timing), not a fit attribute (good for ICP scoring). Fix: Convert to structural: "≥1 event in last 5y" yes/no, or rolling-5-year count. Stable, time-invariant.
Job posting / news / fundraise in scoring
Symptom: "Posted a relevant role last month" → +0.5 score. Root cause: Confusing intent signals with ICP fit. The firm is the same firm regardless of last month's job posting. Fix: Keep intent signals for outreach trigger flows, never for the static scoring model. Flag this explicitly to the client.
Cost estimation
Padding the bill with outreach data
Symptom: Scoring cost plan includes Mobile Finder, Personal Email, work-email enrichment. Root cause: Conflating "score the list" with "reach the list." Fix: Hard rule: scoring cost plans contain only line items that improve a score or fix a data-quality issue poisoning scores. Phone/email/mobile go in a separate outreach cost plan, billed after the scored list is approved.
Claiming Clay charges credits for scoring logic
Symptom: Cost doc says "Clay charges X credits per row for bin lookups / IF-ELIF / dict scoring." Root cause: Conflating Clay enrichment actions (Find People, Use AI, HTTP, scrape) with formula columns. Fix: Clay credits burn only on enrichment actions. Formula columns and column references are free. Scoring rubric logic (bin lookups, dict tier maps, regex matches on present text, IF/SWITCH on FTE bands) costs $0 in Clay.
Re-enriching columns that are already populated
Symptom: Cost plan proposes Country enrichment at $X when source CSV has Country at 97% coverage. Root cause: Skipped the existing-vs-net-new inventory step. Defaulting to "rerun the whole workflow." Fix: Always build the three-bucket inventory before pricing anything. Every cost line must trace to a "net-new" or "repair" row.
Quoting prices from memory
Symptom: Cost doc cites prices that don't match current provider pricing. Root cause: Forgot to check the provider's current pricing for each cited tool. Fix: Re-confirm every price live. Pricing changes; quote-checking takes seconds.
No empirical pilot
Symptom: Cost plan claims $X but never ran a single live call. Root cause: Felt costly to spend $0.17 on a 10-row pilot. Fix: Always run the pilot. Even $0.17 grounded data is dramatically more persuasive to a budget approver than a $3K modeled estimate.
Pricing the full list when most of it will be filtered
Symptom: Cost plan prices 45K rows even though Phase 0 cleanup drops 75% of them. Root cause: Conflating "list size" with "enrichable rows." Fix: Always show two columns: full-list and cleaned-list. Recommend spending on cleaned only. Defer full-list spend behind a "Phase 4, conditional approval" line.
Doc quality
Tables stuffed with long prose cells
Symptom: Rubric table has bin definitions occupying 150 characters per cell, forcing uneven row heights. Root cause: Treating tables as the primary content holder. Fix: Tables hold short cell content. Long rules, descriptions, explanations go in bullet lists below the table. Pad columns with spaces so the source view is also readable.
Mixed analysis prose + code in one doc
Symptom: RECOMMENDATIONS.md has 200 lines of Python embedded between paragraphs.
Root cause: Pasted code into the doc instead of referencing a separate file.
Fix: Code lives in .py files. Docs reference them. Docs are for analysis prose, tables, and conclusions.
Headline buried below context
Symptom: Boss has to read 3 pages to find "approve $X to do Y." Root cause: Linear write-up from setup → analysis → recommendation. Fix: TL;DR at the top with the one number that matters. Then context below for those who want to read further.
Estimate presented as measured
Symptom: Cost doc says "the workflow will cost $X" without distinguishing modeled from empirically piloted.
Root cause: Sloppy phrasing.
Fix: Tag every cost line as (modeled), (per provider doc), or (empirical, N=10 pilot 2026-MM-DD).
references/display-score-and-tiers.md
Display Score, raw max and tiers
Phases 3a to 3c of the workflow.
Phase 3a: Bell curve normalization (when raw stdev hits ceiling)
When the raw rubric's stdev plateaus below target due to structural data-coverage limits (common when >30% of rubric weight sits on axes with <10% coverage), apply bell curve normalization as a display layer on top of the raw scores.
This does NOT replace rubric iteration: it runs AFTER the rubric is as good as the data allows. The raw Grand Total remains the source of truth for debugging.
Process:
- Export non-DQ pool's Grand Total values
- Compute percentile rank per row → inverse-normal CDF → scale to mean=50, std=20
- Average Display Score per GT value to build a lookup table
- Implement as a nested IF formula in Clay (see
references/distribution_techniques.md§5a) - Sync Display Score (not raw GT) to HubSpot as the rep-facing "Fit Score"
When to use: Raw stdev is 10-18 on 0-100 scale (0.10-0.18 on 0-1) and further rubric iteration yields diminishing returns (<+1 stdev per iteration). The bell curve transform lifts effective stdev to ~20 and centers the distribution at 50.
When NOT to use: Raw stdev is already ≥20, or the client needs absolute scores ("hit 70% of the rubric criteria") rather than relative ranking. In those cases, keep iterating the rubric.
Recalibration trigger: The GT → Display lookup must be recomputed whenever DQ criteria, card bands, or card weights change. A structural rubric change invalidates the mapping.
Phase 3b: Raw Grand Total max is typically 85-95, not 100 (this is correct)
A well-designed rubric with mutually-exclusive bullseyes (Field Reps 60-89, FTE 400-799, Revenue $1B-$5B, etc.) produces a structural raw max of ~85-95 because no real firm hits every card's bullseye simultaneously. This is intentional, not a bug.
For example: a 60-rep firm with $3B revenue would need ~50K FTE per rep (impossible). A 400-FTE firm with $3B revenue has $7.5M/employee (unrealistic for field-sales orgs). So the structural raw max for real firms is ~90.
Do not "fix" this by loosening bullseyes: that reduces discrimination and collapses the top tier. The right answer is:
- Use the Display Score (Phase 3a bell curve) as the 0-100 rep-facing metric
- Tier on Display Score, not raw Grand Total
- Treat raw Grand Total as a debugging metric only
If a client insists on raw 0-100 with no bell curve, use one of:
- A. Accept raw max ≈ 90 (most honest)
- B. Rescale to observed max:
Final % = (GT / observed_max) × 100. Warn that this makes scoring population-dependent (one new high-scoring firm shifts everyone). - C. Use a "perfect-fit firm" denominator: define an explicit max raw possible for the optimal firm (e.g. 92 in the Client A case) and divide by that. Honest but requires explaining the denominator to the operator.
Default to A + Display Score. B and C are escape hatches if the client refuses bell curve normalization.
Phase 3c: Tier scheme: 20 tiers at 5% intervals on Display Score
The recommended tiering for the rep-facing UX is 20 tiers at 5% intervals on the Display Score. This gives finer stack-rank granularity than the A/B/C/D/Low scheme while still being scannable.
Two valid scales for Display Score: pick one and match the Tier formula:
| Scale | When to use | Display values | Tier formula (T1 = best, T20 = worst) |
|---|---|---|---|
| 0-1 (recommended) | Display Score column type = Number, matches Final Score Percentage convention | 0.000 to 1.000 | "T" + (20 - Math.floor(Math.min(0.99, ({{Display Score}} || 0)) / 0.05)) |
| 0-100 | Display Score column type = Number, want literal integer-ish values | 0 to 100 | "T" + (20 - Math.floor(Math.min(99, ({{Display Score}} || 0)) / 5)) |
Both produce T1 (best) through T20 (worst) with identical population distributions. Pick the scale based on what reads naturally in the client's existing column conventions.
Tier direction convention: T1 = top (best fit), T20 = bottom (worst fit). This matches how people naturally read rankings ("we have 200 T1 accounts to call this week"). If a client prefers T20 = best, swap (20 - Math.floor(...)) for (Math.floor(...) + 1) in the Tier formula.
In references/bell_curve.py, set DISPLAY_SCALE = 1 for 0-1 output or DISPLAY_SCALE = 100 for 0-100. The lookup file generates both the Display Score formula AND the matching Tier formula automatically (defaulting to T1 = best).
Letter tiers (alternative): 15-tier A+/A/A- ... F-/F+ scheme:
Tier_Letter =
(({{Display Score}} || 0) >= 95) ? "A+" :
(({{Display Score}} || 0) >= 90) ? "A" :
(({{Display Score}} || 0) >= 85) ? "A-" :
(({{Display Score}} || 0) >= 80) ? "B+" :
(({{Display Score}} || 0) >= 75) ? "B" :
... (continue through F-)
Why 5% tiers over the old 5-tier scheme:
- 5 tiers (A/B/C/D/Low) hide a lot of stack-rank signal: 50% of the pool ends up in "C" or "D" with no further differentiation
- 20 tiers map cleanly to the bell-curve Display Score; each tier's expected population is predictable (T20 ≈ 2.4%, T11 ≈ 9.2%, T1 ≈ 2.5%)
- Operator can filter "T17 and up" or sort descending: both work without further thresholding
Old 5-tier scheme is still fine for coarse summaries (boss memos, "we have
3.4% A-tier accounts"). Keep both columns: a 20-tier Tier for filtering,
a 5-tier Tier_Coarse for reporting.
references/distribution_techniques.md
Distribution Techniques: Phase 2 default upgrades
Reference formulas, thresholds, and worked examples for the six structural upgrades
called out in SKILL.md Phase 2. Apply these before spending on net-new
enrichment: they're free distribution-shape wins on existing data.
Each section: what it does, when to apply, formula, worked example.
1. Log transforms on heavy-tailed numeric axes
What it does. Replaces fixed bands with a smooth log curve. Preserves rank order between firms in the same band that banding throws away.
When to apply. Any right-tailed numeric axis where P75 / P25 ratio > 4 or where a single band catches > 30% of rows. Examples: practitioner count, specialist count, public filings, FTEs, office count, headcount-by-role columns.
Formula.
score = min(max_pts, k * log10(value + 1))
Pick k so that the realistic top of the distribution hits max_pts. Quick rule:
k = max_pts / log10(p99_value + 1).
Worked example: specialist headcount (Client B).
Original bands: 1-5=2, 6-10=2.5, 11-25=3, 26-50=4, 51-100=5, 101-250=6, 251+=7.
Data: P50=0, P75=3, P99=55, max=1000.
Log replacement: score = min(7, 2 * log10(specialists + 1))
- 1 specialist → 0.6
- 5 → 1.6
- 10 → 2.1
- 25 → 2.8
- 50 → 3.4
- 100 → 4.0
- 250 → 4.8
- 1000 → 6.0
- 10000 → 7.0 (capped)
Compared to original bands, a firm with 7 specialists now scores 1.8 instead of sharing the same 2.5 with firms that have 10. Rank order within a 5x value range is recovered.
2. Multiplicative fit gates
What it does. Compounds advantages across signals so high-fit firms separate from the middle. Pure additive scoring is too forgiving: a firm with one strong axis ends up close to a firm with five medium axes.
When to apply. Whenever distribution has no clear elite tier (top 10 too close to top 100). Also when client wants the top 1-5% to visibly stand apart.
Formula.
Final = (sum of universal axis points) × Domain_Fit_Multiplier × Activity_Multiplier
Tunable multipliers. Calibrate to client. Example defaults for a service-firm TAM:
Domain_Fit_Multiplier:
has_domain_focus=true AND specialists ≥ 10: 1.5
has_domain_focus=true OR specialists ≥ 5: 1.2
neither: 0.8
Activity_Multiplier:
public_filings_last_12mo > 0 OR recent_activity < 365d: 1.3
otherwise: 1.0
Cap product at e.g. 2.0 so a single firm doesn't completely dominate.
Worked example. Two firms, both raw additive score = 15:
- Firm A: 12 specialists, domain focus = true, recent public filings → 15 × 1.5 × 1.3 = 29.25
- Firm B: 0 specialists, domain focus = false, no recent activity → 15 × 0.8 × 1.0 = 12.0
The 15-point tie becomes a 2.4× separation. Distribution stretches as a direct result.
3. Type-aware scoring for mixed populations
What it does. Handles lists with structurally different entity types (service firms
- in-house teams in the same TAM) without averaging signal away.
When to apply. When the same list has > 5% non-primary-type rows AND the client wants both segments scored on a comparable scale. Don't average-default a single rubric: it dilutes signal for both populations.
Pattern. Split axes into three groups; compute two parallel scores; each row gets its segment's score.
Axis taxonomy
| Group | Behavior in service-firm rubric | Behavior in corporate rubric |
|---|---|---|
| Universal (country, FTE, public filings, domain mentions, domain leadership, recent activity) | Score normally | Score normally |
| Service-firm-only (practitioner count, specialist count, classification, domain expertise, number of offices) | Score normally | Weight = 0 (don't penalize, don't credit) |
| Corporate-only (Filing-heavy industry tier, in-house team size, R&D-intensity proxy, filings-per-FTE) | Weight = 0 | Score normally |
Formula
Score_ServiceFirm = sum(universal_pts + service_firm_pts) / denominator_ServiceFirm
Score_Corporate = sum(universal_pts + corporate_pts) / denominator_Corporate
# Each row picks its segment based on classification
if classification in {Service Type A, Service Type B, Both}:
Final = Score_ServiceFirm
elif classification in {Corporate, Corporate / In-House}:
Final = Score_Corporate
else: # ambiguous
Final = max(Score_ServiceFirm, Score_Corporate)
Corporate-only axes: recommended starter bands
| Axis | Source | Bands |
|---|---|---|
| Filing-heavy industry | NAICS / industry classification | Core industries = 3 ; adjacent = 1 ; unrelated = 0 |
| Filings per FTE | (public_filings / fte) | top 10% = 3 ; top 25% = 2 ; top 50% = 1 ; else 0 |
| In-house team size | LinkedIn count of employees with the relevant role keywords in title | log10 transform, max 3 |
| Filing history size | Public Filings | log10 transform, max 3 |
| Public filings mention the domain | EDGAR full-text | boolean = +1 |
4. Data-completeness confidence multiplier
What it does. Prevents sparse-data rows from accidentally ranking high on partial info. A firm with only 3 of 15 fields enriched and scoring 0.7 on those 3 fields shouldn't beat a firm with 12 of 15 fields enriched scoring 0.6 on all.
Formula.
completeness = (# enrichable fields non-null) / (total enrichable fields)
confidence = 0.7 + 0.3 * completeness # ranges 0.7 to 1.0
Final_adjusted = Raw_Score * confidence
Tuning. The 0.7 floor and 0.3 weight are starting points. Tighten the floor (e.g., 0.5) if data sparseness is a known structural problem; loosen it if most rows are well-enriched and you just want a small bonus for completeness.
Don't apply to. Universal/structural axes that are expected to be 100% populated (country, classification). Apply only to enrichment-dependent axes.
5. Percentile-rank normalization within segments
What it does. Computes per-segment percentile rank and uses it as the score. Guarantees a uniform distribution per segment. Each segment's top 1% sits at 99%, each segment's bottom 1% at 1%: directly comparable across segments.
When to apply. When client wants a cleaner-looking headline distribution OR when the rubric's natural ceiling differs by segment (e.g. service firms can hit 90%, corporates cap at 60% structurally).
Formula.
for each segment S:
rank firms in S by Raw_Score
Percentile_within_S = rank / count(S)
# Blend with raw to keep absolute meaning
Final = 0.5 * Raw_Score_pct + 0.5 * Percentile_within_S
Trade-off. Pure percentile loses absolute meaning ("a 70%" no longer means "hit 70% of the rubric"). Blended preserves both. Always explain the choice to the client and label the column unambiguously.
5a. Bell curve normalization (inverse-normal CDF on percentile rank)
What it does. Maps the non-DQ pool's percentile rank through an inverse-normal CDF to produce a bell-curve-shaped 0-100 Display Score. Preserves the exact ranking from the raw rubric but distributes scores symmetrically around 50 with a standard deviation of ~20. Reps see intuitive "best = 95, worst = 5" scores without needing to understand the raw rubric's compressed scale.
When to apply. When the raw rubric produces correct rankings but the natural score ceiling is far below 100 (common when low-coverage axes account for a large share of the rubric weight). The raw scores compress into a narrow band (e.g., 0-50 on a 100-pt scale) and non-technical stakeholders interpret them as "everyone is bad" instead of "the scale is compressed." Bell curve normalization fixes the presentation without altering the ranking.
Architecture. This is a display-layer technique that sits ON TOP of the raw rubric. It does not replace the rubric: it transforms the raw Grand Total into a Display Score via a lookup formula. The raw Grand Total and per-card scores remain unchanged and are the source of truth for debugging and rubric iteration.
Formula.
# Step 1: Compute percentile rank within non-DQ pool
percentile = rank(Grand_Total) / count(non_DQ_rows)
# Step 2: Inverse normal CDF → z-score
z = norm.ppf(clamp(percentile, 0.0005, 0.9995))
# Step 3: Scale to mean=50, std=20, clamp to 1-99
Display_Score = clamp(round(50 + z * 20), 1, 99)
Clay implementation. The inverse-normal CDF cannot be computed natively in Clay. Instead, pre-compute the Grand Total → Display Score mapping table from the current data, then implement as a formula column (NOT a scoring card) using Clayscript chained ternary operators. Scoring cards use the Clay band UI; this is one of the few columns that requires Clayscript:
// Display Score: Clayscript formula column (type: Number)
// Pre-computed from inverse-normal CDF on the non-DQ pool
({{Grand Total Score}} || 0) >= 71 ? 99
: ({{Grand Total Score}} || 0) >= 66 ? 95
: ({{Grand Total Score}} || 0) >= 62 ? 90
: ({{Grand Total Score}} || 0) >= 58 ? 87
: ({{Grand Total Score}} || 0) >= 55 ? 84
: ({{Grand Total Score}} || 0) >= 51 ? 80
: ({{Grand Total Score}} || 0) >= 48 ? 77
: ({{Grand Total Score}} || 0) >= 46 ? 75
: ({{Grand Total Score}} || 0) >= 43 ? 71
: ({{Grand Total Score}} || 0) >= 40 ? 68
: ({{Grand Total Score}} || 0) >= 37 ? 65
: ({{Grand Total Score}} || 0) >= 35 ? 63
: ({{Grand Total Score}} || 0) >= 33 ? 61
: ({{Grand Total Score}} || 0) >= 30 ? 57
: ({{Grand Total Score}} || 0) >= 28 ? 55
: ({{Grand Total Score}} || 0) >= 25 ? 51
: ({{Grand Total Score}} || 0) >= 22 ? 47
: ({{Grand Total Score}} || 0) >= 19 ? 42
: ({{Grand Total Score}} || 0) >= 16 ? 38
: ({{Grand Total Score}} || 0) >= 13 ? 33
: ({{Grand Total Score}} || 0) >= 10 ? 26
: ({{Grand Total Score}} || 0) >= 8 ? 17
: ({{Grand Total Score}} || 0) >= 6 ? 5
: ({{Grand Total Score}} || 0) >= 3 ? 1
: 0
Note: Clayscript is JavaScript. Column references use {{Column Name}} syntax.
Guard against null with || 0. Check values in descending order so the first
match wins. Set the column data type to "Number" for sorting/filtering.
Recalibration. The GT → Display lookup must be recomputed whenever the DQ criteria, card bands, or card weights change materially. The mapping is derived from the current pool's distribution: a structural change to the rubric shifts the distribution and invalidates the lookup. Recalibration process:
- Export the non-DQ pool's Grand Total column
- Run the inverse-normal CDF transform (script in
references/simulate_scoring.py) - Average Display Score per GT value to produce the new lookup table
- Update the Clay IF formula
HubSpot sync. The Display Score is the value that syncs to HubSpot for reps. Label it unambiguously (e.g., "Acme Fit Score") so it's not confused with the raw Grand Total. The raw Grand Total and per-card scores stay in Clay for debugging.
Tier column (Clayscript, type: Text). Simple 5% score bands on the bell curve Display Score. The bell curve already spreads companies across 0-1, so even score bands produce a natural bell-shaped tier distribution: more companies in the middle tiers (T10/T11), fewer at the extremes (T01/T20). No recalibration needed when the pool changes: the bands are fixed.
// 20 tiers, 5% score bands. Input: bell curve Display Score (0-1).
({{Score Mapping}} || 0) >= 0.95 ? "T01 (95-100%)"
: ({{Score Mapping}} || 0) >= 0.90 ? "T02 (90-95%)"
: ({{Score Mapping}} || 0) >= 0.85 ? "T03 (85-90%)"
: ({{Score Mapping}} || 0) >= 0.80 ? "T04 (80-85%)"
: ({{Score Mapping}} || 0) >= 0.75 ? "T05 (75-80%)"
: ({{Score Mapping}} || 0) >= 0.70 ? "T06 (70-74%)"
: ({{Score Mapping}} || 0) >= 0.65 ? "T07 (65-69%)"
: ({{Score Mapping}} || 0) >= 0.60 ? "T08 (60-64%)"
: ({{Score Mapping}} || 0) >= 0.55 ? "T09 (55-59%)"
: ({{Score Mapping}} || 0) >= 0.50 ? "T10 (50-54%)"
: ({{Score Mapping}} || 0) >= 0.45 ? "T11 (45-49%)"
: ({{Score Mapping}} || 0) >= 0.40 ? "T12 (40-44%)"
: ({{Score Mapping}} || 0) >= 0.35 ? "T13 (35-39%)"
: ({{Score Mapping}} || 0) >= 0.30 ? "T14 (30-34%)"
: ({{Score Mapping}} || 0) >= 0.25 ? "T15 (25-29%)"
: ({{Score Mapping}} || 0) >= 0.20 ? "T16 (20-24%)"
: ({{Score Mapping}} || 0) >= 0.15 ? "T17 (15-19%)"
: ({{Score Mapping}} || 0) >= 0.10 ? "T18 (10-14%)"
: ({{Score Mapping}} || 0) >= 0.05 ? "T19 (5-9%)"
: "T20 (0-4%)"
Column name may vary by client (Score Mapping, Display Score, etc.) ,
replace {{Score Mapping}} with the client's actual bell curve column name.
Expected output. Mean ~50, Median ~50, Stdev ~20. Tier distribution: A (2%), B (8%), C (18%), D (22%), Low (50%). The slight left skew in the Low tier is structural: DQ catches the worst misfits, but the bottom of the surviving pool still has more sparse-data rows than the top has data-rich ones.
Worked example (Client B). A raw rubric (11 cards, maxes sum to 100) produced stdev 14.6 with scores in a 3-93 range. After bell curve normalization: stdev 20.2, mean 49, median 50, P5=16, P95=82. The top account went from raw 93 to Display 99, the median from raw 24 to Display 50, and the bottom from raw 3 to Display 1.
Trade-off. The Display Score is a relative rank, not an absolute measure. "Display 75" means "better than 75% of the qualified pool," not "hit 75% of the rubric criteria." This is a feature when the audience is reps who need to prioritize: they care about relative ordering, not rubric semantics. It's a limitation when the audience is the scoring model builder who needs to debug which card is underperforming: use the raw Grand Total and per-card scores for that.
6. Negative signals / explicit DQ axes
What it does. Pulls the bottom further down, stretching visible range upward and surfacing misfit firms separately from low-data firms.
When to apply. Whenever the list has identifiable wrong-fit categories that currently score the same as low-data correct-fit firms.
Starter negative axes for a service-firm TAM
| Signal | Penalty |
|---|---|
| Practice or service description matches an out-of-scope category | −3 |
| No website / dead domain | −2 |
| Below minimum size AND no activity signal | −2 |
| Defunct or merged (cross-reference a public list) | −5 (or hard DQ) |
| Solo operator indicators (FTE = 1) AND no domain signal | −1 |
Starter negative axes for an in-house buyer
| Signal | Penalty |
|---|---|
| Industry in {retail, hospitality, food service, professional services non-tech} | −2 |
| < 50 FTE AND 0 public filings | −2 |
| No website / dead domain | −2 |
Hard DQ vs soft penalty
- Hard DQ when the row should not appear in the output at all (sanctioned country, dissolved entity, an out-of-scope firm type). Removes from list.
- Soft penalty when the row could conceivably belong but the signal suggests it's a poor fit. Reduces score, doesn't remove.
Default to soft penalty unless client has explicit "do not contact" criteria.
How these stack
Order of application matters. The recommended composition:
1. Hard DQ filter (drop rows)
2. For each remaining row:
raw_universal = sum(universal_axis_pts)
raw_segment = sum(segment_specific_axis_pts) # service-firm OR corporate
raw_negative = sum(negative_signal_penalties) # typically ≤ 0
raw_total = max(0, raw_universal + raw_segment + raw_negative)
3. Apply log transforms inside the per-axis scorers (step into step 2)
4. multiplied = raw_total × Domain_Fit_Multiplier × Activity_Multiplier
5. confidence_adjusted = multiplied × completeness_multiplier
6. pct = confidence_adjusted / segment_denominator
7. (optional) blended = 0.5 * pct + 0.5 * percentile_within_segment(pct)
8. (optional) bell_curve = inverse_normal_cdf(percentile_rank) * 20 + 50
→ use when raw stdev plateaus and reps need 0-100 display scores
→ implement in Clay as GT → Display Score lookup (nested IF formula)
→ see §5a for full formula, Clay template, and recalibration process
Apply only what the data supports. If the segments are clean (single entity type), skip type-aware (step 2 segment-split). If data completeness is uniformly high, skip multiplier (step 5). Don't apply every technique on every engagement: pick the 2-3 that address the specific distribution problems found in Phase 1.
Decision matrix: which technique fixes which symptom
| Symptom from Phase 1 profile | Apply |
|---|---|
| Median in 10-25%, top tail thin (< 1% above 60%) | Log transforms + multiplicative gate |
| Top 10 too close to top 100 | Multiplicative fit gate |
| Same score on rows with vastly different enrichment depth | Completeness multiplier |
| Two populations (service firms + corporates) compressed together | Type-aware scoring |
| Headline distribution looks bad to non-technical reviewer | Percentile blend |
| Raw stdev plateaued at 10-18 due to low coverage on heavy axes; reps need 0-100 | Bell curve normalization (§5a) |
| Misfit rows scoring same as low-data correct-fit rows | Negative signals / soft DQ |
| Default-when-null in a derived column | Phase 1.1: fix the bug first, don't paper over with technique |
| Normalization cap < 1.0 | Phase 1.1: fix the denominator first |
Bug fixes (Phase 1.1) always come before distribution techniques. A technique applied on top of a bug just hides the bug.
references/levers.md
Levers: Empirical results from past engagements
Concrete deltas from the four levers, measured on real client datasets. Use this to answer "which lever should I pull first?" by mapping the symptom to the lever that empirically moved the right metric.
The 6 levers, ranked by empirical impact
| Lever | Mechanism | Typical stdev delta | Twin-firm spread delta | Architecture |
|---|---|---|---|---|
| 1. Add bands (resolution) | A 3-band card emits 3 values. A 9-band card emits 9. Resolution is the prerequisite for everything else. | +0.02 to +0.06 | huge per-axis (3-band → 9-band on Revenue moves spread from <1 pt to 12+ pts) | All |
| 2. Balance weights | Spread weight across more axes: flattens histogram toward bell. Trim top axes, lift mid-tier axes. | +0.00 to +0.01 (rarely moves stdev) | +30-100% on the bumped axis | All |
| 3. Wide tier gaps within positive bands | Tier 1 = 22, Tier 2 = 16, Tier 3 = 8. Gaps of 6+ pts do the discrimination work that negatives would do, while staying positive. | +0.02 to +0.05 | n/a | All |
| 4. Expanded Hard DQ for objective no-fit | Move "Industry = software / banks / restaurants" to Hard DQ instead of trying to score them. Removes them from the pool entirely (no enrichment spend). Surviving pool has cleaner spread. | +0.01 to +0.04 (on the surviving pool) | n/a | All: especially aligned with Clay's "DQ stops enrichment" |
| 5. Deep negative bands within cards | Push card band minimums to −15, −25 for misfit categories. Industry "Other" = −25, Product Type "software" = −15, etc. Creates a visible F-tier pile separate from candidates. | +0.04 to +0.10 | n/a (acts on penalized rows) | Pure-card with negatives only |
| 6. Cross-axis multipliers (Fit_Multiplier + Negative_Penalty column) | Compounds top-fit firms across multiple axes. Name-regex / pattern penalties. Requires separate formula columns. | +0.06 to +0.12 | +5-10 pts on top tier | With-multipliers architecture only |
Key insight by architecture:
- Pure-positive + smart Hard DQ: Levers 1+2+3+4. Realistic ceiling +15% to +35% stdev. Cleanest UX, aligns with "DQ stops enrichment" workflow.
- Pure-card with negatives: Levers 1+2+3+5. Realistic ceiling +30% to +60% stdev. Lever 5 (deep negative bands) does ~half the work.
- With-multipliers: Levers 1+2+3+5+6. Realistic ceiling +60% to +110% stdev. Lever 6 adds another ~+50% over pure-card.
What each lever does NOT do
| Lever | Common misconception |
|---|---|
| 1. Add bands | "More bands = wider distribution": partly true, but only if other levers are also applied. Bands alone bumped stdev by +0.061 in Client A; total possible was +0.190. |
| 2. Balance weights | "Pushing weight into the most discriminating axis widens distribution." False if the axis correlates with other top axes. Tested: pushed Industry 22→28, Reps 20→25, PE 13→18, Product 12→15: stdev moved +0.007 but largest 0.01-pct bucket grew from 3.15% to 5.93%, violating the gate. |
| 3. Widen Fit_Multiplier | "More aggressive multiplier always wins." False: if Multiplier × 2 pushes top firms past the cap, they all tie at the cap. Drop the cap together with the multiplier widening. |
| 4. Deepen penalty | "Negative scores look weird." Fine if you have an F-tier. The F-tier is a feature, not a bug: it's what creates the visible separation between DQ and real candidates. |
Symptom → Lever decision matrix
| Symptom | Pull this lever | Expected result |
|---|---|---|
| Histogram has a spike of 30%+ of rows in one bin | 1 (bands): that card has too few bands | Spike spreads across multiple bins |
| Card has 3 bands but the data spans 5+ orders of magnitude | 1 (bands) | 8-10 log-shaped bands |
| Two firms identical except one axis differ by <5 pts | 2 (weights) | Bump that axis weight (if uncorrelated) |
| Top-K firms all tied at the cap | 3 (multiplier) + remove cap | Top stretches 20-40 pts above the rest |
| Largest 0.01-pct bucket > 5% (pile-up) | 2 (weights): likely overweighted top axes | Trim top, lift mid-tier |
| Distribution looks bimodal | 2 (weights): usually means one mega-axis dominates the others | Flatten across mid-tier |
| Bad-fit firms scoring in the C/D tier (above the DQ threshold) | 4 (penalty) | Penalty pushes them below the DQ threshold cleanly |
| DQ rows pile at exactly 0 (floor clamp) | 4 (drop clamp) | Bottom extends to -30/-60, F-tier separates from D-tier |
| stdev is low AND no obvious pile-up | 3+4 (multiplier and penalty together) | +0.06-0.09 stdev |
| stdev is fine but stack-rank "feels samey" | 2 (weights) | Twin-firm spread improves; histogram more bell-shaped |
| Top-25 named good-fit firms aren't appearing in A | NOT a lever: investigate sourcing gap or specific bug in Phase 1.1 | Fix root cause |
Worked example: Client A scoring engagement
With-multipliers track (full architecture)
| Stage | What changed | stdev | Twin-firm Revenue | Largest bucket | A-tier % | Lever(s) used |
|---|---|---|---|---|---|---|
| Original Clay (44,723 rows) | baseline | 0.178 | <1 pt | 3.79% | ~15% (3,400) | n/a |
| v4 | Bug fixes + re-banded all cards | 0.239 | 4 pts | n/a (clamp pile-up) | 0.9% (396) | 1 |
| v5 | + reweight (Revenue 0→7, Employee 7→10) | 0.239 | 4 pts | n/a (clamp pile-up) | 1.1% (476) | 2 (no stdev impact) |
| v6 | + drop clamp + widen Fit_Multiplier 0.5-1.6 | 0.280 | 7 pts | 3.15% | 1.7% (782) | 4 |
| v7c | + wider Fit_Multiplier 0.4-1.8 + ~2× penalty | 0.368 | 13 pts | 3.72% | 3.1% (1,386) | 4 stronger, 5 stronger |
| v9 (with-multipliers final) | + balance weights: Industry 22→18, Reps 20→18, Revenue 7→12, Employee 10→12 | 0.367 | 20.6 pts | 2.96% | 3.4% | 2 (now visible in twin-firm) |
Pure-card with-negatives track
| Stage | What changed | stdev | Twin-firm Revenue | A-tier % | F-tier % | Lever(s) used |
|---|---|---|---|---|---|---|
| v10 | Pure card, moderate bands | 0.188 | 11 pts | 0.5% | 0.1% | 1 |
| v10b | + aggressive bands (Other=−15, software=−8) | 0.228 | 12 pts | 0.3% | 8.2% | 1+5 (moderate) |
| v10c (pure-card final) | + max-aggressive bands (Other=−25, software=−15) + intra-card PE tie-breaker | 0.279 | 15 pts | 0.2% | 14.5% | 1+2+5 maxed |
Pure-positive + smart Hard DQ track (RECOMMENDED for Clay GTM workflows)
| Stage | What changed | stdev (est.) | Pool size | Architecture benefit |
|---|---|---|---|---|
| v10d (final) | All bands positive (Other=1, software=0); expanded Hard DQ on Normal Industry contains [software | banks | schools | restaurants | real estate]; Hard DQ mega-corps (FTE >10K) | ~0.22 | ~12K-15K candidates (down from 22K) | All scores 0-100. No negatives in UI. DQ'd rows don't waste enrichment credits. Soft-fit rows stay enriched and can promote on signals. |
What this tells you:
- Bug fixes + Lever 1 alone: +0.061 stdev (huge first move regardless of architecture)
- Lever 2 alone: +0 stdev BUT +400% twin-firm spread on the lifted axis
- Lever 5 (deep negative bands): +0.05 to +0.09: only available in pure-card-with-negatives
- Lever 6 (multipliers): +0.09: only available with-multipliers
- Lever 4 (expanded Hard DQ): substitutes for ~half of Lever 5's gain in pure-positive architecture
- Pure-positive + smart DQ ceiling: ~+22% stdev (v10d)
- Pure-card with negatives ceiling: +57% stdev (v10c)
- With-multipliers ceiling: +107% stdev (v9)
- Gap between architectures is real but operational considerations matter more. Client A chose v10d because Clay's "DQ stops enrichment" behavior makes negative-scoring rows operationally wasteful: they keep getting enriched but will never be customers.
Failed variant (don't do this regardless of architecture): pushed Industry/Reps/PE/Product weights aggressively. stdev moved +0.005 over v7c but largest bucket grew to 6.08%, violating the gate. Confirms: pushing weight into correlated top axes does NOT widen distribution.
The architectural decision flowchart
Ask: Does the client's tool (Clay or similar) STOP enrichment when a row is DQ'd?
YES (Clay default) ──► Recommend pure-positive + smart Hard DQ (v10d pattern)
Rationale: negative scores keep enriching rows that will never
be customers. Hard DQ stops the waste.
NO ──► Ask: Does the client tolerate cross-axis multipliers in formulas?
NO ──► Pure-card with negatives (v10c pattern)
Use Lever 5 (deep negatives) for spread
YES ──► With-multipliers (v9 pattern)
Maximum spread, accepts 3 new formula columns
Default to pure-positive + smart Hard DQ unless the client explicitly asks for the extra stdev.
Worked example: Client B (anonymized)
Pure-positive + smart Hard DQ + bell curve normalization
A 41,652-row export with two entity types in one list. It started at stdev 8.2 on a 100-point scale, with scores compressed into the 0-20% range and a median of 9%.
Root causes found in Phase 1:
- 51% of rows were a structurally wrong-fit entity type
- Two axes were over 90% saturated, so they added a constant rather than differentiation
- 4 axes had under 5% coverage but carried 30% of the weight
- The denominator was 39 while the theoretical max was 50, and nobody could reach 39
| Stage | What changed | Raw stdev | Display stdev | Architecture |
|---|---|---|---|---|
| Original | baseline | 8.2 | n/a | : |
| v3 | Aggressive DQ, percentile-based bands on every card, weight moved from saturated axes to data-rich ones, maxes sum to 100 | 14.6 | n/a | Pure-positive + smart Hard DQ |
| v3 + bell curve | Display Score column: inverse-normal CDF on percentile rank, mean 50, std 20. Cards untouched | 14.6 | 20.2 | Pure-positive + bell curve display |
Key insight: the raw stdev plateaued at 14.6 because 30% of the weight sat on axes with under 5% coverage. More rubric rounds added less than 0.5 stdev each. Bell curve normalization added 5.6 display stdev by mapping the correct ranking onto a full 0-100 scale.
Implementation details:
- Scoring cards use Clay's band UI. Clayscript is only for formula columns: DQ, Grand Total, Display Score, Tier.
- The Display Score formula reads
{{Score Percentage}}and must wrap its null guard in parens:({{Score Percentage}} || 0). Clay stripsconstdeclarations, which breaks operator precedence. - A Contains (Text) card must list every value explicitly. Missing values score 0 with no error; in this case coverage on one card fell from 98% to 48%.
What this tells you:
- Aggressive DQ is a prerequisite, not a lever. No weight tuning fixes a pool that's 51% non-ICP.
- Weight must follow coverage. Weight on a 5%-populated axis means 95% of rows score 0 on it.
- Saturated axes should carry minimal weight.
- Bell curve normalization is a legitimate final step once the raw stdev ceiling is hit. It extends a correct ranking; it doesn't replace rubric work.
The "DQ threshold" calibration
Clients often want a natural cutoff (e.g. 40%) that cleanly separates DQ from real candidates. Validation:
| Check | Pass condition |
|---|---|
| DQ pile median | ≤ (threshold − 10 pts on the 100-pt raw scale) |
| Real candidates median | ≥ (threshold + 10 pts) |
| No firms between (threshold − 5) and (threshold + 5) | Ideally ≤ 5% of full list: the threshold should fall in a natural valley of the histogram |
If the histogram doesn't have a natural valley at the threshold, you can either (a) move the threshold to where the valley actually is, or (b) deepen the Negative_Penalty so the DQ pile shifts further down and a valley opens.
Twin-firm test protocol
For each weighted axis (weight ≥ 5), run this:
- Pick a real top-15 firm as the template
- Clone it 8-10 times, varying ONLY axis X across its full realistic range
- Score all clones
- Report: spread between min and max clone, span of axis X values tested
Example output from a Revenue test on one top-15 account (Client A):
Revenue v7c v9 (lifted)
$1M 143.3 139.3
$10M 146.2 143.1
$100M 153.1 153.3
$1B 156.3 159.9
$25B 149.1 147.5
v7c spread $1M-$1B: 13 pts v9 spread: 20.6 pts (+58%)
A passing test: spread ≥ 10 pts on the top 3 axes by weight.
If an axis fails the test, the diagnosis is usually one of:
- Card has too few bands → Lever 1
- Axis weight too low → Lever 2 (if uncorrelated)
- Axis is just not very discriminating in this dataset → leave it as a tie-breaker (low weight is correct)
references/profile_data.py
""" Profile data coverage and existing score distribution for a TAM CSV.
Usage: python3 profile_data.py path/to/source.csv
Configure SIGNAL_COLUMNS and EXISTING_SCORE_COLUMN for the dataset under audit. Outputs:
- stdout: per-column coverage + stats + score histogram + bug audit
- profile_output.txt: same content
The bug audit checks for the four most common derived-score failures (see SKILL.md Phase 1.1):
- Default-when-null : derived nonzero rate >> source coverage rate
- Normalization cap : *_Golden / *_Pct max < 1.0
- Band gaps : gaps in keyword-based rubric definitions (manual entry)
- Empty enrichment : column referenced in rubric but 0% populated
Plus a canonical-entity coverage check against CANONICAL_ENTITIES. """ import csv import os import statistics import sys from collections import Counter, defaultdict
=== CONFIGURE PER DATASET ===
SIGNAL_COLUMNS = [ # ('display name', 'csv column name', type='int'|'float'|'str'|'bool') # Example for a service-firm TAM: # ('FTE', 'FTEs vF', 'int'), # ('Country', 'Country vF', 'str'), # ('Specialists', 'Specialist Ct vF', 'int'), ] EXISTING_SCORE_COLUMN = 'Final Score Percentage' # set to None if no existing score EXISTING_GRAND_TOTAL_COLUMN = 'Grand Total Score' # optional NAME_COLUMN = 'Name vF' # for canonical-entity matching
Pairs of (raw value column, score column) to reverse-engineer rubrics
RUBRIC_PAIRS = [ # ('FTEs vF', 'FTE Score'), # ('Country vF', 'Country Score'), ]
Audit pairs: (derived_score_column, [source_input_columns])
Used to detect default-when-null bugs (Phase 1.1 check #1)
DERIVED_SCORE_AUDIT = [ # Example: # ('Domain Experience Golden', ['Domain Expertise Classification', 'Service Count']), # ('Activity Golden', ['Activity Count']), ]
Columns expected to be normalized 0..1. Flags if observed max < cap.
NORMALIZED_COLUMNS = [ # 'Country Score Golden', # 'Global Presence Golden', # 'Domain Experience Golden', ] NORMALIZATION_CAP_THRESHOLD = 0.95 # warn if observed_max < this
Canonical "any reasonable scoring puts these in the top 50" entities for the
domain under audit. Used for sourcing-gap detection. Adapt per client.
CANONICAL_ENTITIES = [ # 'Market Leader A', 'Market Leader B', 'Market Leader C', ]
==============================
def to_int(v): try: return int(float(v)) except: return None def to_float(v): try: return float(v) except: return None
def profile(rows): lines = [] def p(*a): lines.append(' '.join(str(x) for x in a))
p(f"Total rows: {len(rows)}")
p('')
# Column coverage
p('=== Column coverage ===')
p(f"{'Column':40s} {'Non-empty':>10} {'Pct':>6}")
for r in [{}] + rows[:1]:
pass
cols = list(rows[0].keys())
for c in cols:
n = sum(1 for r in rows if (r.get(c) or '').strip())
if n == 0:
continue
p(f"{c:40s} {n:>10} {100*n/len(rows):>5.1f}%")
p('')
# Signal-column distributions
if SIGNAL_COLUMNS:
p('=== Signal-column distributions ===')
for name, col, typ in SIGNAL_COLUMNS:
vals = [r.get(col, '') for r in rows]
present = [v for v in vals if (v or '').strip()]
p(f"\n{name} (col: {col})")
p(f" coverage: {len(present)}/{len(rows)} ({100*len(present)/len(rows):.1f}%)")
if typ == 'int' or typ == 'float':
nums = [to_float(v) for v in present]
nums = [x for x in nums if x is not None]
nz = [x for x in nums if x > 0]
if nz:
sn = sorted(nz)
p(f" nonzero: {len(nz)} median={statistics.median(nz):.1f} max={max(nz):.0f}")
for q in [10, 25, 50, 75, 90, 95, 99]:
idx = int(len(sn) * q / 100)
p(f" P{q}: {sn[idx]:.1f}")
elif typ == 'str':
top = Counter(v for v in present).most_common(10)
for v, n in top:
p(f" {v[:40]}: {n}")
elif typ == 'bool':
p(f" {Counter(v.lower() for v in present).most_common()}")
p('')
# Existing score distribution
if EXISTING_SCORE_COLUMN:
scores = [to_float(r.get(EXISTING_SCORE_COLUMN, '')) for r in rows]
scores = [x for x in scores if x is not None]
p(f'=== Existing score distribution ({EXISTING_SCORE_COLUMN}) ===')
if scores:
p(f"n={len(scores)} mean={statistics.mean(scores):.3f} median={statistics.median(scores):.3f} stdev={statistics.stdev(scores):.3f}")
ss = sorted(scores)
for q in [10, 25, 50, 75, 90, 95, 99]:
p(f" P{q}: {ss[int(len(ss)*q/100)]:.3f}")
p('')
p('Histogram (0.05 bins):')
bh = Counter()
for x in scores:
bh[round(x / 0.05) * 0.05] += 1
scale = max(1, max(bh.values()) // 50)
for k in sorted(bh):
bar = '#' * (bh[k] // scale)
p(f" {k:.2f}: {bh[k]:>6} {bar}")
p('')
# Reverse-engineer rubrics from raw value -> score column pairs
if RUBRIC_PAIRS:
p('=== Reverse-engineered rubric bins ===')
for raw_col, score_col in RUBRIC_PAIRS:
buckets = defaultdict(list)
for r in rows:
rv = to_float(r.get(raw_col, ''))
sv = (r.get(score_col, '') or '').strip()
if rv is not None and sv:
buckets[sv].append(rv)
p(f"\n{raw_col} -> {score_col}:")
for s in sorted(buckets, key=lambda x: (x == '', x)):
vals = buckets[s]
if vals:
p(f" score={s!r}: range [{min(vals)}, {max(vals)}] n={len(vals)}")
p('')
# === BUG AUDIT (Phase 1.1) ===
p('=== BUG AUDIT: derived score columns ===')
# Check 1: default-when-null detector
if DERIVED_SCORE_AUDIT:
p('\n-- Default-when-null check --')
p('Flags when a derived score is nonzero on far more rows than its source has data.')
for derived, sources in DERIVED_SCORE_AUDIT:
derived_vals = [to_float(r.get(derived, '')) for r in rows]
derived_nonzero = sum(1 for v in derived_vals if v is not None and v > 0)
derived_rate = derived_nonzero / len(rows) if rows else 0
source_max_cov = 0
source_details = []
for s in sources:
c = sum(1 for r in rows if (r.get(s) or '').strip()
and (r.get(s) or '').strip().lower() not in ('none', 'n/a', 'na'))
rate = c / len(rows) if rows else 0
source_max_cov = max(source_max_cov, rate)
source_details.append(f'{s}={100*rate:.1f}%')
verdict = 'OK'
if derived_rate > source_max_cov + 0.2 and derived_rate > 0.5:
verdict = 'LIKELY BUG: default-when-null'
p(f" {derived}: nonzero={100*derived_rate:.1f}% vs sources [{', '.join(source_details)}] → {verdict}")
p('')
# Check 2: normalization-cap detector
if NORMALIZED_COLUMNS:
p('-- Normalization-cap check --')
p(f'Flags 0..1 columns whose observed max is below {NORMALIZATION_CAP_THRESHOLD}.')
for col in NORMALIZED_COLUMNS:
vals = [to_float(r.get(col, '')) for r in rows]
vals = [v for v in vals if v is not None]
if not vals:
p(f" {col}: NO DATA")
continue
mx = max(vals)
verdict = 'OK' if mx >= NORMALIZATION_CAP_THRESHOLD else f'LIKELY BUG: denominator too large (max={mx:.2f} < {NORMALIZATION_CAP_THRESHOLD})'
p(f" {col}: observed max={mx:.3f} → {verdict}")
p('')
# Check 3: empty enrichment columns (any column referenced in scoring but 0% populated)
p('-- Empty enrichment check (columns with <2% coverage) --')
cols = list(rows[0].keys()) if rows else []
for c in cols:
n = sum(1 for r in rows if (r.get(c) or '').strip())
if n == 0:
p(f" {c}: 0% populated: DEAD COLUMN")
elif n / len(rows) < 0.02:
p(f" {c}: {100*n/len(rows):.2f}% populated: likely broken pipeline step")
p('')
# Check 4: canonical-entity sourcing-gap detector
if CANONICAL_ENTITIES:
p('-- Canonical-entity sourcing-gap check --')
p('Flags canonical top-tier entities missing from the source list.')
for ent in CANONICAL_ENTITIES:
needle = ent.lower()
hits = sum(1 for r in rows if needle in (r.get(NAME_COLUMN, '') or '').lower())
tag = '✓ present' if hits else 'MISSING'
p(f" {ent}: {tag} ({hits} matches)")
p('')
return lines
def main(): if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} path/to/source.csv") sys.exit(1) path = sys.argv[1] with open(path) as f: rows = list(csv.DictReader(f)) lines = profile(rows) out = '\n'.join(lines) print(out) out_path = os.path.join(os.path.dirname(os.path.abspath(path)) or '.', 'profile_output.txt') with open(out_path, 'w') as f: f.write(out) print(f"\nWrote {out_path}")
if name == 'main': main()
references/scoring-tables-primer.md
Scoring Tables Primer: Everything We Know
The full conceptual model for how Clay-style scoring tables work, why distributions compress, and how to widen them. Read this first if you're new to a scoring engagement. Loaded findings from the Client A engagement (a 44,723-row TAM).
1. The basic mechanics
Per scoring card:
Raw Score → points the firm earned on that card
Max → the highest possible Raw Score for that card = the card's weight
Golden → Raw Score / Max → 0-1 normalized (display-only helper, optional)
Grand Total Score: SUM of all cards' Raw Scores
Final Score Percentage: Grand Total / sum of all maxes (e.g. /100 if maxes sum to 100)
Per-card max = the card's weight. There's no separate × weight multiplication step: the scaling lives in the band values themselves. Industry max=22 means Industry contributes up to 22% of Final %.
Golden columns are display-only. They exist for readability ("70% on Revenue") but don't feed Grand Total. The Grand Total uses Raw Scores directly.
2. The two metrics you optimize for
| Metric | What it measures | Target (typical) |
|---|---|---|
| stdev of Final % (within DQ=False pool) | Overall spread across the list | ≥ 0.20 usable, ≥ 0.25 great, 0.30+ exceptional |
| Twin-firm spread | Δ between two identical firms differing only in axis X. Run for every weighted axis. | ≥ 10 pts on the top 3 axes |
Optimize both. stdev alone can be flattered by a long penalty tail without giving you adjacent-row resolution. Twin-firm spread alone can be flattered by one outsized axis weight that compresses the rest.
3. The 6 levers that actually widen distribution
| Lever | Mechanism | Stdev gain | Architecture availability |
|---|---|---|---|
| 1. Add bands | More bands per card = more resolution. A 3-band card emits 3 values; a 9-band card emits 9. Prerequisite for everything else. | +0.02 to +0.06 | All |
| 2. Balance weights | Spread weight across more axes. Flatter weight distribution → bell-shaped histogram. Doesn't widen stdev directly, but widens twin-firm spread on the lifted axis. | +0.00 to +0.01 | All |
| 3. Wide tier gaps within positive bands | Tier 1 = 22, Tier 2 = 16, Tier 3 = 8. 6+ pt gaps do discrimination work that negatives would do, while staying positive. | +0.02 to +0.05 | All |
| 4. Expanded Hard DQ for objective no-fit | Move "Industry = software / banks / restaurants" to Hard DQ. Removes from pool (no enrichment spend). Surviving pool has cleaner spread. | +0.01 to +0.04 (on survivors) | All |
| 5. Deep negative bands within cards | Industry "Other" = −25, Product "software" = −15. Creates F-tier pile separate from candidates. | +0.04 to +0.10 | Pure-card-with-negatives only |
| 6. Cross-axis multipliers | Fit_Multiplier (0.4-1.8 swing) + Negative_Penalty formula column. Compounds top-tier fit across axes. | +0.06 to +0.12 | With-multipliers only |
4. What does NOT widen distribution (common traps)
| Tempting move | Why it fails |
|---|---|
| Push more weight into the top axes (Industry/Reps/PE/Product) | Those axes correlate. Firms hitting all of them already top out together. Tested: pushed Industry 22→28, stdev moved +0.007 but largest 0.01-pct bucket grew from 3.15% to 5.93%. |
| Raise a card's raw max from 1 to 10 without adding bands | Mathematically identical to original. Golden = score/max → same 0-1 output. Resolution is set by band COUNT, not max value. |
| Add weight to an axis with low coverage | If only 5% of rows have data, bumping its weight only differentiates that 5%. Doesn't widen the histogram. |
| Add weight to an axis derived from another already-weighted axis (Rev/Rep, Fragmentation as Industry lookup) | Double-counts the same signal. Keep redundant axes as tie-breakers (weight 2-5). |
5. The three architecture options
The choice is operational, not mathematical. Ask the client first.
| Architecture | What you can use | What you give up | Stdev ceiling |
|---|---|---|---|
| Pure-positive + smart Hard DQ (recommended default) | All scores ≥ 0. Wide tier gaps. Expanded Hard DQ filters obvious misfits. Soft-fit firms get low positive scores. | Negative bands. Cross-axis multipliers. | +15-35% (Client A v10d: 0.178 → ~0.22) |
| Pure-card with negatives | Each card max = weight. Deep negative bands within cards. No multipliers. Grand Total can go negative. | Cross-axis compounding. Cross-axis name regex. Operator must accept negative scores in UI. | +30-60% (Client A v10c: 0.178 → 0.279) |
| With-multipliers | Per-card weights + Fit_Multiplier + Completeness_Multiplier + Negative_Penalty column. Most aggressive. | 3 new formula columns. More complex Grand Total. Cross-axis multiplier harder to explain. | +50-110% (Client A v9: 0.178 → 0.367) |
The deciding question (ask before designing bands)
Does the client's tool (Clay or similar) STOP enrichment when a row is DQ'd?
- YES (Clay default) → Pure-positive + smart Hard DQ. Reason: negative scores keep enriching rows that will never be customers. Hard DQ stops the waste.
- NO → Pure-card with negatives, or with-multipliers if they tolerate the formula columns.
Hard DQ vs score-low-positive: the rule
- Hard DQ: firm's industry/type is fundamentally wrong (software, bank, school, restaurant). Future enrichment can't refine it into a fit.
- Score-low-positive: firm might be misclassified, weak-fit, or have wrong data that better enrichment could refine. Keep in pool with score 0-2.
6. The five most common bugs in existing rubrics
Always run these 4 detectors in Phase 1.1 before touching weights.
| Bug | Detection | Client A example |
|---|---|---|
| Default-when-null | Derived score column has nonzero rate >> source column coverage rate | PE Score Golden = 0.25 on 95% of rows because PE Backers Count defaulted to 1 |
| Normalization-cap | A *_Golden column's observed max caps well below 1.0 | Territories Golden capped at 0.44 because divisor was too large |
| Band-gap | Keyword bands have gaps (1-5, 11-25, ...: missing 6-10) | Specialist bands missed 6-10 range in a prior engagement |
| Empty enrichment | Column referenced in rubric but <2% populated | Distinct Company Countries = 15.5% populated, contributed to Territories cap |
| VC-counted-as-PE | PE detection treats any investor name as PE | 123 of 129 VC-backed firms (Sequoia, a16z, Venrock) scored as PE in original Client A model |
Fixing a single default-when-null bug often does more for the distribution than any weight tuning.
7. Validation gates: what tells you the rubric works
| Gate | Target | If it fails |
|---|---|---|
| Stdev of Final % (within non-DQ pool) | ≥ 0.20 (target 0.20-0.25 for pure-positive) | Apply more aggressive Lever 3, 4, or 5 |
| Largest 0.01-pct bucket | ≤ 5% | Weights too top-heavy: flatten across mid-tier axes |
| Twin-firm spread on top 3 axes | ≥ 10 pts each | Bump axis weight (only if uncorrelated) |
| DQ threshold separation | DQ rows median ≤ threshold − 10 pts; real candidates median ≥ threshold + 10 pts | Deepen Lever 4 or Lever 5 |
| Top-K named good-fit firms appear in A or B | ≥ 80% of known-good firms | Sourcing gap OR specific bug: re-run Phase 1.1 |
| Histogram shape | Bell-shaped around median; clear separation between F/Low tier and the rest | Apply Lever 2 (balance weights) |
8. Client A empirical track (worked example, all 10+ versions)
44,723-row Clay export. Started with stdev 0.178, mostly-tied A-tier (~15% of list), DTC consumer brands at the top, default-when-null bugs everywhere.
| Version | What changed | Stdev | Twin-firm Revenue | Architecture |
|---|---|---|---|---|
| Original Clay | baseline | 0.178 | <1 pt | n/a |
| v4 | Bug fixes + re-banded all cards | 0.239 | 4 pts | bug-fix prep |
| v5 | + reweight (Revenue 0→7, Employee 7→10) | 0.239 | 4 pts | reweight (no stdev gain: correlation) |
| v6 | + drop bottom clamp + Fit_Multiplier 0.5-1.6 | 0.280 | 7 pts | with-multipliers |
| v7c | + wider Fit_Multiplier 0.4-1.8 + 2× penalty | 0.368 | 13 pts | with-multipliers max |
| v9 | + balance weights | 0.367 | 20.6 pts | with-multipliers final |
| v10 | Switch to pure-card, moderate bands | 0.188 | 11 pts | pure-card baseline |
| v10b | + aggressive negative bands | 0.228 | 12 pts | pure-card-with-negatives |
| v10c | + max-aggressive negatives (Other=−25, software=−15) | 0.279 | 15 pts | pure-card-with-negatives max |
| v10d (shipped) | + remove all negatives, expand Hard DQ for objective misfits | ~0.22 | ~10-12 pts | pure-positive + smart Hard DQ |
Why v10d shipped despite lower stdev: in Clay, DQ stops enrichment. Negative-scoring rows otherwise keep burning enrichment credits forever on firms that will never become customers. v10d's operational benefit (no wasted enrichment + cleaner UX) outweighs the +0.05 stdev gap to v10c.
9. The implementation pattern for pure-positive + smart Hard DQ
This is the recommended default. Roughly 80-90 min in Clay for a 9-card rubric.
Step 1: Expand DQ logic (10 min):
DQ2 = OR(
-- existing
{FTEs vF} < 50,
{AI Industry Score} = 0,
{Domain vF} = "",
-- NEW: objectively wrong-fit industries
REGEX_MATCH(LOWER({Normal Industry vF}),
"software development|saas|computer software|information technology and services|" &
"financial services|banking|insurance|investment management|" &
"restaurants|real estate|higher education"),
-- NEW: Fortune 100
{FTEs vF} > 10000
)
Step 2: Rewrite each card (5-10 min each):
- Add bands (Lever 1): 8-12 bands for right-tailed numerics like Revenue/Reps/FTEs
- Use wide tier gaps in positive ranges (Lever 3): Tier 1 = 22, Tier 2 = 16, Tier 3 = 8
- Bottom out at 0 (no negatives)
- Make sure card maxes sum to 100
Step 3: Rewrite Grand Total (5 min):
Grand Total Score = (pure sum of all 9 Raw Scores)
Final Score Percentage = Grand Total Score / 100
Step 4: Add Tier column (5 min):
Tier = IF(GrandTotal >= 75, "A",
IF(GrandTotal >= 55, "B",
IF(GrandTotal >= 35, "C",
IF(GrandTotal >= 15, "D", "Low"))))
Step 5 ((Optional) Add 9 Golden display columns (15 min)) one per card, =Raw / Max. Display only, doesn't affect math.
Step 6: Validate against the 6 gates (above).
10. Raw Grand Total ≈ 85-95 by design (not 100)
A well-designed rubric with mutually-exclusive bullseyes produces a structural raw max of ~85-95. No real firm hits every card's bullseye simultaneously because they imply different firm sizes (e.g. 60-89 reps + 400-799 FTE + $1B-$5B revenue together implies $7.5M revenue per employee: unrealistic for field-sales orgs).
This is correct behavior. The Display Score (Phase 3a bell curve) goes 0-100 by construction and is what reps should see. Raw Grand Total is the debugging metric.
Don't "fix" the raw < 100 by loosening bullseyes: that collapses discrimination. The right answer is Display Score + tier off Display Score.
11. Tier scheme: 20 tiers at 5% intervals on Display Score
Recommended tier UX (replaces the old 5-tier A/B/C/D/Low scheme). Convention: T1 = top (best fit), T20 = bottom (worst fit).
Tier = "T" + (20 - Math.floor(Math.min(0.99, ({{Display Score}} || 0)) / 0.05))
(For 0-100 Display scale: "T" + (20 - Math.floor(Math.min(99, ({{Display Score}} || 0)) / 5)))
T1 (Display 0.95-1.00, best) through T20 (Display 0-0.05, worst). Sort ascending: T1 first. Each tier's expected population is predictable:
| Tier | Display 0-1 range | Expected pop | Notes |
|---|---|---|---|
| T1 | 0.95-1.00 (best) | ~2.4% | Top priority outreach |
| T2 | 0.90-0.95 | ~0.6% | |
| T3 | 0.85-0.90 | ~1.9% | |
| T5 | 0.75-0.80 | ~4.6% | |
| T10 | 0.50-0.55 (median) | ~10.7% | |
| T15 | 0.25-0.30 | ~4.6% | |
| T20 | 0-0.05 (worst) | ~2.5% | Low-fit; signal monitoring only |
Keep the old 5-tier scheme as a Tier_Coarse column for boss-memo summaries ("we have 3% A-tier"). The 20-tier Tier is for filtering and stack-rank UX.
12. The single most important insight
The architecture choice precedes the band design. The same scoring intent (Industry tier 1 = bullseye, software = wrong fit) renders three different ways depending on whether the tool supports cross-axis multipliers, whether DQ stops enrichment, and whether the operator tolerates negative scores in the UI.
Default to pure-positive + smart Hard DQ + Display Score (bell curve) + 20-tier tiering. It maps cleanly to how GTM teams actually run enrichment workflows: stop wasting credits on objectively wrong-fit firms (Hard DQ), keep enriching everything else even if they currently score low (because signal monitoring can promote them later), present a clean 0-100% score to non-technical reviewers without negative numbers to explain, and surface 5%-granular tiers for stack-rank filtering.
references/simulate_scoring.py
""" Scoring simulation harness: drop-in starter for any client engagement.
DEFAULT ARCHITECTURE: pure-positive + smart Hard DQ. This is the recommended default per SKILL.md. All cards bottom out at 0, soft-fit categories score low-positive (0-2), Hard DQ filters objectively wrong-fit firms before scoring runs. No Fit_Multiplier, no Completeness_Multiplier, no Negative_Penalty column.
If you need to switch architectures (e.g. client uses a non-Clay tool where DQ doesn't stop enrichment, OR they want maximum stdev with multiplier columns), toggle ARCHITECTURE below and uncomment the relevant scorer overrides.
What it does:
- Loads a CSV of accounts + existing per-criterion scores
- Computes a baseline (the client's current model) and a revised model
- Reports side-by-side: stdev, histogram, percentiles, largest pile-up bucket, A/B/C/D/F tier counts, and TWIN-FIRM SPREAD per axis
- Validates against the 6 gates from SKILL.md (architecture-appropriate stdev, bucket concentration ≤5%, twin-firm spread on top 3 axes, DQ-threshold valley, top-K named-firms recognition, histogram shape)
What you adapt per client (the CONFIGURE block):
- ARCHITECTURE: 'pure-positive' (default) | 'pure-card-with-negatives' | 'with-multipliers'
- SRC: path to the source CSV
- NAME_COLUMN / CURRENT_FINAL_PCT_COLUMN: column names in the source
- WEIGHTS: per-axis weight in Final % (sum should be 100)
- DQ_THRESHOLD_PCT: where the client wants the DQ cutoff (e.g. 0.40)
- hard_dq(row): returns (bool, reason): expanded list for pure-positive
- score_*(row) functions: per-axis raw score → returns (score, max)
- TWIN_FIRM_TEMPLATE_NAME: a real top-15 firm to clone for twin-firm tests
- TWIN_FIRM_AXES: list of (axis_name, column, [test values]) tuples
Usage: python3 simulate_scoring.py path/to/source.csv
Outputs: scored_v[N].csv per-row scores + breakdown columns summary_v[N].txt the headline report (paste into RECOMMENDATIONS.md) """ import csv import math import os import statistics import sys from collections import Counter
csv.field_size_limit(sys.maxsize)
============================================================
CONFIGURE PER CLIENT: everything below this line is the contract
============================================================
Architecture decision (Phase 0: set BEFORE anything else)
Options:
'pure-positive' : RECOMMENDED default. All scores ≥ 0. Expanded Hard DQ.
Stdev target: ≥ 0.20. Used when DQ stops enrichment.
'pure-card-with-negatives' - Negative bands within cards. No multipliers.
Stdev target: ≥ 0.27. Used when DQ doesn't stop enrichment.
'with-multipliers' : Fit_Multiplier + Negative_Penalty column + Completeness_Multiplier.
Stdev target: ≥ 0.35. Used when client wants max spread.
ARCHITECTURE = 'pure-positive'
VERSION = 10 SRC = sys.argv[1] if len(sys.argv) > 1 else '/path/to/source.csv' OUT_DIR = os.path.dirname(os.path.abspath(SRC)) or '.' OUT_CSV = os.path.join(OUT_DIR, f'scored_v{VERSION}.csv') OUT_TXT = os.path.join(OUT_DIR, f'summary_v{VERSION}.txt')
NAME_COLUMN = 'Name vF' CURRENT_FINAL_PCT_COLUMN = 'Final Score Percentage' # set None if absent DQ_THRESHOLD_PCT = 0.40
Per-axis weights in the Final % (must sum to 100)
These weights are appropriate starting points for a PE-backed
mid-market ICP. Adjust per client but preserve the principle:
top 3 axes get ~50% of weight, mid-tier ~35%, tie-breakers ~15%.
WEIGHTS = { 'industry': 22, # primary fit signal 'reps': 18, # field-sales sweet spot 'pe': 14, # ownership signal (intra-card tie-breaker) 'product': 12, # motion proxy 'employees': 12, # 100% coverage = stable anchor 'revenue': 12, # scale signal (independent of /rep) 'tech': 6, # CRM maturity 'maturity': 2, # founded-year tie-breaker 'country': 2, # geo already filtered }
Tier cutoffs on raw Final pts.
Pure-positive: 'Low' replaces 'F' since scores can't go negative.
Pure-card-with-negatives: 'F' is rows that scored below 0 (visible misfit pile).
if ARCHITECTURE == 'pure-positive': TIER_CUTOFFS = {'A': 75, 'B': 55, 'C': 35, 'D': 15} # below 15 = 'Low' else: TIER_CUTOFFS = {'A': 75, 'B': 55, 'C': 35, 'D': 0} # below 0 = 'F'
============================================================
PER-AXIS SCORERS: each returns (raw_score, max_score)
============================================================
def tf(v): try: return float(v) except: return None def ti(v): try: return int(float(v)) except: return None def s(v): return (v or '').strip()
def score_industry(row): """Industry → tier lookup. Pure-positive default (all values ≥ 0).
Wide tier gaps (Tier 1 = 22, Tier 2 = 16, Tier 3 = 8) do the discrimination
work that negative bands would do, while staying positive.
For pure-card-with-negatives architecture: replace 0s with -5 to -25 for misfits.
"""
bands = {
# Example values. Replace with the client's industries.
# Tier 1: true bullseye (max 22)
'Bullseye Industry A': 22, 'Bullseye Industry B': 22,
# Tier 2: strong fit (~16)
'Strong Industry A': 16, 'Strong Industry B': 14,
# Tier 3: good fit (~8-9)
'Good Industry A': 9, 'Good Industry B': 8,
# Tier 4: weaker fit (~3)
'Weak Industry': 3,
# Tier 5: soft fit, kept in pool with low score (~1-2)
'Soft Industry': 2, 'Other': 1,
}
ind = s(row.get('AI Industry vF')) or s(row.get('AI Industry v3'))
return bands.get(ind, 1), 22
def score_employees(row): """Employee count → score. Pure-positive (peak 400-799 FTE).""" fte = ti(row.get('FTEs vF')) if fte is None or fte < 50: return 0, 12 if fte < 100: return 2, 12 if fte < 200: return 5, 12 if fte < 400: return 8, 12 if fte < 800: return 12, 12 # peak if fte < 1500: return 9, 12 if fte < 3000: return 4, 12 if fte < 7500: return 1, 12 return 0, 12
def score_reps(row): """Field rep count → score. Pure-positive (peak 60-89 reps).""" raw = ti(row.get('Sales Reps')) or ti(row.get('Field Rep Count')) if raw is None or raw < 10: return 0, 18 if raw < 20: return 1, 18 if raw < 30: return 4, 18 if raw < 50: return 11, 18 if raw < 60: return 16, 18 if raw < 90: return 18, 18 # peak if raw < 120: return 16, 18 if raw < 150: return 12, 18 if raw < 200: return 6, 18 if raw < 300: return 2, 18 if raw < 500: return 1, 18 return 0, 18
def score_revenue(row): """Revenue → score. Log-shaped, pure-positive.""" rev = tf(row.get('Revenue vF')) if rev is None or rev <= 0: return 0, 12 if rev < 5e6: return 0, 12 if rev < 25e6: return 2, 12 if rev < 100e6: return 5, 12 if rev < 250e6: return 8, 12 if rev < 500e6: return 10, 12 if rev < 1e9: return 11, 12 if rev < 5e9: return 12, 12 # peak if rev < 25e9: return 6, 12 return 0, 12
Add the rest of the axes per your rubric (score_pe, score_product, score_tech, etc.).
Each must return (raw, max). Keep them pure-positive.
============================================================
HARD DQ: pure-positive architecture expects expanded DQ list
Move objectively wrong-fit categories here so enrichment credits aren't spent
on rows that can never become customers.
============================================================
HARD_DQ_INDUSTRY_PATTERNS = ( 'software development', 'saas', 'computer software', 'information technology and services', 'financial services', 'banking', 'insurance', 'investment management', 'restaurants', 'real estate', 'higher education', )
def hard_dq(row): """Return (is_dq, reason). Expanded for pure-positive architecture.""" if s(row.get('DQ2')).lower() == 'true': return True, 'dq2' if s(row.get('Quick DQ')).lower() == 'true': return True, 'quick_dq' fte = ti(row.get('FTEs vF')) or 0 if fte < 50: return True, 'ftes<50' if fte > 10000: return True, 'mega_corp(>10K FTE)' # Fortune 100 # Expanded: objective wrong-fit industries (pure-positive only) if ARCHITECTURE == 'pure-positive': ni = s(row.get('Normal Industry vF')).lower() if any(p in ni for p in HARD_DQ_INDUSTRY_PATTERNS): return True, f'industry({ni[:30]})' return False, ''
============================================================
CROSS-AXIS HELPERS (only used in with-multipliers architecture)
In pure-positive and pure-card-with-negatives: these are NOT called.
============================================================
def negative_penalty(row): """Cross-axis penalty column. Only used in 'with-multipliers' architecture.""" pen = 0 if s(row.get('AI Industry v3')) == 'Other': pen -= 20 pt = s(row.get('Product Type vF')).lower() if pt == 'software': pen -= 40 elif pt in ('service', 'services'): pen -= 12 return pen
def fit_multiplier(industry_g, pe_g, product_g): """Range 0.4-1.8. Compounds top-tier fit. with-multipliers only.""" adv = industry_g * 0.5 + pe_g * 0.25 + product_g * 0.25 return 0.4 + 1.4 * adv
COMPLETENESS_FIELDS = ['FTEs vF', 'Sales Reps', 'Revenue vF', 'PE Backers', 'Technologies Text', 'Product Count vF', 'Founded Year vF'] def completeness_multiplier(row): """0.70-1.00. with-multipliers only.""" populated = sum(1 for c in COMPLETENESS_FIELDS if s(row.get(c))) return 0.7 + 0.3 * (populated / len(COMPLETENESS_FIELDS))
============================================================
TWIN-FIRM TEST CONFIG
============================================================
TWIN_FIRM_TEMPLATE_NAME = 'Example Co' # a real top-15 account in your dataset TWIN_FIRM_AXES = [ ('Revenue', 'Revenue vF', [1e6, 1e7, 5e7, 1e8, 2.5e8, 5e8, 1e9, 5e9, 2.5e10]), ('FTEs', 'FTEs vF', [50, 100, 200, 400, 800, 1500, 3000, 7500]), ('Sales Reps', 'Sales Reps', [10, 20, 30, 50, 100, 150, 250, 500]), ]
============================================================
CORE SCORING
============================================================
def score_row(row): """Score one row. Dispatches on ARCHITECTURE.
pure-positive: Grand Total = sum of card raw scores. Always ≥ 0.
pure-card-with-negatives: Grand Total = sum of card raw scores. Can be < 0.
with-multipliers: Grand Total = (weighted sum × Fit_Mult × Compl_Mult) + Penalty.
"""
dq, reason = hard_dq(row)
if dq:
return {'dq': True, 'dq_reason': reason, 'final_pts': 0, 'final_pct': 0, 'tier': 'DQ'}
raw = {}
for axis, fn in [
('industry', score_industry),
('employees', score_employees),
('reps', score_reps),
('revenue', score_revenue),
# add the rest per your rubric
]:
r, m = fn(row)
raw[axis] = r
# In pure-positive / pure-card-with-negatives, card max = weight (the
# scaling lives in the band values themselves). Grand Total is a pure sum.
if ARCHITECTURE in ('pure-positive', 'pure-card-with-negatives'):
final_pts = sum(raw.values())
if ARCHITECTURE == 'pure-positive':
final_pts = max(0, final_pts) # safety floor; shouldn't trigger
final_pct = max(-0.5, min(1.0, final_pts / 100))
fmult = cmult = 1.0
pen = 0
# In with-multipliers, the card emits 0-1 Golden (or raw/max), then we
# apply weights + Fit_Multiplier + Completeness_Multiplier + Negative_Penalty.
else: # 'with-multipliers'
golden = {axis: r / WEIGHTS.get(axis, 1) for axis, r in raw.items()}
weighted = sum(golden.get(a, 0) * WEIGHTS.get(a, 0) for a in WEIGHTS)
fmult = fit_multiplier(golden.get('industry', 0),
golden.get('pe', 0),
golden.get('product', 0))
cmult = completeness_multiplier(row)
pen = negative_penalty(row)
final_pts = weighted * fmult * cmult + pen
final_pct = max(-0.5, min(1.0, final_pts / 100))
# Tier assignment
tier = 'Low' if ARCHITECTURE == 'pure-positive' else 'F'
for t, cut in TIER_CUTOFFS.items():
if final_pts >= cut:
tier = t
break
return {'dq': False, 'dq_reason': '',
'final_pts': round(final_pts, 2), 'final_pct': round(final_pct, 4),
'tier': tier, 'fit_mult': round(fmult, 3), 'compl_mult': round(cmult, 3),
'arch': ARCHITECTURE,
**{f'raw_{k}': round(v, 2) for k, v in raw.items()}}
============================================================
REPORT GENERATION
============================================================
def report(scored, original_pcts): lines = [] def p(*a): lines.append(' '.join(str(x) for x in a))
real = [(r, sc) for r, sc in scored if not sc['dq']]
pcts = [sc['final_pct'] for r, sc in real]
pts = [sc['final_pts'] for r, sc in real]
n = len(scored)
p(f"Total rows: {n} | After DQ: {len(real)} | Version: v{VERSION}")
p('')
# Side-by-side stats
p('=== Headline distribution: original vs v{} ==='.format(VERSION))
p(f"{'Metric':28s} {'Original':>12s} {'v' + str(VERSION):>12s} {'Δ':>10s}")
if original_pcts:
for label, fn in [('stdev', statistics.stdev), ('mean', statistics.mean),
('median', statistics.median), ('P25', lambda v: sorted(v)[int(len(v)*0.25)]),
('P75', lambda v: sorted(v)[int(len(v)*0.75)]),
('P95', lambda v: sorted(v)[int(len(v)*0.95)])]:
o = fn(original_pcts); n_v = fn(pcts)
p(f"{label:28s} {o:>12.4f} {n_v:>12.4f} {n_v - o:>+10.4f}")
# Concentration check
ctr_new = Counter(round(x, 2) for x in pcts)
top_new = ctr_new.most_common(1)[0]
if original_pcts:
ctr_old = Counter(round(x, 2) for x in original_pcts)
top_old = ctr_old.most_common(1)[0]
p(f"{'Largest 0.01-bucket':28s} {100*top_old[1]/len(original_pcts):>11.2f}% {100*top_new[1]/len(pcts):>11.2f}%")
p('')
# Tier counts
tier_ctr = Counter(sc['tier'] for r, sc in scored)
p('Tier distribution:')
for t in ['A', 'B', 'C', 'D', 'F', 'DQ']:
c = tier_ctr.get(t, 0)
p(f" {t}: {c} ({100*c/n:.1f}%)")
p('')
# Histogram
p('Histogram v{} (5-pt bins, after DQ):'.format(VERSION))
bh = Counter()
for x in pts:
bh[round(x / 5) * 5] += 1
scale = max(1, max(bh.values()) // 50) if bh else 1
for k in sorted(bh):
bar = '#' * (bh[k] // scale)
p(f" {k:>6.0f}: {bh[k]:>6} {bar}")
p('')
# DQ-threshold validation
threshold_pts = DQ_THRESHOLD_PCT * 100
below = sum(1 for x in pts if x < threshold_pts)
above = sum(1 for x in pts if x >= threshold_pts)
valley_window = sum(1 for x in pts if threshold_pts - 5 <= x <= threshold_pts + 5)
p(f'=== DQ-threshold check ({DQ_THRESHOLD_PCT:.0%} = {threshold_pts:.0f} pts) ===')
p(f" Below threshold: {below} ({100*below/len(pts):.1f}%)")
p(f" Above threshold: {above} ({100*above/len(pts):.1f}%)")
p(f" In ±5 pt valley: {valley_window} ({100*valley_window/len(pts):.1f}%) : lower is better, ≤5% is good")
p('')
return lines
def twin_firm_test(rows): """Find the template firm, clone with varied axis values, score, report spread.""" lines = [] def p(*a): lines.append(' '.join(str(x) for x in a)) template = None for r in rows: if s(r.get(NAME_COLUMN)) == TWIN_FIRM_TEMPLATE_NAME: template = dict(r) break if not template: p(f'(twin-firm template "{TWIN_FIRM_TEMPLATE_NAME}" not in dataset: skipping test)') return lines
p(f'=== Twin-firm test (template: {TWIN_FIRM_TEMPLATE_NAME}) ===')
p('Same firm scored with one axis varied. Spread shows visible stack-rank differentiation.')
p('')
for axis_name, col, values in TWIN_FIRM_AXES:
results = []
for v in values:
tcopy = dict(template)
tcopy[col] = str(v)
sc = score_row(tcopy)
results.append((v, sc['final_pts']))
spread = max(r[1] for r in results) - min(r[1] for r in results)
p(f' {axis_name} ({col}): spread = {spread:>5.1f} pts ' +
' '.join(f'{v:>10.0f}→{pts:>6.1f}' for v, pts in results[:5]) + ' ...')
gate = 'PASS' if spread >= 10 else 'FAIL: bump weight on this axis (if uncorrelated with top axes)'
p(f' gate (spread ≥ 10 pts): {gate}')
p('')
return lines
============================================================
MAIN
============================================================
def main():
assert sum(WEIGHTS.values()) >= 95 and sum(WEIGHTS.values()) <= 105,
f"WEIGHTS sum = {sum(WEIGHTS.values())}: should be ~100"
print(f"Loading {SRC}...", file=sys.stderr)
with open(SRC) as f:
rows = list(csv.DictReader(f))
print(f"Scoring {len(rows)} rows v{VERSION}...", file=sys.stderr)
scored = [(r, score_row(r)) for r in rows]
original_pcts = []
if CURRENT_FINAL_PCT_COLUMN:
original_pcts = [tf(r.get(CURRENT_FINAL_PCT_COLUMN))
for r, sc in scored if not sc['dq']]
original_pcts = [x for x in original_pcts if x is not None]
# Write per-row CSV
breakdown_cols = ['dq', 'dq_reason', 'final_pts', 'final_pct', 'tier',
'fit_mult', 'compl_mult'] + [f'g_{k}' for k in WEIGHTS]
keep_in = [NAME_COLUMN, 'Domain vF', 'AI Industry vF', 'FTEs vF',
'Sales Reps', 'Revenue vF', 'PE Backers',
CURRENT_FINAL_PCT_COLUMN] if CURRENT_FINAL_PCT_COLUMN else \
[NAME_COLUMN, 'Domain vF', 'AI Industry vF', 'FTEs vF',
'Sales Reps', 'Revenue vF', 'PE Backers']
keep_in = [c for c in keep_in if c]
with open(OUT_CSV, 'w', newline='') as f:
w = csv.writer(f)
w.writerow(keep_in + [f'v{VERSION}_{c}' for c in breakdown_cols])
for r, sc in scored:
w.writerow([r.get(c, '') for c in keep_in] +
[sc.get(c, '') for c in breakdown_cols])
print(f"Wrote {OUT_CSV}", file=sys.stderr)
# Reports
lines = []
lines.extend(report(scored, original_pcts))
lines.extend(twin_firm_test(rows))
# Top 15
real = [(r, sc) for r, sc in scored if not sc['dq']]
rk = sorted(real, key=lambda x: -x[1]['final_pts'])
lines.append(f'=== Top 15 v{VERSION} ===')
for r, sc in rk[:15]:
nm = s(r.get(NAME_COLUMN))[:32]
ind = s(r.get('AI Industry vF'))[:18]
lines.append(f" {sc['final_pts']:>6.1f} {sc['tier']} | {nm:<32} | {ind:<18}")
lines.append('')
lines.append('=== Bottom 5 v{} ==='.format(VERSION))
for r, sc in rk[-5:]:
nm = s(r.get(NAME_COLUMN))[:32]
lines.append(f" {sc['final_pts']:>6.1f} {sc['tier']} | {nm}")
out = '\n'.join(lines)
with open(OUT_TXT, 'w') as f:
f.write(out)
print(out)
print(f"\nWrote {OUT_TXT}")
if name == 'main': main()