Agency Skills
SkillsCollectionsContributorsContribute
Install every skill
Agency Skills

Skills written by the people who run agencies, consultancies and coaching firms. Free and open source, MIT.

LibraryAll skillsCollectionsContributorsInstall every skillFor AI agents (llms.txt)
ContributeHow to contributeContributor guideGitHubReport a problem
GiaCompany BrainBook a build session
Built by the community. Published by Gia.PrivacyTerms
Skills/Run the firm/TAM scoring audit and distribution fix
RevOps

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, OneGTM
Say something like
“rescore this list”“fix the scoring”“why is everyone scoring the same”“distribution is compressed”“twin-firm test”
clayhubspotlead-scoringtamicpclaydata-analysis
SKILL.mdView on GitHub

Scoring 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.

  1. 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.

  2. 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.

  3. Phase 2: Apply levers within the chosen architecture. See lever table below. Pull only the ones available to your architecture.

  4. Phase 3: Simulate side-by-side. Original vs proposed, on the same row pool. Report stdev, histogram, twin-firm spread per axis, tier counts.

  5. Phase 4: Validate against the 6 gates. Fail any gate → iterate within Phase 2.

  6. 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):

InputRequiredForm
Accounts databaseyesCSV / Clay export / DB query: one row per company, all enrichment columns + current scoring columns
Per-criterion scoresyesAlready-computed score columns per row (the *_Score, *_Golden, Grand Total, Final % columns Clay produces, or equivalent)
Scoring rubric definitionyesThe bands per card + per-criterion weight + Grand Total formula. Either documented (table) or inferable from the score columns + screenshots
Client contextrecommendedWhat they sell, ICP definition, named good-fit firms
DQ threshold targetrecommendedThe 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/:

FileContents
RECOMMENDATIONS.mdTL;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].pyPer-row simulation script
scored_v[N].csvPer-row score + breakdown columns for the revised model
summary_v[N].txtHistogram, percentiles, top-K, twin-firm spread, tier counts
display_score_lookup.txtGT → Display Score mapping table + paste-ready Clay IF formula (only when bell curve normalization is applied)
(optional) COST_PLAN.mdOnly if new enrichment is required to close real data gaps

Every recommendation must include:

  1. Proposed new distribution (stdev + histogram shape + twin-firm spread)
  2. Quantified improvement over the original
  3. The new bands/scores/weights/multiplier formulas in copy-paste form for the client's tool (Clay scoring cards or formula columns)
  4. Impact on the Final % for a small set of named firms (top 5, bottom 5, 3 known-good targets)
  5. 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

MetricWhat it measuresTarget depends on architecture
stdev of Final % (within DQ=False pool)Overall spread across the listPure-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.

LeverWhat it doesFree in Clay?Pure-card OK?
1. More bands per cardIncreases 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.YesYes
2. Balance weights across axesFlatter weight distribution → more independent contributors summing → bell-shaped histogram. Don't pile weight onto correlated axes: it creates 5%-of-list pile-ups.YesYes
3. Deep negative band values within cardsA 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.YesYes
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 moveWhy 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 bandsMathematically 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 coverageIf 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:

ArchitectureWhat you can useWhat you give upRealistic 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 negativesEach 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-multipliersPer-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:

ArchitectureEnrichment cost trajectoryStack-rank cleanness
Pure-positive + smart Hard DQLower (no enrichment on wrong-fit)Cleanest (0-100% range, no negatives to explain)
Pure-card with negativesHigher (everything gets enriched, even -25-scoring rows)Negative scores in UI
With-multipliersHighest + 3 formula columns to maintainMultiplier 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)

  1. 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.
  2. Normalization-cap check (for each *_Golden column, 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.
  3. Band-gap detector: inspect keyword bands (1-5, 11-25, 26-50, ...). Firms whose raw value falls in a gap silently score 0.
  4. 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 1LeverConcrete fix
Card has ≤4 bands but covers wide value range1 (bands)Add bands; log-shape for right-tailed numerics
Two firms identical except axis X differ by <5 pts2 (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 cap3 (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-fit4 (penalty)Add Negative_Penalty formula column; make penalties ≥-50 for hard misfits
Histogram is bimodal or step-shaped2 (weights)More balanced weights → smoother curve
Many firms at exactly the mediansourcingRe-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

GateTargetIf 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 eachWeight too low on the failing axis; bump it (only if uncorrelated)
DQ threshold separationDQ rows median ≤ threshold − 10 pts; real candidates median ≥ threshold + 10 ptsNegative_Penalty too shallow; deepen
Top-K named good-fit firms≥ 80% appear in A or B tierSourcing 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 flowchart
  • references/distribution_techniques.md: formulas: log transforms, multiplicative gates, type-aware scoring, percentile normalization, completeness multipliers
  • references/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 with ARCHITECTURE toggle, 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).
9 reference files
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) or Between (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.9 not 0.
  • Open-ended upper bounds: 251-999999 not 251+.
  • 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:

  1. Export non-DQ pool's Grand Total values
  2. Compute percentile rank per row → inverse-normal CDF → scale to mean=50, std=20
  3. Average Display Score per GT value to build a lookup table
  4. Implement as a nested IF formula in Clay (see references/distribution_techniques.md §5a)
  5. 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:

ScaleWhen to useDisplay valuesTier formula (T1 = best, T20 = worst)
0-1 (recommended)Display Score column type = Number, matches Final Score Percentage convention0.000 to 1.000"T" + (20 - Math.floor(Math.min(0.99, ({{Display Score}} || 0)) / 0.05))
0-100Display Score column type = Number, want literal integer-ish values0 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

GroupBehavior in service-firm rubricBehavior in corporate rubric
Universal (country, FTE, public filings, domain mentions, domain leadership, recent activity)Score normallyScore normally
Service-firm-only (practitioner count, specialist count, classification, domain expertise, number of offices)Score normallyWeight = 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 = 0Score 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

AxisSourceBands
Filing-heavy industryNAICS / industry classificationCore 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 sizeLinkedIn count of employees with the relevant role keywords in titlelog10 transform, max 3
Filing history sizePublic Filingslog10 transform, max 3
Public filings mention the domainEDGAR full-textboolean = +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:

  1. Export the non-DQ pool's Grand Total column
  2. Run the inverse-normal CDF transform (script in references/simulate_scoring.py)
  3. Average Display Score per GT value to produce the new lookup table
  4. 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

SignalPenalty
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

SignalPenalty
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 profileApply
Median in 10-25%, top tail thin (< 1% above 60%)Log transforms + multiplicative gate
Top 10 too close to top 100Multiplicative fit gate
Same score on rows with vastly different enrichment depthCompleteness multiplier
Two populations (service firms + corporates) compressed togetherType-aware scoring
Headline distribution looks bad to non-technical reviewerPercentile blend
Raw stdev plateaued at 10-18 due to low coverage on heavy axes; reps need 0-100Bell curve normalization (§5a)
Misfit rows scoring same as low-data correct-fit rowsNegative signals / soft DQ
Default-when-null in a derived columnPhase 1.1: fix the bug first, don't paper over with technique
Normalization cap < 1.0Phase 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

LeverMechanismTypical stdev deltaTwin-firm spread deltaArchitecture
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.06huge per-axis (3-band → 9-band on Revenue moves spread from <1 pt to 12+ pts)All
2. Balance weightsSpread 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 axisAll
3. Wide tier gaps within positive bandsTier 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.05n/aAll
4. Expanded Hard DQ for objective no-fitMove "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/aAll: especially aligned with Clay's "DQ stops enrichment"
5. Deep negative bands within cardsPush 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.10n/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 tierWith-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

LeverCommon 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

SymptomPull this leverExpected result
Histogram has a spike of 30%+ of rows in one bin1 (bands): that card has too few bandsSpike spreads across multiple bins
Card has 3 bands but the data spans 5+ orders of magnitude1 (bands)8-10 log-shaped bands
Two firms identical except one axis differ by <5 pts2 (weights)Bump that axis weight (if uncorrelated)
Top-K firms all tied at the cap3 (multiplier) + remove capTop stretches 20-40 pts above the rest
Largest 0.01-pct bucket > 5% (pile-up)2 (weights): likely overweighted top axesTrim top, lift mid-tier
Distribution looks bimodal2 (weights): usually means one mega-axis dominates the othersFlatten 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-up3+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 ANOT a lever: investigate sourcing gap or specific bug in Phase 1.1Fix root cause

Worked example: Client A scoring engagement

With-multipliers track (full architecture)

StageWhat changedstdevTwin-firm RevenueLargest bucketA-tier %Lever(s) used
Original Clay (44,723 rows)baseline0.178<1 pt3.79%~15% (3,400)n/a
v4Bug fixes + re-banded all cards0.2394 ptsn/a (clamp pile-up)0.9% (396)1
v5+ reweight (Revenue 0→7, Employee 7→10)0.2394 ptsn/a (clamp pile-up)1.1% (476)2 (no stdev impact)
v6+ drop clamp + widen Fit_Multiplier 0.5-1.60.2807 pts3.15%1.7% (782)4
v7c+ wider Fit_Multiplier 0.4-1.8 + ~2× penalty0.36813 pts3.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→120.36720.6 pts2.96%3.4%2 (now visible in twin-firm)

Pure-card with-negatives track

StageWhat changedstdevTwin-firm RevenueA-tier %F-tier %Lever(s) used
v10Pure card, moderate bands0.18811 pts0.5%0.1%1
v10b+ aggressive bands (Other=−15, software=−8)0.22812 pts0.3%8.2%1+5 (moderate)
v10c (pure-card final)+ max-aggressive bands (Other=−25, software=−15) + intra-card PE tie-breaker0.27915 pts0.2%14.5%1+2+5 maxed

Pure-positive + smart Hard DQ track (RECOMMENDED for Clay GTM workflows)

StageWhat changedstdev (est.)Pool sizeArchitecture 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
StageWhat changedRaw stdevDisplay stdevArchitecture
Originalbaseline8.2n/a:
v3Aggressive DQ, percentile-based bands on every card, weight moved from saturated axes to data-rich ones, maxes sum to 10014.6n/aPure-positive + smart Hard DQ
v3 + bell curveDisplay Score column: inverse-normal CDF on percentile rank, mean 50, std 20. Cards untouched14.620.2Pure-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 strips const declarations, 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:

CheckPass 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:

  1. Pick a real top-15 firm as the template
  2. Clone it 8-10 times, varying ONLY axis X across its full realistic range
  3. Score all clones
  4. 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):

  1. Default-when-null : derived nonzero rate >> source coverage rate
  2. Normalization cap : *_Golden / *_Pct max < 1.0
  3. Band gaps : gaps in keyword-based rubric definitions (manual entry)
  4. 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

MetricWhat it measuresTarget (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

LeverMechanismStdev gainArchitecture availability
1. Add bandsMore 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.06All
2. Balance weightsSpread 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.01All
3. Wide tier gaps within positive bandsTier 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.05All
4. Expanded Hard DQ for objective no-fitMove "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 cardsIndustry "Other" = −25, Product "software" = −15. Creates F-tier pile separate from candidates.+0.04 to +0.10Pure-card-with-negatives only
6. Cross-axis multipliersFit_Multiplier (0.4-1.8 swing) + Negative_Penalty formula column. Compounds top-tier fit across axes.+0.06 to +0.12With-multipliers only

4. What does NOT widen distribution (common traps)

Tempting moveWhy 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 bandsMathematically 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 coverageIf 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.

ArchitectureWhat you can useWhat you give upStdev 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 negativesEach 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-multipliersPer-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.

BugDetectionClient A example
Default-when-nullDerived score column has nonzero rate >> source column coverage ratePE Score Golden = 0.25 on 95% of rows because PE Backers Count defaulted to 1
Normalization-capA *_Golden column's observed max caps well below 1.0Territories Golden capped at 0.44 because divisor was too large
Band-gapKeyword bands have gaps (1-5, 11-25, ...: missing 6-10)Specialist bands missed 6-10 range in a prior engagement
Empty enrichmentColumn referenced in rubric but <2% populatedDistinct Company Countries = 15.5% populated, contributed to Territories cap
VC-counted-as-PEPE detection treats any investor name as PE123 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

GateTargetIf 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 eachBump axis weight (only if uncorrelated)
DQ threshold separationDQ rows median ≤ threshold − 10 pts; real candidates median ≥ threshold + 10 ptsDeepen Lever 4 or Lever 5
Top-K named good-fit firms appear in A or B≥ 80% of known-good firmsSourcing gap OR specific bug: re-run Phase 1.1
Histogram shapeBell-shaped around median; clear separation between F/Low tier and the restApply 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.

VersionWhat changedStdevTwin-firm RevenueArchitecture
Original Claybaseline0.178<1 ptn/a
v4Bug fixes + re-banded all cards0.2394 ptsbug-fix prep
v5+ reweight (Revenue 0→7, Employee 7→10)0.2394 ptsreweight (no stdev gain: correlation)
v6+ drop bottom clamp + Fit_Multiplier 0.5-1.60.2807 ptswith-multipliers
v7c+ wider Fit_Multiplier 0.4-1.8 + 2× penalty0.36813 ptswith-multipliers max
v9+ balance weights0.36720.6 ptswith-multipliers final
v10Switch to pure-card, moderate bands0.18811 ptspure-card baseline
v10b+ aggressive negative bands0.22812 ptspure-card-with-negatives
v10c+ max-aggressive negatives (Other=−25, software=−15)0.27915 ptspure-card-with-negatives max
v10d (shipped)+ remove all negatives, expand Hard DQ for objective misfits~0.22~10-12 ptspure-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:

TierDisplay 0-1 rangeExpected popNotes
T10.95-1.00 (best)~2.4%Top priority outreach
T20.90-0.95~0.6%
T30.85-0.90~1.9%
T50.75-0.80~4.6%
T100.50-0.55 (median)~10.7%
T150.25-0.30~4.6%
T200-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:

  1. Loads a CSV of accounts + existing per-criterion scores
  2. Computes a baseline (the client's current model) and a revised model
  3. Reports side-by-side: stdev, histogram, percentiles, largest pile-up bucket, A/B/C/D/F tier counts, and TWIN-FIRM SPREAD per axis
  4. 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()

Install this skill

Pick the app you use. We remember it for next time.

  1. Download the skill as a ZIP.
  2. In Claude, open Customize, then Skills, then Your skills.
  3. Choose Upload skill and pick the ZIP. Leave it switched on.
Download ZIPOpen Claude skills

Then ask: “rescore this list”

Want the whole library, kept up to date? .

Run it on your own playbookBook 30 minutes. We build it onto a Company Brain live.
Run the firmcollection
RevOpscategory
MITlicense

Install this skill

Pick the app you use. We remember it for next time.

  1. Download the skill as a ZIP.
  2. In Claude, open Customize, then Skills, then Your skills.
  3. Choose Upload skill and pick the ZIP. Leave it switched on.
Download ZIPOpen Claude skills

Then ask: “rescore this list”

Want the whole library, kept up to date? .

Want this sounding like you?

Out of the box it runs on general knowledge. On a Company Brain it runs on your framework, your pricing and the proposals you’ve already won with, so the output comes back in your voice.

Book 30 minutes

More from Garrett

Customer Success

Client performance review doc

Use this skill when an agency owes a client its periodic outbound performance review (QBR, 30-60-90, "three months in") and wants a commentable doc the client reads before the call. Pulls the numbers by channel and campaign, writes the summary, levers, what has not worked, new plays and the client's asks, adds a data appendix (TAM, who replies, objections), renders clean tables and charts, and runs the comment-and-call loop. Also use for "performance review", "QBR", "results doc", "write up how outbound is going".

Garrett WolfeFounder, OneGTM
Sales

Cold email sequence that gets positive replies

Use this skill when an agency is writing, rewriting or reviewing cold email for a client: a full sequence, a subject line, first line, P.S., CTA, offer, follow-up step, spintax, a signal or trigger campaign, conference outreach, a segment and offer map, or an A/B test. Walks the decisions in order (value lever, list and signal, offer, subject, first line, body framework, CTA, follow-ups), lints the copy and sizes or reads tests. Also use for "build a sequence", "why isn't this campaign getting replies", "did variant B win".

Garrett WolfeFounder, OneGTM
Sales

Sent cold email content review

Use this skill when an agency needs to QA every cold email a client campaign has actually sent: names and company names rendering correctly, variables filled, message coherence, pitch accuracy, ICP fit, signal-to-message alignment, sender and signature consistency, CTA calibration and cross-campaign duplication. Produces findings tagged by severity and fix location with a prioritized action list. Also use for "review our emails", "QA our outbound", "are our emails going out correctly".

Garrett WolfeFounder, OneGTM

More in Run the firm

Hiring

Candidate interview coach

Use this skill when a sales manager is about to interview a salesperson, has just finished one, or is about to make an offer. Three modes: /prep turns a CV or LinkedIn profile into red flags, forensic commercial-maths questions, a 45-second cold-call roleplay and accountability probes; /evaluate scores interview notes or a transcript on five dimensions with a hire, conditional or reject call; /contract generates a signature-ready Pre-Employment Accountability Agreement. Also use for "interview questions for a sales hire", "is this candidate any good", "sales rep CV review", "accountability agreement", "forensic interview".

Tony DowlingTony DowlingDirector, CONVRG
Operations

CEO exec assistant

Use this skill when the person running a small agency needs to know what the week actually demands, pulled from the project tool and the calendar rather than from memory. Produces a short brief: what is committed today and this week, what is at risk, what the calendar makes impossible, and the three things to do first. Also use for "what's on this week", "am I missing anything", "catch me up before Monday", "what's slipping".

Christian BeckChristian BeckCOO and Managing Partner, Five Four
Sales Coaching

Commercial KPI scoreboard

Use this skill when a sales manager or rep asks how the numbers look for a rep or the whole team over today, this week, this month or the quarter. Produces a six-row scoreboard from live CRM deal data: presentations made, closing ratio, sales velocity, average order value, current billing, and position against target, with a 2 to 3 line commercial read. Also use for "how is Danny doing this month", "team scoreboard", "closing ratio", "sales velocity", "are we on target", "KPI check".

Tony DowlingTony DowlingDirector, CONVRG