scorer.js 11.5 KB
/**
 * trend-scout-global Scorer v1
 * Rule-based rubric scorer using train.jsonl + test.jsonl with per-case dimCeil.
 * Reads both datasets and saves unified iteration_scores_vN.json.
 */

const fs = require('fs');
const path = require('path');

const BENCHMARK = {
  dimensions: [
    { id: 'D1', name: 'Cross-Platform Information Source Understanding', weight: 0.20, max: 20 },
    { id: 'D2', name: 'Trend Recognition & Theme Extraction', weight: 0.20, max: 20 },
    { id: 'D3', name: 'Data Cleaning & Structuring', weight: 0.20, max: 20 },
    { id: 'D4', name: 'Trend Grading & Forecasting', weight: 0.15, max: 15 },
    { id: 'D5', name: 'Report Output Specification', weight: 0.15, max: 15 },
    { id: 'D6', name: 'Exception Handling & Source Verification', weight: 0.10, max: 10 },
  ],
  passLine: 95,
};

function loadJsonl(filePath) {
  const text = fs.readFileSync(filePath, 'utf-8');
  return text.split('\n').map(l => l.trim()).filter(Boolean).map((l, idx) => {
    try { return { ...JSON.parse(l), _idx: idx }; } catch (e) { return null; }
  }).filter(Boolean);
}

function norm(text) {
  return (text || '').toLowerCase().replace(/[\s,;.::\/\-_=+()\[\]{}|"']/g, '');
}

function has(skillNorm, kws) {
  return kws.some(k => skillNorm.includes(norm(k)));
}

function evaluateCase(skillMd, sample) {
  const mdNorm = norm(skillMd);
  const md = skillMd.toLowerCase();
  const input = sample.input || {};
  const expected = sample.expected_output || {};
  const dims = sample.dimensions || {};
  const hints = (sample.grader_hints || '').toLowerCase();

  // dimCeil derived from grader dimensions mapped to benchmark scale
  const ceil = {
    D1: Math.min(20, Math.round((dims['Cross-Platform Information Source Understanding'] || 5) * 2)),
    D2: Math.min(20, Math.round((dims['Trend Recognition & Theme Extraction'] || 5) * 2)),
    D3: Math.min(20, Math.round((dims['Data Cleaning & Structuring'] || 5) * 2)),
    D4: Math.min(15, Math.round((dims['Trend Grading & Forecasting'] || 5) * 1.5)),
    D5: Math.min(15, Math.round((dims['Report Output Specification'] || 5) * 1.5)),
    D6: Math.min(10, Math.round((dims['Exception Handling & Source Verification'] || 5) * 1)),
  };

  const rawEntry = input.raw_entry || {};
  const rawEntries = input.raw_entries || [];
  const platform = expected.platform || '';
  const hasMulti = rawEntries.length > 1;
  const hasStale = !!expected.stale_warning;
  const hasLowConf = expected.confidence === 'low';
  const hasUnknownPlat = platform === 'UNKNOWN';
  const hasConflictRes = !!expected.conflict_resolution;

  // ========== D1: Cross-Platform (ceil dynamic) ==========
  let d1Score = 0, d1Max = 0;
  d1Max += 4; if (has(mdNorm, ['platform', 'tiktok', 'instagram', 'youtube', 'x', 'pinterest'])) d1Score += 4;
  d1Max += 4; if (has(mdNorm, ['source_url', 'url'])) d1Score += 4;
  if (hints.includes('cross-language') || hints.includes('日本')) {
    d1Max += 4; if (has(mdNorm, ['ja-jp', 'japan', '日本版tiktok', 'cross-language'])) d1Score += 4;
  }
  if (hints.includes('region')) {
    d1Max += 4; if (has(mdNorm, ['region', 'brasil', 'brazil', 'jp', 'uk', 'de', 'in', 'ph', 'au', 'unknown'])) d1Score += 4;
  }
  if (hasUnknownPlat) {
    d1Max += 4; if (has(mdNorm, ['unknown', 'unrecognized', 'aggregator'])) d1Score += 4;
  }

  // ========== D2: Trend Recognition (ceil dynamic) ==========
  let d2Score = 0, d2Max = 0;
  d2Max += 5; if (has(mdNorm, ['name', 'hashtag', 'canonical'])) d2Score += 5;
  d2Max += 5; if (has(mdNorm, ['english', 'mapping', 'spanish', 'amor de vampiro', 'vampire love'])) d2Score += 5;
  d2Max += 5; if (has(mdNorm, ['related_hashtags', 'hashtags', 'related'])) d2Score += 5;
  d2Max += 5; if (has(mdNorm, ['ip_style', 'werewolf', 'vampire', 'mafia', 'revenge', 'billionaire', 'contract marriage', 'second chance', 'interactive story', 'vertical drama'])) d2Score += 5;

  // ========== D3: Data Cleaning (ceil dynamic) ==========
  let d3Score = 0, d3Max = 0;
  d3Max += 5; if (has(mdNorm, ['schema', 'required_fields'])) d3Score += 5;
  d3Max += 5; if (has(mdNorm, ['views_or_posts', 'numeric', 'parse', '1.3b', '24.5b'])) d3Score += 5;
  d3Max += 5; if (has(mdNorm, ['iso8601', 'crawl_time', 'timestamp'])) d3Score += 5;
  d3Max += 5; if (has(mdNorm, ['growth_7d', 'growth', 'percentage'])) d3Score += 5;

  // ========== D4: Trend Grading (ceil dynamic) ==========
  let d4Score = 0, d4Max = 0;
  d4Max += 5; if (has(mdNorm, ['rank', 'volume', 'prioritize'])) d4Score += 5;
  d4Max += 5; if (has(mdNorm, ['growth', 'breakout', 'plateau', 'declining'])) d4Score += 5;
  if (hasMulti || hasConflictRes) {
    d4Max += 5; if (has(mdNorm, ['conflict', 'resolution', 'fresher', 'crawl_time'])) d4Score += 5;
  } else {
    d4Max += 5; if (has(mdNorm, ['growth', 'forecasting'])) d4Score += 5;
  }

  // ========== D5: Report Output (ceil dynamic) ==========
  let d5Score = 0, d5Max = 0;
  d5Max += 5; if (has(mdNorm, ['report', 'json', 'markdown', 'table'])) d5Score += 5;
  d5Max += 5; if (has(mdNorm, ['summary', 'takeaway', 'actionable'])) d5Score += 5;
  d5Max += 5; if (has(mdNorm, ['platform', 'regional', 'highlights'])) d5Score += 5;

  // ========== D6: Exception Handling (ceil dynamic) ==========
  let d6Score = 0, d6Max = 0;
  d6Max += 3; if (has(mdNorm, ['confidence', 'low', 'unverified'])) d6Score += 3;
  d6Max += 3; if (has(mdNorm, ['fallback', 'default', 'null', 'missing'])) d6Score += 3;
  if (hasStale) {
    d6Max += 2; if (has(mdNorm, ['stale', 'warning', '7days'])) d6Score += 2;
  } else {
    d6Max += 2; if (has(mdNorm, ['fallback', 'graceful'])) d6Score += 2;
  }
  d6Max += 2; if (has(mdNorm, ['unknown', 'graceful', 'hallucination'])) d6Score += 2;

  const calc = (score, max, cap) => (max > 0 ? Math.min(cap, Math.round((score / max) * cap)) : 0);

  let D1 = calc(d1Score, d1Max, ceil.D1);
  let D2 = calc(d2Score, d2Max, ceil.D2);
  let D3 = calc(d3Score, d3Max, ceil.D3);
  let D4 = calc(d4Score, d4Max, ceil.D4);
  let D5 = calc(d5Score, d5Max, ceil.D5);
  let D6 = calc(d6Score, d6Max, ceil.D6);

  // Additional case-specific penalties for missing highly specific rules
  if (hasUnknownPlat && !has(mdNorm, ['unknown aggregator'])) D1 -= 2;
  if (hints.includes('stale') && !has(mdNorm, ['stale_warning'])) D6 -= 2;
  if (hasLowConf && !has(mdNorm, ['confidence: low'])) D6 -= 2;

  D1 = Math.max(0, Math.min(ceil.D1, D1));
  D2 = Math.max(0, Math.min(ceil.D2, D2));
  D3 = Math.max(0, Math.min(ceil.D3, D3));
  D4 = Math.max(0, Math.min(ceil.D4, D4));
  D5 = Math.max(0, Math.min(ceil.D5, D5));
  D6 = Math.max(0, Math.min(ceil.D6, D6));

  const total = D1 + D2 + D3 + D4 + D5 + D6;
  return {
    id: sample._idx + 1,
    platform: expected.platform || 'unknown',
    scores: { D1, D2, D3, D4, D5, D6 },
    total,
    passed: total >= BENCHMARK.passLine,
    reasoning: `ceil=${JSON.stringify(ceil)} d1=${d1Score}/${d1Max} d4=${d4Score}/${d4Max}`,
  };
}

function scoreDataset(skillMd, samples, label) {
  const results = [];
  const dimTotals = { D1: 0, D2: 0, D3: 0, D4: 0, D5: 0, D6: 0 };

  for (let i = 0; i < samples.length; i++) {
    const r = evaluateCase(skillMd, samples[i]);
    results.push(r);
    for (const k of Object.keys(dimTotals)) dimTotals[k] += r.scores[k];
    const mark = r.passed ? 'PASS' : 'FAIL';
    console.log(`[${label}] ${mark} [${String(r.id).padStart(2)}] ${r.platform.padEnd(10)} | T=${String(r.total).padStart(2)} | D1=${r.scores.D1} D2=${r.scores.D2} D3=${r.scores.D3} D4=${r.scores.D4} D5=${r.scores.D5} D6=${r.scores.D6}`);
  }

  const avgTotal = results.length ? Number((results.reduce((a, b) => a + b.total, 0) / results.length).toFixed(2)) : 0;
  const passCount = results.filter(r => r.passed).length;
  const totals = results.map(r => r.total);
  const spread = totals.length ? Math.max(...totals) - Math.min(...totals) : 0;
  const allSame = spread === 0;

  const dimAvgs = {};
  for (const k of Object.keys(dimTotals)) {
    dimAvgs[k] = results.length ? Number((dimTotals[k] / results.length).toFixed(2)) : 0;
  }

  return {
    count: results.length,
    avg_score: avgTotal,
    pass_rate: `${passCount}/${results.length}`,
    score_spread: spread,
    all_identical: allSame,
    dim_avgs: dimAvgs,
    scores: results.map(r => ({
      id: r.id,
      platform: r.platform,
      scores: r.scores,
      total: r.total,
      passed: r.passed,
      reasoning: r.reasoning,
    })),
  };
}

function main(skillPath, trainPath, testPath, iterationArg) {
  const evolveDir = __dirname;
  const skillMdPath = skillPath || process.argv[2] || path.join(evolveDir, 'SKILL_v1.md');
  const trainJsonl = trainPath || process.argv[3] || path.join(evolveDir, '..', 'digest', 'train.jsonl');
  const testJsonl = testPath || process.argv[4] || path.join(evolveDir, '..', 'digest', 'test.jsonl');
  const iteration = iterationArg || process.argv[5] || 'v1';

  if (!fs.existsSync(trainJsonl)) {
    console.error('Train dataset not found:', trainJsonl);
    process.exit(1);
  }
  if (!fs.existsSync(testJsonl)) {
    console.error('Test dataset not found:', testJsonl);
    process.exit(1);
  }

  const trainSamples = loadJsonl(trainJsonl);
  const testSamples = loadJsonl(testJsonl);
  const skillMd = fs.existsSync(skillMdPath) ? fs.readFileSync(skillMdPath, 'utf-8') : '';

  console.log(`Evaluating: ${path.basename(skillMdPath)}`);
  console.log(`Train: ${trainJsonl} (${trainSamples.length} samples)`);
  console.log(`Test:  ${testJsonl} (${testSamples.length} samples)\n`);

  const trainResult = scoreDataset(skillMd, trainSamples, 'TRAIN');
  const testResult = scoreDataset(skillMd, testSamples, 'TEST');

  const combinedAvg = Number((
    (trainResult.avg_score * trainResult.count + testResult.avg_score * testResult.count)
    / (trainResult.count + testResult.count)
  ).toFixed(2));
  const combinedPassCount = parseInt(trainResult.pass_rate.split('/')[0], 10) + parseInt(testResult.pass_rate.split('/')[0], 10);
  const combinedTotalCount = trainResult.count + testResult.count;
  const globalPassed = combinedAvg >= BENCHMARK.passLine
    && !trainResult.all_identical && !testResult.all_identical
    && trainResult.score_spread >= 2 && testResult.score_spread >= 2;

  console.log(`\n--- Summary ---`);
  console.log(`Train Average: ${trainResult.avg_score} / 100`);
  console.log(`Test Average:  ${testResult.avg_score} / 100`);
  console.log(`Combined Average: ${combinedAvg} / 100`);
  console.log(`Combined Pass Rate: ${combinedPassCount}/${combinedTotalCount}`);
  console.log(`Train Spread: ${trainResult.score_spread} | Test Spread: ${testResult.score_spread}`);
  for (const dim of BENCHMARK.dimensions) {
    console.log(`  ${dim.id} ${dim.name}: train=${trainResult.dim_avgs[dim.id]} test=${testResult.dim_avgs[dim.id]} / ${dim.max}`);
  }
  console.log(`\n${globalPassed ? 'GLOBAL PASS' : 'GLOBAL FAIL'}`);

  if (trainResult.all_identical || testResult.all_identical) {
    console.error('\nERROR: At least one dataset has identical scores for all samples.');
    process.exit(1);
  }

  const outFile = path.join(evolveDir, `iteration_scores_${iteration}.json`);
  const output = {
    iteration,
    skill_file: path.basename(skillMdPath),
    train_dataset: trainJsonl,
    test_dataset: testJsonl,
    combined_average: combinedAvg,
    combined_pass_rate: `${combinedPassCount}/${combinedTotalCount}`,
    global_passed: globalPassed,
    train: trainResult,
    test: testResult,
    scaler_method: 'grader-dimCeil + skill keyword rubric v1',
  };
  fs.writeFileSync(outFile, JSON.stringify(output, null, 2));
  console.log(`\nScores written to ${outFile}`);

  return output;
}

module.exports = { main, evaluateCase, BENCHMARK, scoreDataset };

if (require.main === module) {
  main();
}