Ctrl K
ComunidadGuías…

funnel-velocity.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * funnel-velocity.mjs — Funnel calibration vs market benchmarks + stage velocity
 *
 * Three payloads, decreasing availability:
 *   1. calibration — own funnel rates (canonical ever* definition imported from
 *      stats.mjs) vs candidate-side market benchmark ranges. Works day one;
 *      folds status-log history when the ledger is present so a row rejected
 *      after an interview still counts as having reached Interview (#3493).
 *   2. waiting — in-flight Applied rows and elapsed days vs the typical
 *      first-response window. Per-row factual reporting, not an aggregate claim.
 *   3. velocity — median/p75 days per stage hop, folded from the append-only
 *      transition ledger data/status-log.tsv. Accrues value as the log grows.
 *
 * Transition ledger (written by set-status.mjs, never edited in place):
 *   {tracker#}\t{YYYY-MM-DD}\t{from}\t{to}\t{source}\t{note}
 *   - from may be "-" (unknown prior state)
 *   - to "-" retracts the row's latest observation
 *   - source "correction" replaces an earlier same-(num,to) observation
 *   - log path = sibling of the active tracker file (sandbox-safe for tests)
 *
 * Statistical honesty rules (council-reviewed, non-negotiable):
 *   - medians report right-censoring ("n still waiting, excluded") — with 61%
 *     ghosting, completed-only medians are survivorship-biased otherwise
 *   - 0-day hops (same-day catch-up entries) are excluded from medians, counted
 *   - no comparative multiplier claims below n=20 applied
 *   - above-range calibration always carries the selection-bias note
 *   - every benchmark mention carries its year and "directional"
 *
 * Benchmarks lookup order: --benchmarks <path> > config/benchmarks.yml (user
 * layer, survives updates) > templates/benchmarks.yml (shipped default).
 *
 * Run: node funnel-velocity.mjs             (JSON)
 *      node funnel-velocity.mjs --summary   (human-readable)
 *      node funnel-velocity.mjs --self-test
 *      node funnel-velocity.mjs --benchmarks path/to/benchmarks.yml
 */

import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { loadYaml } from './lib/yaml-document.mjs';
import { computeFunnel, computeFunnelWithHistory, parseStatusLogStages, trackerStatusByNum, computeTrackerStats } from './stats.mjs';
import { resolveColumns, parseTrackerRow } from './tracker-parse.mjs';
import { resolveTrackerPath, loadCanonicalStates, resolveCanonicalState } from './tracker-utils.mjs';
import { getCareerOpsRoot } from './path-resolver.mjs';
import { parseAppliedDate, normalizeStatus, loadFollowupsEnabled } from './followup-cadence.mjs';
import { flagValue, validateFlags } from './lib/cli-flags.mjs';
import { localToday } from './lib/local-today.mjs';
import { isMainModule } from './lib/is-main-module.mjs';

// Two roots, because this file reads both layers and they are not the same
// directory. CODE_ROOT is where the shipped templates live; DATA_ROOT is where
// the USER's tracker and overrides live, and getCareerOpsRoot() is what honours
// CAREER_OPS_ROOT / CAREER_OPS_DATA_DIR / the .career-ops-data marker.
//
// One name for both is how this went wrong: the constant was called CAREER_OPS
// and held __dirname, so it read correctly for templates and silently pointed
// the tracker lookup at the checkout. A user with a data root configured got
// "No tracker found ... nothing to calibrate yet" — indistinguishable from
// having no data.
const CODE_ROOT = dirname(fileURLToPath(import.meta.url));
const DATA_ROOT = getCareerOpsRoot();
const STATES_FILE = join(CODE_ROOT, 'templates/states.yml');

const KNOWN_FLAGS = ['--summary', '--self-test', '--benchmarks', '--help', '-h'];
const VALUE_FLAGS = ['--benchmarks'];

const USAGE = `Usage:
  node funnel-velocity.mjs                       # JSON report
  node funnel-velocity.mjs --summary             # human-readable report
  node funnel-velocity.mjs --benchmarks <path>   # override the benchmark YAML file
  node funnel-velocity.mjs --self-test           # run the built-in fixtures
  node funnel-velocity.mjs --help|-h              # show this message`;

const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// `web` sits alongside `set-status` because it is the same class of event: a
// status change made in the web app is recorded as the user makes it, not
// reconstructed afterwards. Only the door differs.
// `reply-watch` is the same class: the user confirms the update interactively;
// the transition is observed at that moment, not reconstructed later.
// `dedup-tracker` is the opposite class: the kept row of a duplicate pair takes
// over the removed row's more advanced status, and that transition was found
// at dedup time, not observed then (#5536).
// `close-stale` is the same class: close-stale.mjs moves a long-silent Applied
// row to Discarded in bulk, dated the day it ran. That is when the silence was
// declared, not when anything happened, so it must not read as a hop (#5794).
const VALID_SOURCES = new Set(['set-status', 'web', 'correction', 'backfill', 'manual', 'reply-watch', 'dedup-tracker', 'close-stale']);
// Sources whose dates are trusted for day-math. backfill/manual/dedup-tracker/close-stale
// are parsed and counted but excluded: they are reconstructed after the fact,
// not observed.
const DAY_MATH_SOURCES = new Set(['set-status', 'web', 'correction', 'reply-watch']);

// The hops a candidate can measure from their own tracker. Applied→Rejected is
// tracked separately from the forward hops — a "days to terminal" number that
// mixes offers and rejections reads grim and means nothing.
const HOPS = [
  { key: 'appliedToResponded', from: 'Applied', to: 'Responded' },
  { key: 'respondedToAssessment', from: 'Responded', to: 'Assessment' },
  { key: 'assessmentToInterview', from: 'Assessment', to: 'Interview' },
  // Keep the direct hop for legacy rows that moved from Responded straight to
  // Interview before Assessment became a first-class lifecycle stage.
  { key: 'respondedToInterview', from: 'Responded', to: 'Interview', excludeIntermediate: 'Assessment' },
  { key: 'interviewToOffer', from: 'Interview', to: 'Offer' },
  { key: 'appliedToRejected', from: 'Applied', to: 'Rejected' },
];

// Verbatim strings the tone contract pins (also asserted by mode-doc checks).
const SELECTION_BIAS_NOTE = 'targeted applications are expected to beat mass-platform averages — this confirms your filtering works';
const BELOW_RANGE_ACTION = '→ check follow-up compliance (followup mode) or review your score threshold (patterns mode)';
const BELOW_RANGE_ACTION_NO_FOLLOWUPS = '→ review your score threshold (patterns mode)';
const CLAIM_MIN_N = 20;
const HOP_MIN_N = 3;

// --- Date helpers ---
export function parseISODate(s) {
  if (!DATE_RE.test(String(s ?? '').trim())) return null;
  const d = new Date(`${String(s).trim()}T00:00:00Z`);
  return Number.isNaN(d.getTime()) ? null : d;
}

export function daysBetween(fromStr, toStr) {
  const a = parseISODate(fromStr), b = parseISODate(toStr);
  if (!a || !b) return null;
  return Math.round((b - a) / 86400000);
}

// --- Ledger parsing ---
// line: {tracker#}\t{YYYY-MM-DD}\t{from}\t{to}\t{source}\t{note}
export function parseStatusLog(content, states) {
  const observations = [];
  const unparseable = [];
  const unknownSources = [];
  const lines = String(content || '').replace(/\r/g, '').split('\n');
  for (let i = 0; i < lines.length; i++) {
    const t = lines[i].trim();
    if (!t || t.startsWith('#')) continue;
    const cells = t.split('\t').map(c => c.trim());
    if (cells.length < 5) { unparseable.push({ line: i + 1, raw: t, reason: 'expected 5+ tab-separated columns' }); continue; }
    const [numRaw, date, fromRaw, toRaw, source, note = ''] = cells;
    const num = parseInt(numRaw, 10);
    if (!Number.isInteger(num) || String(num) !== numRaw) { unparseable.push({ line: i + 1, raw: t, reason: `bad tracker# "${numRaw}"` }); continue; }
    if (!parseISODate(date)) { unparseable.push({ line: i + 1, raw: t, reason: `bad date "${date}"` }); continue; }
    const from = fromRaw === '-' ? null : resolveCanonicalState(fromRaw, states);
    if (fromRaw !== '-' && !from) { unparseable.push({ line: i + 1, raw: t, reason: `unknown from-state "${fromRaw}"` }); continue; }
    const to = toRaw === '-' ? '-' : resolveCanonicalState(toRaw, states);
    if (toRaw !== '-' && !to) { unparseable.push({ line: i + 1, raw: t, reason: `unknown to-state "${toRaw}"` }); continue; }
    if (!VALID_SOURCES.has(source)) {
      unknownSources.push({ line: i + 1, num, source });
      // Parsed but never fed to day-math; still visible in dataQuality.
      observations.push({ num, date, from, to, source, note, dayMath: false });
      continue;
    }
    observations.push({ num, date, from, to, source, note, dayMath: DAY_MATH_SOURCES.has(source) });
  }
  return { observations, unparseable, unknownSources };
}

/**
 * Replay observations in file order, one timeline per tracker#, applying the
 * ledger's edit rules:
 *   - later `correction` with same (num, to) replaces the earlier observation's
 *     date (the fix for "agent logged the wrong day")
 *   - `to: "-"` retracts the row's latest surviving observation (the fix for
 *     "that transition never happened")
 *   - otherwise observations accumulate in file order
 *
 * File order is what a retraction pops, so the last entry is the change a new
 * retraction line would remove (set-status.mjs --undo reads it).
 *
 * @returns Map num -> [{from, to, date, source, dayMath}]
 */
export function replayObservations(observations) {
  const byNum = new Map();
  for (const obs of observations) {
    if (!byNum.has(obs.num)) byNum.set(obs.num, []);
    const timeline = byNum.get(obs.num);
    if (obs.to === '-') { timeline.pop(); continue; }
    if (obs.source === 'correction') {
      const idx = timeline.map(o => o.to).lastIndexOf(obs.to);
      if (idx !== -1) { timeline[idx] = { from: timeline[idx].from, to: obs.to, date: obs.date, source: obs.source, dayMath: obs.dayMath }; continue; }
    }
    timeline.push({ from: obs.from, to: obs.to, date: obs.date, source: obs.source, dayMath: obs.dayMath });
  }
  return byNum;
}

/**
 * Fold observations into one timeline per tracker#: replayObservations(), then
 * sorted by date so hop math sees event order, not entry order (--on
 * backdating makes file order unreliable, so dates may be non-monotonic in the
 * log). Stable sort keeps same-day catch-up entries in entry order.
 *
 * @returns Map num -> [{from, to, date, source, dayMath}]
 */
export function foldObservations(observations) {
  const byNum = replayObservations(observations);
  for (const timeline of byNum.values()) {
    timeline.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
  }
  return byNum;
}

// --- Percentiles ---
export function median(nums) {
  if (!nums.length) return null;
  const s = [...nums].sort((a, b) => a - b);
  const m = Math.floor(s.length / 2);
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}

// p75 by linear interpolation between closest ranks (R-7, the numpy default).
// Pinned by self-test fixture: [3,6,20] -> 13.
export function p75(nums) {
  if (!nums.length) return null;
  const s = [...nums].sort((a, b) => a - b);
  if (s.length === 1) return s[0];
  const rank = 0.75 * (s.length - 1);
  const lo = Math.floor(rank), hi = Math.ceil(rank);
  return s[lo] + (s[hi] - s[lo]) * (rank - lo);
}

// --- Velocity ---
/**
 * Per-hop day stats from folded timelines.
 *
 * A hop measurement = days between a row's dated `from` observation and its
 * next dated `to` observation (both day-math-trusted). Honesty rules:
 *   - 0-day hops excluded from the math, counted (same-day catch-up entries)
 *   - right-censoring: a row sitting in `from` with no later observation
 *     counts as censored for that hop (still waiting — median excludes it and
 *     must say so)
 *   - < HOP_MIN_N completed measurements -> insufficientData, no median
 */
export function computeVelocity(timelines, todayStr) {
  const result = {};
  for (const hop of HOPS) {
    const days = [];
    let sameDayExcluded = 0;
    let censored = 0;
    for (const timeline of timelines.values()) {
      // A row can re-enter the from-state (re-applied after a rejection), so
      // each entry starts its own measurement instead of the row's first one.
      let start = null;
      let left = false;
      let viaIntermediate = false;
      for (const o of timeline) {
        if (o.to === hop.from) {
          if (!start || left) { start = o.dayMath ? o : null; left = false; viaIntermediate = false; }
          continue;
        }
        if (!start) continue;
        if (o.to === hop.to && o.dayMath) {
          const d = daysBetween(start.date, o.date);
          start = null;
          if (viaIntermediate || d === null || d < 0) continue;
          if (d === 0) { sameDayExcluded++; continue; }
          days.push(d);
          continue;
        }
        left = true;
        if (o.to === hop.excludeIntermediate) viaIntermediate = true;
      }
      // Row's latest state is the hop's from-state: still waiting = censored.
      // Only forward hops censor; Applied→Rejected shares Applied rows with
      // Applied→Responded, so count censoring once (on the forward hop).
      if (start && timeline.at(-1).to === hop.from && hop.to !== 'Rejected') censored++;
    }
    result[hop.key] = {
      from: hop.from,
      to: hop.to,
      n: days.length,
      median: days.length >= HOP_MIN_N ? median(days) : null,
      p75: days.length >= HOP_MIN_N ? p75(days) : null,
      insufficientData: days.length < HOP_MIN_N,
      sameDayExcluded,
      censored,
    };
  }
  return result;
}

// --- Benchmarks ---
export function loadBenchmarks(explicitPath) {
  const path = explicitPath
    || (existsSync(join(DATA_ROOT, 'config/benchmarks.yml')) ? join(DATA_ROOT, 'config/benchmarks.yml') : join(CODE_ROOT, 'templates/benchmarks.yml'));
  let doc;
  try {
    doc = loadYaml(readFileSync(path, 'utf-8'));
  } catch (err) {
    throw new Error(`Cannot read benchmarks at ${path}: ${err.message}`);
  }
  if (!doc || typeof doc.benchmarks !== 'object' || doc.benchmarks === null) {
    throw new Error(`Malformed benchmarks file at ${path}: expected a top-level "benchmarks" map`);
  }
  return { benchmarks: doc.benchmarks, path };
}

/** Classify an own-rate percentage against a benchmark's range (inclusive). */
export function classify(ownPct, metric) {
  if (ownPct === null || ownPct === undefined || !metric || !Array.isArray(metric.range_pct)) return null;
  const [lo, hi] = metric.range_pct;
  const band = ownPct < lo ? 'below-range' : ownPct > hi ? 'above-range' : 'within-range';
  const typical = metric.typical_pct;
  return {
    band,
    ownPct,
    rangePct: [lo, hi],
    typicalPct: typical ?? null,
    vsTypical: typical ? Math.round((ownPct / typical) * 10) / 10 : null,
    source: metric.source ?? null,
    year: metric.year ?? null,
    caveat: metric.caveat ?? null,
  };
}

// --- Calibration (flagship: works day one, no ledger needed) ---
export function computeCalibration(funnel, benchmarks) {
  const smallSample = funnel.everApplied < CLAIM_MIN_N;
  return {
    everApplied: funnel.everApplied,
    smallSample,
    claimMinN: CLAIM_MIN_N,
    // 'ledger' when the ever* counts fold status-log history (rejected-after-
    // interview rows counted at the stage they reached), 'snapshot' otherwise.
    basis: funnel.basis === 'ledger' ? 'ledger' : 'snapshot',
    responseRate: classify(funnel.everApplied > 0 ? funnel.responseRate : null, benchmarks.response_rate),
    interviewRate: classify(funnel.everApplied > 0 ? funnel.interviewRate : null, benchmarks.application_to_interview),
  };
}

// --- Waiting (flagship #2: in-flight Applied rows vs first-response window) ---
/**
 * Applied-date priority per row:
 *   1. ledger Applied observation (event-dated via --on, or logged same day)
 *   2. "Applied YYYY-MM-DD" in the tracker notes (followup-cadence convention;
 *      followup-seed/apply modes write it — same helper, same regex)
 *   3. unknown (listed, never guessed — the tracker date column is the
 *      EVALUATION date and must not stand in for the submission date)
 */
export function computeWaiting(rows, timelines, benchmarks, todayStr) {
  const windowDays = benchmarks.days_first_response?.range_days ?? [5, 14];
  const items = [];
  let unknownDates = 0;
  for (const row of rows) {
    // Canonicalize before comparing — the funnel counts everApplied through
    // normalizeStatus, so non-canonical Applied rows (lowercase "applied", a
    // status carrying a date, localized "Aplicado", markdown-bold "**Applied**")
    // must surface here too, or the follow-up backlog is under-reported.
    if (normalizeStatus(row.status) !== 'applied') continue;
    const timeline = timelines.get(row.num) || [];
    const ledgerApplied = timeline.filter(o => o.to === 'Applied' && o.dayMath).pop();
    const appliedDate = ledgerApplied?.date ?? parseAppliedDate(row.notes) ?? null;
    if (!appliedDate) { unknownDates++; items.push({ num: row.num, company: row.company, appliedDate: null, elapsedDays: null, beyondTypicalWindow: false, dateSource: 'unknown' }); continue; }
    const elapsed = daysBetween(appliedDate, todayStr);
    items.push({
      num: row.num,
      company: row.company,
      appliedDate,
      elapsedDays: elapsed,
      beyondTypicalWindow: elapsed !== null && elapsed > windowDays[1],
      dateSource: ledgerApplied ? 'status-log' : 'tracker-notes',
    });
  }
  return {
    windowDays,
    windowSource: benchmarks.days_first_response ? { source: benchmarks.days_first_response.source, year: benchmarks.days_first_response.year } : null,
    inFlight: items.length,
    unknownDates,
    items: items.sort((a, b) => (b.elapsedDays ?? -1) - (a.elapsedDays ?? -1)),
  };
}

// --- Tracker rows ---
export function parseTrackerRows(content) {
  const lines = String(content ?? '').replace(/\r/g, '').split('\n');
  const colmap = resolveColumns(lines);
  const rows = [];
  for (const line of lines) {
    const row = parseTrackerRow(line, colmap);
    if (row) rows.push(row);
  }
  return rows;
}

// --- Assembly ---
export function analyze({ trackerContent, logContent, benchmarks, states, todayStr }) {
  const rows = parseTrackerRows(trackerContent);
  const stats = computeTrackerStats(trackerContent);
  // Ledger-aware funnel when the transition log carries usable history: a row now
  // sitting in a terminal snapshot (Rejected/Discarded) still counts for the
  // middle stages it passed through, so interviewRate/responseRate stop
  // undercounting rejected-after-interview rows (#3493). Same canonical
  // definition `node stats.mjs` uses. Branch on the parsed stages, not the raw
  // file: a header-only, comment-only, or all-malformed log has no history to
  // fold, and taking the ledger path anyway would stamp basis:'ledger' on a
  // funnel identical to the snapshot and print the "rates fold history" note.
  const ledgerStages = parseStatusLogStages(logContent);
  const funnel = ledgerStages.length > 0
    ? computeFunnelWithHistory(trackerStatusByNum(trackerContent), ledgerStages)
    : computeFunnel(stats.byStatus);
  const { observations, unparseable, unknownSources } = parseStatusLog(logContent, states);
  const timelines = foldObservations(observations);

  const trackerNums = new Set(rows.map(r => r.num));
  const orphans = [...timelines.keys()].filter(n => !trackerNums.has(n));
  const coveredRows = [...timelines.keys()].filter(n => trackerNums.has(n)).length;
  const newestObs = observations.reduce((max, o) => (o.date > max ? o.date : max), '');

  const velocity = computeVelocity(timelines, todayStr);
  // The one hop with a candidate-side day benchmark gets it attached, so the
  // renderer can show market context next to the own-median (same denominator:
  // per-successful-process). Gated on an existing median: with insufficientData
  // the JSON output would otherwise leak a benchmark next to a null median.
  const io = benchmarks.days_interview_to_offer;
  if (io && Array.isArray(io.range_days) && velocity.interviewToOffer.median !== null) {
    velocity.interviewToOffer.benchmark = {
      rangeDays: io.range_days,
      typicalDays: io.typical_days ?? null,
      source: io.source ?? null,
      year: io.year ?? null,
    };
  }

  return {
    calibration: computeCalibration(funnel, benchmarks),
    waiting: computeWaiting(rows, timelines, benchmarks, todayStr),
    velocity,
    dataQuality: {
      trackerRows: rows.length,
      coveredRows,
      observations: observations.length,
      orphans,
      unparseable,
      unknownSources,
      newestObservation: newestObs || null,
    },
  };
}

// --- Summary rendering (tone contract lives here — see header) ---
function fmtCalibrationLine(label, c, smallSample, n, belowRangeAction) {
  if (!c || c.ownPct === null) return `  ${label}: no data yet`;
  const range = `${c.rangePct[0]}–${c.rangePct[1]}% typical band (${c.year ?? 'n/a'}, directional)`;
  let line = `  ${label}: ${c.ownPct}% vs ${range}`;
  if (smallSample) {
    line += ` — small sample (n=${n}) — directional only`;
    return line;
  }
  if (c.band === 'above-range') {
    line += ` — above the typical band${c.vsTypical ? `, ${c.vsTypical}× typical` : ''} (${SELECTION_BIAS_NOTE})`;
  } else if (c.band === 'below-range') {
    line += ` — below the typical band ${belowRangeAction}`;
  } else {
    line += ' — within the typical band';
  }
  return line;
}

export function renderSummary(result, todayStr, { followupsEnabled = true } = {}) {
  const out = [];
  const belowRangeAction = followupsEnabled ? BELOW_RANGE_ACTION : BELOW_RANGE_ACTION_NO_FOLLOWUPS;
  const { calibration: cal, waiting, velocity, dataQuality: dq } = result;
  out.push('━'.repeat(46));
  out.push(`Funnel Calibration — ${todayStr}`);
  out.push('━'.repeat(46));

  out.push('\nCalibration (your funnel vs market):');
  if (cal.everApplied === 0) {
    out.push('  no applications sent yet — calibration starts at your first Applied row');
  } else {
    out.push(fmtCalibrationLine('Response rate', cal.responseRate, cal.smallSample, cal.everApplied, belowRangeAction));
    out.push(fmtCalibrationLine('Interview rate', cal.interviewRate, cal.smallSample, cal.everApplied, belowRangeAction));
    if (cal.basis === 'ledger') out.push('  (rates fold status-log history — a row rejected after an interview still counts as reaching Interview)');
    if (cal.smallSample) out.push(`  (comparative claims need n≥${cal.claimMinN} applied; you have ${cal.everApplied})`);
  }

  out.push('\nWaiting (in-flight applications):');
  if (!waiting.inFlight) {
    out.push('  none in Applied right now');
  } else {
    out.push(`  ${waiting.inFlight} in flight. Typical first-response window: ${waiting.windowDays[0]}–${waiting.windowDays[1]} days (${waiting.windowSource?.year ?? 'n/a'}, directional; many applications never get a response — silence is common, not a verdict).`);
    for (const item of waiting.items) {
      if (item.appliedDate === null) { out.push(`  #${item.num} ${item.company} — applied date unknown (no dated Applied observation; add "Applied YYYY-MM-DD" to its notes or use set-status)`); continue; }
      const flag = item.beyondTypicalWindow ? `, beyond typical ${waiting.windowDays[0]}–${waiting.windowDays[1]}d window${followupsEnabled ? ' → consider followup mode' : ''}` : '';
      out.push(`  #${item.num} ${item.company} — applied ${item.appliedDate} (${item.elapsedDays}d${flag})`);
    }
  }

  out.push('\nVelocity (days per stage, from the transition ledger):');
  const hopsWithData = Object.values(velocity).filter(h => !h.insufficientData);
  for (const h of Object.values(velocity)) {
    const extras = [];
    if (h.censored) extras.push(`${h.censored} still waiting, excluded`);
    if (h.sameDayExcluded) extras.push(`${h.sameDayExcluded} same-day catch-up entr${h.sameDayExcluded === 1 ? 'y' : 'ies'} excluded`);
    const extraStr = extras.length ? `; ${extras.join('; ')}` : '';
    if (h.insufficientData) {
      out.push(`  ${h.from}→${h.to}: insufficient data (n=${h.n}${extraStr})`);
    } else {
      const bm = h.benchmark ? ` vs ${h.benchmark.rangeDays[0]}–${h.benchmark.rangeDays[1]}d typical (${h.benchmark.year ?? 'n/a'}, directional)` : '';
      out.push(`  ${h.from}→${h.to}: median ${h.median}d, p75 ${h.p75}d${bm} (n=${h.n} completed${extraStr} — median reflects answered applications only)`);
    }
  }
  if (!hopsWithData.length && dq.observations === 0) {
    out.push('  ledger is empty — velocity accrues as statuses change through set-status.mjs');
  }

  out.push('\nData quality:');
  out.push(`  velocity data for ${dq.coveredRows} of ${dq.trackerRows} tracker rows (rows predating the log or edited outside set-status have no dated transitions)`);
  if (dq.unparseable.length) {
    out.push(`  ⚠ ${dq.unparseable.length} unparseable ledger line${dq.unparseable.length === 1 ? '' : 's'}:`);
    for (const u of dq.unparseable) out.push(`      line ${u.line}: ${u.reason}`);
  } else out.push('  unparseable ledger lines: none');
  if (dq.unknownSources.length) {
    out.push(`  ⚠ ${dq.unknownSources.length} observation${dq.unknownSources.length === 1 ? '' : 's'} with unrecognized source (excluded from day-math):`);
    for (const s of dq.unknownSources) out.push(`      line ${s.line}: #${s.num} source "${s.source}"`);
  } else out.push('  unrecognized sources: none');
  if (dq.orphans.length) {
    out.push(`  ⚠ ${dq.orphans.length} orphaned tracker#${dq.orphans.length === 1 ? '' : 's'} in the ledger (renumbering/dedup can strand them): ${dq.orphans.map(n => `#${n}`).join(', ')}`);
  } else out.push('  orphaned ledger entries: none');
  if (dq.newestObservation) out.push(`  newest observation: ${dq.newestObservation}`);
  out.push('');
  return out.join('\n');
}

// --- Self-test ---
function selfTest() {
  let failures = 0;
  const check = (cond, msg) => {
    if (!cond) { console.error(`SELF-TEST FAIL: ${msg}`); failures++; }
  };
  const states = loadCanonicalStates(STATES_FILE);
  const TODAY = '2026-07-08';

  // -- parser --
  const LOG_FIXTURE = [
    '1\t2026-06-01\tEvaluated\tApplied\tset-status\t',
    '1\t2026-06-08\tApplied\tResponded\tset-status\t',
    '1\t2026-06-15\tResponded\tInterview\tset-status\t',
    '1\t2026-06-30\tInterview\tOffer\tset-status\t',
    '2\t2026-06-01\t-\tApplied\tset-status\tunknown prior state',
    '2\t2026-06-11\tApplied\tResponded\tset-status\t',
    '2\t2026-06-09\tApplied\tResponded\tcorrection\tthey actually replied on the 9th',
    '3\t2026-06-05\tEvaluated\tApplied\tset-status\t',
    '3\t2026-06-05\tApplied\tResponded\tset-status\tsame-day catch-up',
    '4\t2026-06-20\tEvaluated\tApplied\tset-status\t',
    '5\t2026-06-01\tEvaluated\tApplied\tset-status\t',
    '5\t2026-06-02\tApplied\tInterview\tset-status\tmis-click',
    '5\t2026-06-02\t-\t-\tset-status\tretract the mis-click',
    '6\t2026-06-03\tEvaluated\tApplied\tfuture-import\tunknown source on purpose',
    '99\t2026-06-01\tEvaluated\tApplied\tset-status\torphan - not in tracker',
    'x\t2026-06-01\tEvaluated\tApplied\tset-status\tbad num',
    '7\t06/01/2026\tEvaluated\tApplied\tset-status\tbad date',
    '8\t2026-06-01\tEvaluated\tShortlisted\tset-status\tnon-canonical state',
    '',
    '# comment line',
  ].join('\n');

  const { observations, unparseable, unknownSources } = parseStatusLog(LOG_FIXTURE, states);
  check(unparseable.length === 3, `parser: expected 3 unparseable, got ${unparseable.length}`);
  check(unparseable.some(u => u.reason.includes('bad tracker#')), 'parser: bad num reported');
  check(unparseable.some(u => u.reason.includes('bad date')), 'parser: bad date reported');
  check(unparseable.some(u => u.reason.includes('unknown to-state')), 'parser: non-canonical state reported');
  check(unknownSources.length === 1 && unknownSources[0].source === 'future-import', 'parser: unknown source counted');
  check(observations.find(o => o.source === 'future-import')?.dayMath === false, 'parser: unknown source excluded from day-math');
  check(observations.find(o => o.num === 2 && o.from === null), 'parser: "-" from-state parses as null');

  // A status change made from the web app is observed live, at the moment the
  // user makes it — the same event as a set-status transition entering through
  // a different door. Excluding it does not drop the rows, which is what makes
  // the failure quiet: they are parsed, counted, flagged under unknownSources,
  // and contribute nothing to the hop figures, so a web-driven install reads as
  // an empty funnel rather than a broken one. Its own fixture so the counts
  // pinned above stay pinned.
  const WEB_FIXTURE = [
    '1\t2026-06-01\tEvaluated\tApplied\tweb\t',
    '1\t2026-06-08\tApplied\tResponded\tweb\t',
  ].join('\n');
  const web = parseStatusLog(WEB_FIXTURE, states);
  check(web.unknownSources.length === 0, `parser: web is a recognized source (got ${web.unknownSources.length} unknown)`);
  check(web.observations.length === 2 && web.observations.every(o => o.dayMath === true),
    'parser: web observations are trusted for day-math');

  // The rows above are a clean 7-day Applied→Responded hop. Asserting the
  // parse alone would not have caught this: the defect was never that the rows
  // failed to parse, it was that a parsed row reached no figure.
  const webVelocity = computeVelocity(foldObservations(web.observations), TODAY);
  check(webVelocity.appliedToResponded.n === 1,
    `velocity: a web transition reaches the hop figures (expected n=1, got ${webVelocity.appliedToResponded.n})`);

  // reply-watch is the same event class as set-status/web: the user confirms the
  // update interactively and it is recorded at that moment (not reconstructed
  // after the fact the way backfill/manual are). A reply-watch transition that is
  // excluded from day-math silently empties the velocity figures for users who
  // manage their inbox through reply-watch. Its own fixture so the counts above
  // stay pinned and this source is covered independently.
  const REPLY_WATCH_FIXTURE = [
    '1\t2026-06-01\tEvaluated\tApplied\treply-watch\t',
    '1\t2026-06-09\tApplied\tResponded\treply-watch\t',
  ].join('\n');
  const rw = parseStatusLog(REPLY_WATCH_FIXTURE, states);
  check(rw.unknownSources.length === 0, `parser: reply-watch is a recognized source (got ${rw.unknownSources.length} unknown)`);
  check(rw.observations.length === 2 && rw.observations.every(o => o.dayMath === true),
    'parser: reply-watch observations are trusted for day-math');
  const rwVelocity = computeVelocity(foldObservations(rw.observations), TODAY);
  check(rwVelocity.appliedToResponded.n === 1,
    `velocity: a reply-watch transition reaches the hop figures (expected n=1, got ${rwVelocity.appliedToResponded.n})`);

  // -- fold --
  const timelines = foldObservations(observations);
  const t2 = timelines.get(2);
  check(t2.length === 2, `fold: row 2 expected 2 observations, got ${t2.length}`);
  check(t2[1].date === '2026-06-09' && t2[1].source === 'correction', 'fold: correction replaces same-(num,to) date');
  const t5 = timelines.get(5);
  check(t5.length === 1 && t5[0].to === 'Applied', `fold: retraction removes latest observation (got ${t5.map(o => o.to).join(',')})`);

  // -- percentiles --
  check(median([3, 6, 20]) === 6, 'median odd');
  check(median([3, 6]) === 4.5, 'median even');
  check(p75([3, 6, 20]) === 13, `p75 interpolation: expected 13, got ${p75([3, 6, 20])}`);
  check(p75([5]) === 5, 'p75 single value');

  // -- velocity --
  const velocity = computeVelocity(timelines, TODAY);
  check(velocity.appliedToResponded.n === 2, `velocity: A→R expected n=2 (rows 1,2), got ${velocity.appliedToResponded.n}`);
  check(velocity.appliedToResponded.insufficientData === true, 'velocity: n=2 < 3 → insufficient');
  check(velocity.appliedToResponded.median === null, 'velocity: insufficient → no median');
  check(velocity.appliedToResponded.sameDayExcluded === 1, `velocity: row 3 same-day hop excluded+counted, got ${velocity.appliedToResponded.sameDayExcluded}`);
  // rows 4, 5, 99 sit in Applied with no later observation → censored (still waiting)
  check(velocity.appliedToResponded.censored === 3, `velocity: expected 3 censored, got ${velocity.appliedToResponded.censored}`);
  check(velocity.appliedToRejected.censored === 0, 'velocity: rejection hop does not double-count censoring');
  check(velocity.interviewToOffer.n === 1 && velocity.interviewToOffer.insufficientData, 'velocity: I→O n=1 insufficient');

  const ASSESSMENT_FIXTURE = [
    '10\t2026-06-01\tApplied\tResponded\tset-status\t',
    '10\t2026-06-04\tResponded\tAssessment\tset-status\t',
    '10\t2026-06-09\tAssessment\tInterview\tset-status\t',
  ].join('\n');
  const assessmentVelocity = computeVelocity(
    foldObservations(parseStatusLog(ASSESSMENT_FIXTURE, states).observations),
    TODAY,
  );
  check(assessmentVelocity.respondedToAssessment.n === 1,
    `velocity: Responded→Assessment expected n=1, got ${assessmentVelocity.respondedToAssessment.n}`);
  check(assessmentVelocity.assessmentToInterview.n === 1,
    `velocity: Assessment→Interview expected n=1, got ${assessmentVelocity.assessmentToInterview.n}`);
  check(assessmentVelocity.respondedToInterview.n === 0,
    `velocity: Assessment path must not count as legacy Responded→Interview, got n=${assessmentVelocity.respondedToInterview.n}`);

  // three completed A→R measurements → median renders
  const logWithThird = LOG_FIXTURE + '\n4\t2026-06-27\tApplied\tResponded\tset-status\t';
  const v3 = computeVelocity(foldObservations(parseStatusLog(logWithThird, states).observations), TODAY);
  check(v3.appliedToResponded.n === 3 && v3.appliedToResponded.median === 7, `velocity: [7,8,7] → median 7, got n=${v3.appliedToResponded.n} median=${v3.appliedToResponded.median}`);
  check(v3.appliedToResponded.censored === 2, 'velocity: censored drops to 2 after row 4 completes');

  // -- benchmarks + classification --
  const bm = loadBenchmarks(join(CODE_ROOT, 'templates/benchmarks.yml')).benchmarks;
  check(bm.response_rate && Array.isArray(bm.response_rate.range_pct), 'benchmarks: shipped file loads');
  check(bm.days_first_response.range_days[1] === 14, 'benchmarks: first-response window upper bound');
  check(!('time_to_fill' in bm), 'benchmarks: employer-side time_to_fill must not exist');
  check(classify(1.5, bm.response_rate).band === 'below-range', 'classify: below');
  check(classify(6, bm.response_rate).band === 'within-range', 'classify: within');
  check(classify(14, bm.response_rate).band === 'above-range', 'classify: above');
  check(classify(2, bm.response_rate).band === 'within-range', 'classify: lower bound inclusive');
  check(classify(13, bm.response_rate).band === 'within-range', 'classify: upper bound inclusive');
  check(classify(6, bm.response_rate).vsTypical === 2, 'classify: vsTypical multiplier');

  // -- calibration gating --
  const mkTracker = (applied, responded) => {
    const header = '| # | Date | Company | Role | Score | Status | PDF | Report | Notes |\n|---|------|---------|------|-------|--------|-----|--------|-------|';
    const rows = [];
    let n = 1;
    for (let i = 0; i < applied; i++) rows.push(`| ${n++} | 2026-06-01 | Co${n} | Role | 4.0/5 | Applied | ❌ | - | |`);
    for (let i = 0; i < responded; i++) rows.push(`| ${n++} | 2026-06-01 | Co${n} | Role | 4.0/5 | Responded | ❌ | - | Applied 2026-06-01 |`);
    return `${header}\n${rows.join('\n')}`;
  };
  const small = analyze({ trackerContent: mkTracker(16, 1), logContent: '', benchmarks: bm, states, todayStr: TODAY });
  check(small.calibration.smallSample === true, 'calibration: n=17 < 20 → smallSample');
  const smallSummary = renderSummary(small, TODAY);
  check(!/[\d.]+× typical/.test(smallSummary), 'tone: no multiplier claim under n=20');
  check(smallSummary.includes('directional only'), 'tone: small-sample label present');

  const big = analyze({ trackerContent: mkTracker(38, 2), logContent: '', benchmarks: bm, states, todayStr: TODAY });
  check(big.calibration.smallSample === false, 'calibration: n=40 → claims allowed');
  check(big.calibration.responseRate.ownPct === 5, `calibration: 2/40 = 5%, got ${big.calibration.responseRate.ownPct}`);
  const bigSummary = renderSummary(big, TODAY);
  check(bigSummary.includes('within the typical band'), 'tone: within-band phrasing');

  // above-range → selection-bias note; below-range → single action pointer
  const above = renderSummary(analyze({ trackerContent: mkTracker(25, 15), logContent: '', benchmarks: bm, states, todayStr: TODAY }), TODAY);
  check(above.includes(SELECTION_BIAS_NOTE), 'tone: above-range carries selection-bias note');
  const below = renderSummary(analyze({ trackerContent: mkTracker(40, 0), logContent: '', benchmarks: bm, states, todayStr: TODAY }), TODAY);
  check(below.includes(BELOW_RANGE_ACTION), 'tone: below-range carries the single action pointer');
  check(below.includes('(2025, directional)'), 'tone: benchmark year + directional attached');

  // -- ledger-aware calibration (#3493): a terminal Rejected snapshot must not
  // erase the middle stages the row actually reached. Same shape stats.mjs uses. --
  const ledgerHeader = '| # | Date | Company | Role | Score | Status | PDF | Report | Notes |\n|---|------|---------|------|-------|--------|-----|--------|-------|';
  const ledgerRows = [];
  for (let i = 1; i <= 21; i++) ledgerRows.push(`| ${i} | 2026-06-01 | Co${i} | Role | 4.0/5 | Applied | ❌ | - | |`);
  for (let i = 22; i <= 25; i++) ledgerRows.push(`| ${i} | 2026-06-01 | Co${i} | Role | 4.0/5 | Rejected | ❌ | - | |`);
  const ledgerTracker = `${ledgerHeader}\n${ledgerRows.join('\n')}`;
  const ledgerLog = [
    '22\t2026-06-02\tApplied\tResponded\tset-status\t',
    '22\t2026-06-09\tResponded\tInterview\tset-status\t',
    '22\t2026-06-20\tInterview\tRejected\tset-status\t',
    '23\t2026-06-02\tApplied\tResponded\tset-status\t',
    '23\t2026-06-09\tResponded\tInterview\tset-status\t',
    '23\t2026-06-20\tInterview\tRejected\tset-status\t',
    '24\t2026-06-03\tApplied\tResponded\tset-status\t',
    '24\t2026-06-18\tResponded\tRejected\tset-status\t',
  ].join('\n');
  const snap = analyze({ trackerContent: ledgerTracker, logContent: '', benchmarks: bm, states, todayStr: TODAY });
  check(snap.calibration.basis === 'snapshot', 'ledger-funnel: no log → snapshot basis');
  check(snap.calibration.interviewRate.ownPct === 0, `ledger-funnel: snapshot erases interviews reached, got ${snap.calibration.interviewRate.ownPct}`);
  const led = analyze({ trackerContent: ledgerTracker, logContent: ledgerLog, benchmarks: bm, states, todayStr: TODAY });
  check(led.calibration.basis === 'ledger', 'ledger-funnel: log present → ledger basis');
  check(led.calibration.everApplied === 25, `ledger-funnel: everApplied still 25, got ${led.calibration.everApplied}`);
  check(led.calibration.interviewRate.ownPct === 8, `ledger-funnel: 2/25 reached Interview → 8%, got ${led.calibration.interviewRate.ownPct}`);
  check(led.calibration.responseRate.ownPct === 16, `ledger-funnel: 4/25 received a reply including rejection → 16%, got ${led.calibration.responseRate.ownPct}`);
  check(renderSummary(led, TODAY).includes('rates fold status-log history'), 'ledger-funnel: summary flags the folded basis');
  check(!renderSummary(snap, TODAY).includes('rates fold status-log history'), 'ledger-funnel: snapshot summary carries no fold note');

  // A log with no *parsed* history — header row, comments, torn rows — must stay
  // on the snapshot basis: taking the ledger path would stamp basis:'ledger' on
  // a funnel identical to the snapshot and wrongly print the fold note.
  for (const [label, emptyLog] of [
    ['header only', '#\tdate\tfrom\tto\tsource\tnote'],
    ['comment only', '# migrated 2026-06-01 — no transitions yet'],
    ['torn rows only', 'x\t2026-06-02\tApplied\tResponded\nx\t2026-06-09\tResponded\tInterview'],
    ['whitespace', '   \n\t\n'],
  ]) {
    const r = analyze({ trackerContent: ledgerTracker, logContent: emptyLog, benchmarks: bm, states, todayStr: TODAY });
    check(r.calibration.basis === 'snapshot', `ledger-funnel: ${label} log → snapshot basis`);
    check(r.calibration.interviewRate.ownPct === snap.calibration.interviewRate.ownPct, `ledger-funnel: ${label} log leaves rates on the snapshot value`);
    check(!renderSummary(r, TODAY).includes('rates fold status-log history'), `ledger-funnel: ${label} log carries no fold note`);
  }

  // everX counts distinct tracker rows, so a transition the ledger records more
  // than once (a re-applied set-status, a torn-then-rewritten append) must not
  // inflate the funnel — the ledger result is identical with each line doubled.
  const dupLog = ledgerLog.split('\n').flatMap(l => [l, l]).join('\n');
  const dup = analyze({ trackerContent: ledgerTracker, logContent: dupLog, benchmarks: bm, states, todayStr: TODAY });
  check(dup.calibration.everApplied === led.calibration.everApplied, `ledger-funnel: duplicated transitions leave everApplied at ${led.calibration.everApplied}, got ${dup.calibration.everApplied}`);
  check(dup.calibration.interviewRate.ownPct === led.calibration.interviewRate.ownPct, `ledger-funnel: duplicated transitions leave interviewRate at ${led.calibration.interviewRate.ownPct}%, got ${dup.calibration.interviewRate.ownPct}%`);
  check(dup.calibration.responseRate.ownPct === led.calibration.responseRate.ownPct, `ledger-funnel: duplicated transitions leave responseRate at ${led.calibration.responseRate.ownPct}%, got ${dup.calibration.responseRate.ownPct}%`);
  // A row that genuinely bounces back and forth is still one row at its deepest
  // stage — Responded→Interview→Responded→Interview counts once into everInterview.
  const bounceLog = [
    '2\t2026-06-02\tApplied\tResponded\tset-status\t',
    '2\t2026-06-09\tResponded\tInterview\tset-status\t',
    '2\t2026-06-12\tInterview\tResponded\tset-status\t',
    '2\t2026-06-15\tResponded\tInterview\tset-status\t',
  ].join('\n');
  const bounce = analyze({ trackerContent: ledgerTracker, logContent: bounceLog, benchmarks: bm, states, todayStr: TODAY });
  check(bounce.calibration.interviewRate.ownPct === 4, `ledger-funnel: a bouncing row counts once → 1/25 = 4%, got ${bounce.calibration.interviewRate.ownPct}`);

  // A current Discarded row with no ledger history stays out of everApplied in
  // both funnels — Discarded (unlike Rejected) does not by itself prove a
  // submission, and the ledger path must not silently diverge from the snapshot.
  const discardTracker = `${ledgerHeader}\n${[
    ...Array.from({ length: 10 }, (_, i) => `| ${i + 1} | 2026-06-01 | Co${i + 1} | Role | 4.0/5 | Applied | ❌ | - | |`),
    '| 11 | 2026-06-01 | Co11 | Role | 4.0/5 | Discarded | ❌ | - | |',
  ].join('\n')}`;
  const discardSnap = analyze({ trackerContent: discardTracker, logContent: '', benchmarks: bm, states, todayStr: TODAY });
  const discardLed = analyze({ trackerContent: discardTracker, logContent: '1\t2026-06-05\tApplied\tResponded\tset-status\t', benchmarks: bm, states, todayStr: TODAY });
  check(discardSnap.calibration.everApplied === 10, `ledger-funnel: snapshot leaves a no-history Discarded row out of everApplied, got ${discardSnap.calibration.everApplied}`);
  check(discardLed.calibration.everApplied === discardSnap.calibration.everApplied, `ledger-funnel: ledger path agrees with snapshot on a no-history Discarded row, got ${discardLed.calibration.everApplied} vs ${discardSnap.calibration.everApplied}`);

  // -- waiting --
  const waitTracker = [
    '| # | Date | Company | Role | Score | Status | PDF | Report | Notes |',
    '|---|------|---------|------|-------|--------|-----|--------|-------|',
    '| 1 | 2026-06-01 | LogCo | Role | 4.0/5 | Applied | ❌ | - | |',
    '| 2 | 2026-06-01 | NotesCo | Role | 4.0/5 | Applied | ❌ | - | Applied 2026-07-01 |',
    '| 3 | 2026-06-01 | UnknownCo | Role | 4.0/5 | Applied | ❌ | - | evaluated only |',
    '| 4 | 2026-06-01 | DoneCo | Role | 4.0/5 | Responded | ❌ | - | Applied 2026-06-01 |',
  ].join('\n');
  const waitLog = '1\t2026-06-10\tEvaluated\tApplied\tset-status\t';
  const wait = analyze({ trackerContent: waitTracker, logContent: waitLog, benchmarks: bm, states, todayStr: TODAY }).waiting;
  check(wait.inFlight === 3, `waiting: 3 Applied rows in flight, got ${wait.inFlight}`);
  const w1 = wait.items.find(i => i.num === 1);
  check(w1.dateSource === 'status-log' && w1.appliedDate === '2026-06-10', 'waiting: ledger date wins');
  check(w1.elapsedDays === 28 && w1.beyondTypicalWindow === true, `waiting: 28d beyond 14d window, got ${w1.elapsedDays}`);
  const w2 = wait.items.find(i => i.num === 2);
  check(w2.dateSource === 'tracker-notes' && w2.elapsedDays === 7 && w2.beyondTypicalWindow === false, 'waiting: notes date fallback, within window');
  const w3 = wait.items.find(i => i.num === 3);
  check(w3.dateSource === 'unknown' && w3.appliedDate === null, 'waiting: unknown date listed, never guessed');
  check(!wait.items.some(i => i.num === 4), 'waiting: non-Applied rows excluded');

  // Non-canonical Applied rows must still count as in-flight. The funnel counts
  // everApplied through normalizeStatus, so lowercase "applied", a status
  // carrying a date, a localized "Aplicado", and markdown-bold "**Applied**"
  // belong in the waiting backlog too (regression: a raw row.status !== 'Applied'
  // filter dropped all four and under-reported follow-ups).
  const noncanonTracker = [
    '| # | Date | Company | Role | Score | Status | PDF | Report | Notes |',
    '|---|------|---------|------|-------|--------|-----|--------|-------|',
    '| 1 | 2026-06-01 | CanonCo | Role | 4.0/5 | Applied | ❌ | - | Applied 2026-07-01 |',
    '| 2 | 2026-06-01 | LowerCo | Role | 4.0/5 | applied | ❌ | - | Applied 2026-07-01 |',
    '| 3 | 2026-06-01 | DatedCo | Role | 4.0/5 | Applied 2026-06-01 | ❌ | - | Applied 2026-07-01 |',
    '| 4 | 2026-06-01 | LocaleCo | Role | 4.0/5 | Aplicado | ❌ | - | Applied 2026-07-01 |',
    '| 5 | 2026-06-01 | BoldCo | Role | 4.0/5 | **Applied** | ❌ | - | Applied 2026-07-01 |',
  ].join('\n');
  const noncanon = analyze({ trackerContent: noncanonTracker, logContent: '', benchmarks: bm, states, todayStr: TODAY });
  check(noncanon.waiting.inFlight === 5, `waiting: non-canonical Applied variants all count in-flight, got ${noncanon.waiting.inFlight}`);
  // The waiting count must agree with the funnel's everApplied for the same rows —
  // both canonicalize status the same way, so neither may silently drop a variant.
  check(noncanon.calibration.everApplied === 5, `waiting: in-flight agrees with everApplied on non-canonical rows, got ${noncanon.calibration.everApplied}`);

  // -- data quality --
  const dq = analyze({ trackerContent: waitTracker, logContent: LOG_FIXTURE, benchmarks: bm, states, todayStr: TODAY }).dataQuality;
  check(dq.orphans.length >= 1 && dq.orphans.includes(99), 'dataQuality: orphan #99 surfaced');
  check(dq.unparseable.length === 3, 'dataQuality: unparseable lines surfaced');
  check(dq.newestObservation === '2026-06-30', `dataQuality: newest observation, got ${dq.newestObservation}`);
  const dqSummary = renderSummary(analyze({ trackerContent: waitTracker, logContent: LOG_FIXTURE, benchmarks: bm, states, todayStr: TODAY }), TODAY);
  check(dqSummary.includes('velocity data for'), 'summary: coverage line present');
  check(dqSummary.includes('still waiting, excluded'), 'summary: censoring surfaced next to velocity');

  // -- interview→offer day benchmark wiring --
  const ioLog = [
    '1\t2026-05-01\tResponded\tInterview\tset-status\t',
    '1\t2026-05-22\tInterview\tOffer\tset-status\t',
    '2\t2026-05-01\tResponded\tInterview\tset-status\t',
    '2\t2026-05-26\tInterview\tOffer\tset-status\t',
    '3\t2026-05-01\tResponded\tInterview\tset-status\t',
    '3\t2026-05-31\tInterview\tOffer\tset-status\t',
  ].join('\n');
  const ioResult = analyze({ trackerContent: waitTracker, logContent: ioLog, benchmarks: bm, states, todayStr: TODAY });
  check(ioResult.velocity.interviewToOffer.benchmark?.rangeDays?.[0] === 20, 'io-benchmark: days_interview_to_offer attached to the hop');
  check(ioResult.velocity.interviewToOffer.n === 3 && ioResult.velocity.interviewToOffer.median === 25, `io-benchmark: [21,25,30] → median 25, got ${ioResult.velocity.interviewToOffer.median}`);
  const ioSummary = renderSummary(ioResult, TODAY);
  check(ioSummary.includes('vs 20–28d typical (2019, directional)'), 'io-benchmark: summary carries the benchmark with year + directional');
  check(!renderSummary(analyze({ trackerContent: waitTracker, logContent: '', benchmarks: bm, states, todayStr: TODAY }), TODAY).includes('20–28d typical'), 'io-benchmark: no benchmark context without a median (claims stay gated)');
  // n=1 I→O hop → insufficientData, median null → benchmark must not leak into JSON output
  const ioInsufficient = analyze({ trackerContent: waitTracker, logContent: LOG_FIXTURE, benchmarks: bm, states, todayStr: TODAY });
  check(ioInsufficient.velocity.interviewToOffer.insufficientData === true && !('benchmark' in ioInsufficient.velocity.interviewToOffer), 'io-benchmark: not attached when median is null (JSON output stays gated)');

  // -- missing year in a user-override benchmark file must render n/a, not null --
  const bmNoYear = JSON.parse(JSON.stringify(bm));
  delete bmNoYear.response_rate.year;
  delete bmNoYear.application_to_interview.year;
  delete bmNoYear.days_interview_to_offer.year;
  const noYearSummary = renderSummary(analyze({ trackerContent: mkTracker(40, 0), logContent: ioLog, benchmarks: bmNoYear, states, todayStr: TODAY }), TODAY);
  check(noYearSummary.includes('(n/a, directional)'), 'no-year: renders n/a fallback');
  check(!noYearSummary.includes('null, directional'), 'no-year: never prints (null, directional)');

  // -- empty state --
  const empty = analyze({ trackerContent: '', logContent: '', benchmarks: bm, states, todayStr: TODAY });
  check(empty.calibration.everApplied === 0, 'empty: zero applied');
  const emptySummary = renderSummary(empty, TODAY);
  check(emptySummary.includes('calibration starts at your first Applied row'), 'empty: friendly zero state');
  check(emptySummary.includes('ledger is empty'), 'empty: ledger explainer');

  if (failures) { console.error(`\n${failures} self-test failure(s)`); process.exit(1); }
  console.log('funnel-velocity self-test OK (parser + fold + percentiles + velocity/censoring + benchmarks + calibration gating + waiting + tone contract)');
}

// --- Main ---
function main() {
  const args = process.argv.slice(2);
  validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true });

  const summaryMode = args.includes('--summary');
  const selfTestMode = args.includes('--self-test');
  if (selfTestMode) { selfTest(); return; }

  let benchmarks;
  try {
    benchmarks = loadBenchmarks(flagValue(args, '--benchmarks')).benchmarks;
  } catch (err) {
    console.error(`Error: ${err.message}`);
    process.exit(1);
  }
  const states = loadCanonicalStates(STATES_FILE);
  const trackerPath = resolveTrackerPath(DATA_ROOT);
  const logPath = join(dirname(trackerPath), 'status-log.tsv');
  const trackerContent = existsSync(trackerPath) ? readFileSync(trackerPath, 'utf-8') : '';
  const logContent = existsSync(logPath) ? readFileSync(logPath, 'utf-8') : '';
  // LOCAL day: the UTC day is tomorrow for a west-of-Greenwich evening run, so
  // every "waiting" figure read one day high (#3070).
  const todayStr = localToday();

  if (!trackerContent) {
    if (summaryMode) console.log(`No tracker found at ${trackerPath} — nothing to calibrate yet.`);
    else console.log(JSON.stringify({ calibration: null, waiting: null, velocity: null, dataQuality: { trackerRows: 0, note: `no tracker at ${trackerPath}` } }, null, 2));
    return;
  }

  const result = analyze({ trackerContent, logContent, benchmarks, states, todayStr });
  if (summaryMode) console.log(renderSummary(result, todayStr, { followupsEnabled: loadFollowupsEnabled() }));
  else console.log(JSON.stringify({ ...result, generatedAt: todayStr }, null, 2));
}

if (isMainModule(import.meta.url)) {
  main();
}