# How `funnel-velocity.mjs` Calibrates Pipeline Performance Against Market Benchmarks in santifer/career-ops

> Discover how funnel-velocity.mjs calibrates job-search pipeline performance against market benchmarks in santifer/career-ops. Understand your funnel metrics clearly.

- Repository: [Santiago Fernández de Valderrama/career-ops](https://github.com/santifer/career-ops)
- Tags: deep-dive
- Published: 2026-08-20

---

**`funnel-velocity.mjs` calibrates your job-search pipeline by comparing actual funnel metrics against market benchmarks loaded from YAML files, classifying each metric as below-range, within-range, or above-range.**

The `funnel-velocity.mjs` script in the [santifer/career-ops](https://github.com/santifer/career-ops) repository transforms raw job application tracking data into actionable performance insights. This article breaks down how the script performs **pipeline performance calibration against market benchmarks** using a four-step process grounded in the source code.

## Loading Benchmark Data from YAML Files

The calibration process begins by resolving a **benchmark configuration file**. The `resolveBenchmarkPath` function (lines 31-34 of `funnel-velocity.mjs`) implements a three-tier fallback strategy:

1. User-provided path via `--benchmarks` CLI flag
2. [`config/benchmarks.yml`](https://github.com/santifer/career-ops/blob/main/config/benchmarks.yml) for user overrides
3. [`templates/benchmarks.yml`](https://github.com/santifer/career-ops/blob/main/templates/benchmarks.yml) as the shipped default

The YAML file must contain a top-level `benchmarks` map. If missing, the script throws an explicit error (lines 40-43):

```javascript
// Excerpt from funnel-velocity.mjs
const data = yaml.load(readFileSync(benchmarkPath, 'utf8'));
if (!data.benchmarks) {
  throw new Error(`Benchmark file ${benchmarkPath} missing top-level 'benchmarks' key`);
}

```

This validation ensures downstream calibration logic receives well-structured data.

## Computing Personal Funnel Statistics

Before comparison, the script derives your **actual funnel metrics** using the `computeFunnel` function imported from `stats.mjs` (lines 41-42). This aggregation produces canonical stage counts including:

- `everApplied` — total applications submitted
- `responseRate` — percentage receiving any response
- `interviewRate` — percentage advancing to interview

These percentages feed directly into the benchmark comparison engine.

## Classifying Metrics Against Benchmark Ranges

The `classify` helper function (lines 46-51) performs the core **calibration logic**. For each metric, it receives:

- Your achieved `percentage`
- The corresponding `benchmark` entry with `range_pct` bounds

The function returns a structured classification:

| Classification | Condition | Meaning |
|---------------|-----------|---------|
| **below-range** | `percentage < range_pct[0]` | Underperforming market expectations |
| **within-range** | `range_pct[0] <= percentage <= range_pct[1]` | Aligned with market norms |
| **above-range** | `percentage > range_pct[1]` | Outperforming market expectations |

When a `typical_pct` value exists in the benchmark, the script additionally computes a **"vs-typical" multiplier** (lines 55-57), quantifying how your performance compares to the median professional.

## Assembling the Calibration Payload

The `computeCalibration` function (lines 66-73) orchestrates final output generation. It first validates sample size against `CLAIM_MIN_N = 20` — the minimum applications required for statistically meaningful claims (lines 66-71). Below this threshold, the `smallSample` flag triggers a disclaimer.

For qualifying datasets, the function produces a comprehensive calibration object combining:

- **Metadata**: `everApplied`, `smallSample`, `claimMinN`
- **Rate objects** for response and interview metrics containing:
  - `band` — the classification result
  - `ownPct` — your actual percentage
  - `rangePct` — benchmark bounds
  - `typicalPct` — median market performance (when available)
  - `vsTypical` — performance ratio
  - `source` and `year` — provenance metadata
  - `caveats` — contextual warnings

## Rendering Contextual Summary Output

The calibration results support dual output modes. The `renderSummary` function (lines 78-89) adds **narrative intelligence**:

- **Above-range cases**: Appends a selection-bias disclaimer (high performers may apply selectively)
- **Below-range cases**: Suggests follow-up actions for funnel improvement

JSON output remains available for programmatic consumption and integrations.

## Running the Calibration Tool

### Default JSON report

```bash
node funnel-velocity.mjs

```

### Human-readable summary with contextual notes

```bash
node funnel-velocity.mjs --summary

```

### Override benchmark file path

```bash
node funnel-velocity.mjs --benchmarks path/to/my-benchmarks.yml

```

### Verify internal logic with self-test

```bash
node funnel-velocity.mjs --self-test

```

## Architecture of the Calibration System

| File | Purpose |
|------|---------|
| `funnel-velocity.mjs` | Core driver: benchmark loading, calibration computation, output formatting |
| `stats.mjs` | `computeFunnel` and `computeTrackerStats` for metric derivation |
| `tracker-parse.mjs` | Parses [`applications.md`](https://github.com/santifer/career-ops/blob/main/applications.md) into structured application records |
| `tracker-utils.mjs` | Canonical state resolution functions |
| [`templates/benchmarks.yml`](https://github.com/santifer/career-ops/blob/main/templates/benchmarks.yml) | Default market benchmarks (response rates, interview rates, timing windows) |
| [`config/benchmarks.yml`](https://github.com/santifer/career-ops/blob/main/config/benchmarks.yml) | Optional user overrides for local market conditions |

## Summary

- **Benchmark resolution** follows a three-tier priority: CLI argument → [`config/benchmarks.yml`](https://github.com/santifer/career-ops/blob/main/config/benchmarks.yml) → [`templates/benchmarks.yml`](https://github.com/santifer/career-ops/blob/main/templates/benchmarks.yml)
- **Classification logic** in `funnel-velocity.mjs` compares your `responseRate` and `interviewRate` against YAML-defined `range_pct` bounds
- **Sample size validation** enforces `CLAIM_MIN_N = 20` minimum applications before claiming statistical significance
- **vs-typical multiplier** quantifies performance relative to median market rates when `typical_pct` data exists
- **Dual output modes** support both machine-readable JSON and human-readable summaries with contextual caveats

## Frequently Asked Questions

### What happens if I have fewer than 20 applications tracked?

The `smallSample` flag activates in your calibration output, suppressing range classifications and adding a disclaimer. The `CLAIM_MIN_N` constant (line 66) enforces this threshold to prevent misleading conclusions from insufficient data.

### How do I customize benchmark ranges for my industry or region?

Create a [`config/benchmarks.yml`](https://github.com/santifer/career-ops/blob/main/config/benchmarks.yml) file with your market data, matching the structure of [`templates/benchmarks.yml`](https://github.com/santifer/career-ops/blob/main/templates/benchmarks.yml). Pass an explicit path via `--benchmarks` if storing outside the default location.

### Why does above-range performance include a disclaimer?

The `renderSummary` function (lines 78-89) appends selection-bias warnings because candidates applying selectively to well-matched positions naturally achieve higher conversion rates than broad-applicants — this doesn't necessarily indicate superior execution.

### What metrics does the calibration currently cover?

As implemented in `funnel-velocity.mjs` lines 72-73, the system calibrates `responseRate` and `interviewRate`. The [`templates/benchmarks.yml`](https://github.com/santifer/career-ops/blob/main/templates/benchmarks.yml) additionally includes timing benchmarks (first-response window, offer-velocity) used by separate waiting and velocity calculations in the same script.