Docs/Skills and code/skill script-cohort-analysis

.claude/skills/script-cohort-analysis/scripts/prepare.js

Code298 lines13.8 KBSkill: script-cohort-analysis

What the file says about itself

Phase 1: blind the cohort, compute deterministic features, freeze the pre-registration, and write one task file per blind rater.

#!/usr/bin/env node
// Phase 1: blind the cohort, compute deterministic features, freeze the pre-registration,
// and write one task file per blind rater.
//
//   node prepare.js --cohort <dir-of-transcript-txts> [--out <runDir>] [--raters 2]
//                   [--seed N] [--ledger <path>] [--metric views_per_day|views|performance]

const fs = require('fs');
const path = require('path');
const { readJson, writeJson, sha256, parseArgs, toSec, rng, shuffle, mean, sd } = require('./lib');
const { FEATURES } = require('./features');
const { renderMarkdown } = require('./checklist');

const SKILL_ROOT = path.resolve(__dirname, '..');
const args = parseArgs(process.argv.slice(2));
if (!args.cohort) {
  console.error('usage: node prepare.js --cohort <dir> [--out <runDir>] [--raters 2] [--seed N] [--ledger <path>] [--metric views_per_day|views]');
  process.exit(1);
}
const cohortDir = path.resolve(args.cohort);
const nRaters = Number(args.raters || 3);
const audience = args.audience || 'viewers of a Hindi-language explainer YouTube channel in India';
const seed = Number(args.seed || Date.now() % 2147483647);
const metric = args.metric || 'views_per_day';
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
const runDir = path.resolve(args.out || path.join(cohortDir, '_runs', stamp));
const ledgerPath = path.resolve(args.ledger || path.join(cohortDir, '..', '_script_ledger.json'));
const lexicon = readJson(path.join(__dirname, 'lexicon.json'));

// ---------- parse transcript files ----------
function parseNum(s) {
  if (!s) return null;
  const m = String(s).replace(/,/g, '').match(/-?\d+(\.\d+)?/);
  return m ? Number(m[0]) : null;
}
function field(text, label) {
  const m = text.match(new RegExp('^' + label + ':\\s*(.*)$', 'm'));
  return m ? m[1].trim() : null;
}
function parseFile(file) {
  const raw = fs.readFileSync(file, 'utf8');
  const tIdx = raw.indexOf('=== TRANSCRIPT ===');
  if (tIdx < 0) throw new Error(`${file}: no "=== TRANSCRIPT ===" section`);
  const header = raw.slice(0, tIdx);
  const body = raw.slice(tIdx);
  const chunks = [];
  for (const line of body.split(/\r?\n/)) {
    const m = line.match(/^\[(\d{1,2}(?::\d{2}){1,2})\]\s*(.*)$/);
    if (m) chunks.push({ ts: m[1], start: toSec(m[1]), text: m[2] });
  }
  const durMatch = (field(header, 'Duration') || '').match(/(?:(\d+)h\s*)?(?:(\d+)m\s*)?(\d+)s/);
  const duration = durMatch
    ? (Number(durMatch[1] || 0) * 3600 + Number(durMatch[2] || 0) * 60 + Number(durMatch[3] || 0))
    : (chunks.length ? chunks[chunks.length - 1].start + 30 : null);
  const ageMatch = (field(header, 'Published') || '').match(/age (\d+) days/);
  const langLine = field(body, 'Language');
  return {
    file: path.basename(file),
    title: field(header, 'Title'),
    video_id: field(header, 'Video ID'),
    bucket: (field(header, 'Sample bucket') || '').split(/\s+/)[0].toLowerCase() || null,
    views: parseNum(field(header, 'Views')),
    views_per_day: parseNum(field(header, 'Views per day')),
    performance_score: parseNum(field(header, 'Performance score')),
    rank_reported: parseNum(field(header, 'Rank by views/day')),
    age_days: ageMatch ? Number(ageMatch[1]) : null,
    duration_sec: duration,
    language: langLine ? langLine.split('[')[0].trim() : 'unknown',
    chunks,
  };
}

// ---------- deterministic features ----------
function tokenize(text) {
  return text.split(/[\s।?!,;:"“”‘’()\[\]…|]+/)
    .map((t) => t.replace(/\.+$/, '').toLowerCase())
    .filter(Boolean);
}
function countTerms(tokens, terms) {
  let n = 0;
  const seqs = terms.map((t) => tokenize(t));
  for (let i = 0; i < tokens.length; i++) {
    for (const s of seqs) {
      if (s.length && s.every((w, k) => tokens[i + k] === w)) n++;
    }
  }
  return n;
}
function sentences(text) {
  return text.split(/(?<=[।?!])|\.(?=\s|$)/).map((s) => s.trim()).filter((s) => tokenize(s).length > 0);
}
function isQuestion(sentence, qset) {
  if (sentence.trim().endsWith('?')) return true;
  return tokenize(sentence).some((t) => qset.has(t));
}
function numberTokens(tokens, magnitude) {
  const mag = new Set(magnitude.map((t) => t.toLowerCase()));
  return tokens.filter((t) => /\d/.test(t) || t.includes('₹') || mag.has(t)).length;
}
function detFeatures(doc) {
  const full = doc.chunks.map((c) => c.text).join(' ');
  const hook = doc.chunks.filter((c) => c.start < 60).map((c) => c.text).join(' ');
  const tokens = tokenize(full);
  const hookTokens = tokenize(hook);
  const W = tokens.length || 1;
  const qset = new Set(lexicon.question_words.map((t) => t.toLowerCase()));
  const sents = sentences(full);
  const lens = sents.map((s) => tokenize(s).length);
  const first = tokens.slice(0, 1500);
  const per100 = (n) => (100 * n) / W;
  return {
    words: tokens.length,
    sentences: sents.length,
    det_question_rate: per100(sents.filter((s) => isQuestion(s, qset)).length),
    det_second_person_rate: per100(countTerms(tokens, lexicon.second_person)),
    det_number_rate: per100(numberTokens(tokens, lexicon.magnitude)),
    det_contrast_rate: per100(countTerms(tokens, lexicon.contrast)),
    det_causal_rate: per100(countTerms(tokens, lexicon.causal)),
    det_additive_rate: per100(countTerms(tokens, lexicon.additive)),
    det_hook_questions: sentences(hook).filter((s) => isQuestion(s, qset)).length,
    det_hook_numbers: numberTokens(hookTokens, lexicon.magnitude),
    det_imagine_rate: (1000 * countTerms(tokens, lexicon.imagine)) / W,
    det_sentence_len_cv: lens.length > 1 ? sd(lens) / mean(lens) : 0,
    det_wpm: doc.duration_sec ? tokens.length / (doc.duration_sec / 60) : null,
    det_ttr: first.length ? new Set(first).size / first.length : null,
  };
}

// ---------- main ----------
const files = fs.readdirSync(cohortDir)
  .filter((f) => f.endsWith('.txt'))
  .map((f) => path.join(cohortDir, f));
if (files.length < 4) {
  console.error(`Need at least 4 transcripts in ${cohortDir}, found ${files.length}.`);
  process.exit(1);
}
const docs = files.map(parseFile);
for (const d of docs) {
  if (d.views_per_day === null && d.views !== null && d.age_days) d.views_per_day = d.views / Math.max(d.age_days, 30);
  // 'performance' = a cross-channel blend (views vs the channel's own norm, views/day, like rate) for cohorts that mix channels.
  d.perf = metric === 'views' ? d.views : metric === 'performance' ? d.performance_score : d.views_per_day;
  if (d.perf === null) { console.error(`${d.file}: no ${metric} found`); process.exit(1); }
}

const rand = rng(seed);
const order = shuffle(docs, rand);
const titleOrder = shuffle(order.map((_, i) => i), rand);
const pidOf = {};
titleOrder.forEach((idx, k) => { pidOf[idx] = `P${k + 1}`; });

fs.mkdirSync(path.join(runDir, 'blind'), { recursive: true });
fs.mkdirSync(path.join(runDir, 'scores'), { recursive: true });
fs.mkdirSync(path.join(runDir, 'tasks'), { recursive: true });

const key = {};
const features = {};
order.forEach((d, i) => {
  const sid = `S${i + 1}`;
  const mins = Math.floor(d.duration_sec / 60), secs = d.duration_sec % 60;
  const blind = [
    `BLIND SAMPLE ${sid}`,
    `Duration: ${mins}m ${String(secs).padStart(2, '0')}s | Transcript language: ${d.language} | Chunks are ~30s, timestamps mark chunk starts`,
    '',
    ...d.chunks.map((c) => `[${c.ts}] ${c.text}`),
    '',
  ].join('\n');
  fs.writeFileSync(path.join(runDir, 'blind', `${sid}.txt`), blind, 'utf8');
  features[sid] = { duration_sec: d.duration_sec, ...detFeatures(d) };
  key[sid] = {
    file: d.file, video_id: d.video_id, title: d.title, bucket: d.bucket,
    views: d.views, views_per_day: d.views_per_day, age_days: d.age_days,
    duration_sec: d.duration_sec, perf: d.perf, pid: pidOf[i],
  };
});
writeJson(path.join(runDir, 'blind', 'titles.json'),
  titleOrder.map((idx, k) => ({ pid: `P${k + 1}`, title: order[idx].title })));
writeJson(path.join(runDir, 'features.json'), features);
writeJson(path.join(runDir, 'sealed', 'key.json'), key);


// ledger hypotheses become extra rater questions
let ledger = { hypotheses: [] };
if (fs.existsSync(ledgerPath)) ledger = readJson(ledgerPath);
const testable = ledger.hypotheses.filter((h) => ['untested', 'testing'].includes(h.status) && h.rater_question);

// the exact rubric this run uses is frozen into the run directory
const checklistMd = renderMarkdown();
fs.writeFileSync(path.join(runDir, 'checklist.md'), checklistMd, 'utf8');
const ref = (f) => path.join(SKILL_ROOT, 'references', f);
const rubricHash = sha256([checklistMd, ...['rubric.md', 'rater-prompt.md', 'pair-prompt.md', 'topic-prompt.md']
  .map((f) => fs.readFileSync(ref(f), 'utf8'))].join('\n')).slice(0, 16);

const prereg = {
  created_at: new Date().toISOString(),
  cohort: cohortDir,
  n: docs.length,
  metric,
  performance_transform: 'log10',
  raters_per_sample: nRaters,
  rubric_version: 'v3-checklist',
  rubric_hash: rubricHash,
  audience,
  seed,
  features: FEATURES,
  ledger_hypotheses: testable.map((h) => ({
    id: h.id, statement: h.statement, rater_question: h.rater_question, direction: h.direction ?? 1,
  })),
  decision_rules: {
    explains: 'signed rho >= 0.6 AND one-sided permutation p <= 0.10 AND measurement sound (direction != 0)',
    weak_support: '0.3 <= signed rho < 0.6, or explains-level rho with p > 0.10',
    no_signal: '|rho| < 0.3',
    contradicts: 'signed rho <= -0.3',
    unreliable: 'mean inter-rater spearman < 0.4 (overrides above)',
    exploratory: 'direction 0: report rho and two-sided p only',
    stumped: 'sample whose composite-predicted rank differs from actual rank by >= ceil(n/3)',
    rule_status: 'see scorecard.js: WORKED / CONFOUNDED / MEASURES_OK_NO_EFFECT / CONTRADICTED / BROKEN_MEASUREMENT',
  },
};
const preregText = JSON.stringify(prereg, null, 2);
writeJson(path.join(runDir, 'prereg.json'), prereg);
fs.writeFileSync(path.join(runDir, 'prereg.sha256'), sha256(preregText) + '\n');

// ---------- task files ----------
const BLIND_RULE = 'Read ONLY the files listed above. Do not list directories, glob, grep or open any other file: other files reveal the answers and would invalidate the experiment.';
const validate = (out) => `node "${path.join(__dirname, 'check.js')}" --file "${out}"  (fix and rewrite until it prints OK)`;
const hypBlock = testable.length
  ? '\nExtra hypothesis questions (answer each in a `hypotheses` object as {"score": 1-5, "ts": "", "quote": "", "gloss": ""}):\n' +
    testable.map((h) => `- ${h.id}: ${h.rater_question}`).join('\n') + '\n'
  : '\nNo extra hypothesis questions this run (`hypotheses` = {}).\n';
const sids = Object.keys(key);

const tasks = [];
for (const sid of sids) {
  for (let r = 1; r <= nRaters; r++) {
    const out = path.join(runDir, 'scores', `${sid}.r${r}.json`);
    const p = path.join(runDir, 'tasks', `rate_${sid}_r${r}.md`);
    fs.writeFileSync(p, [
      `# Blind rating task: ${sid}, rater ${r}`, '',
      `1. Read the rater instructions: ${ref('rater-prompt.md')}`,
      `2. Read the definitions: ${ref('rubric.md')}`,
      `3. Read the checklist: ${path.join(runDir, 'checklist.md')}`,
      `4. Read the transcript: ${path.join(runDir, 'blind', `${sid}.txt`)}`,
      `5. Write your JSON to: ${out}`,
      `6. Validate: ${validate(out)}`,
      hypBlock, BLIND_RULE, '',
    ].join('\n'), 'utf8');
    tasks.push(p);
  }
}

// all n-choose-2 pairs, positions balanced so each sample is "A" about half the time
const pairs = [];
const asA = Object.fromEntries(sids.map((s) => [s, 0]));
for (const [i, j] of shuffle(sids.flatMap((s, i) => sids.slice(i + 1).map((_, k) => [i, i + 1 + k])), rand)) {
  const [x, y] = [sids[i], sids[j]];
  const [a, b] = asA[x] <= asA[y] ? [x, y] : [y, x];
  asA[a]++;
  pairs.push({ id: `P${String(pairs.length + 1).padStart(2, '0')}`, a, b });
}
const pairTasks = pairs.map((pr) => {
  const out = path.join(runDir, 'pairs', `${pr.id}.json`);
  const p = path.join(runDir, 'tasks', `pair_${pr.id}.md`);
  fs.writeFileSync(p, [
    `# Blind pairwise comparison ${pr.id}`, '',
    `1. Read the instructions: ${ref('pair-prompt.md')}`,
    `2. Read transcript A (${pr.a}): ${path.join(runDir, 'blind', `${pr.a}.txt`)}`,
    `3. Read transcript B (${pr.b}): ${path.join(runDir, 'blind', `${pr.b}.txt`)}`,
    `4. Write your JSON (pair_id "${pr.id}", a "${pr.a}", b "${pr.b}") to: ${out}`,
    `5. Validate: ${validate(out)}`, '', BLIND_RULE, '',
  ].join('\n'), 'utf8');
  return p;
});
fs.mkdirSync(path.join(runDir, 'pairs'), { recursive: true });

const pkgOut = path.join(runDir, 'scores', 'packaging.json');
const pkgTask = path.join(runDir, 'tasks', 'packaging.md');
fs.writeFileSync(pkgTask, [
  '# Blind packaging task', '',
  `1. Read the instructions: ${ref('packaging-prompt.md')}`,
  `2. Read the titles: ${path.join(runDir, 'blind', 'titles.json')}`,
  `3. Write your JSON to: ${pkgOut}`,
  `4. Validate: ${validate(pkgOut)}`, '', BLIND_RULE, '',
].join('\n'), 'utf8');

writeJson(path.join(runDir, 'manifest.json'), {
  run_dir: runDir, cohort: cohortDir, ledger: ledgerPath, samples: sids, raters: nRaters,
  rubric_hash: rubricHash, audience, pairs,
  rater_tasks: tasks, pair_tasks: pairTasks, packaging_task: pkgTask,
});

console.log(`Run directory: ${runDir}`);
console.log(`Samples: ${sids.join(', ')}  (key sealed in sealed/key.json — do not open before analyze)`);
console.log(`Pre-registration hash: ${sha256(preregText).slice(0, 16)} | rubric hash: ${rubricHash}`);
console.log(`Ledger hypotheses under test: ${testable.length}`);
console.log(`\nWave 1 (run in parallel): ${tasks.length} rater tasks + ${pairTasks.length} pair tasks + 1 packaging task`);
[...tasks, ...pairTasks, pkgTask].forEach((t) => console.log('  ' + t));
console.log(`\nWave 2 (after wave 1): node "${path.join(__dirname, 'topics.js')}" --run "${runDir}"  -> writes the topic task`);

Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.