Ctrl K
ComunidadGuías…

__tests__/traceset.test.js

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

Archivo original ↗Preguntar a KiBot
/*
 * THE TRAINING SET, AND THE FOUR WAYS A TRAINING SET SILENTLY RUINS A MODEL.
 *
 * Every test here is one of them:
 *
 *   - a credential leaves with the data, because redaction was going to be a later step;
 *   - the label comes from the agent's own report, so the model learns to declare success;
 *   - the evaluation split is cut by TURN, so turns from the same job sit on both sides and every
 *     measurement afterwards flatters the model;
 *   - one loud role drowns the rest, and the model becomes a notification sweeper.
 *
 * None of these fail loudly. They all produce a model that looks fine and is not.
 */
import { describe, it, expect } from 'vitest';
import { build, turnsOf, toJsonl, scrubText, scrubValue } from '../src/traceset.js';

const job = (over = {}) => ({
  id: 'j-1', role: 'research.web', profile: 'google', goal: 'Find three suppliers',
  createdAt: '2026-09-01T10:00:00.000Z', endedAt: '2026-09-01T10:10:00.000Z',
  status: 'idle', report: 'Found three suppliers.', error: '', proposals: [], leads: [],
  steps: [
    { n: 1, kind: 'you', text: 'Find three suppliers' },
    { n: 2, kind: 'tool', tool: 'google', args: { query: 'suppliers' } },
    { n: 3, kind: 'read', text: 'google "suppliers" — 8 results', url: 'https://www.google.com/search?q=suppliers' },
    { n: 4, kind: 'tool', tool: 'open', args: { url: 'https://example.com' } },
    { n: 5, kind: 'read', text: 'Example — contact us at sales@example.com or +32 496 31 12 12' },
    { n: 6, kind: 'tool', tool: 'finish', args: { summary: 'done' } },
  ],
  ...over,
});

describe('nothing sensitive leaves with the data', () => {
  it('redacts the things actually found in this corpus', () => {
    /* 533 of 2,222 job files contain an email address; one holds a Search Console token and one a
       Facebook CSRF pair. Those are the real contents, so those are the tests. */
    expect(scrubText('write to sales@example.com')).toBe('write to <email>');
    expect(scrubText('ya29.a0AfB_veryLongTokenValue')).toBe('<token>');
    expect(scrubText('key gb_42c3f0aabbccdd')).toBe('key <key>');
    expect(scrubText('Authorization: Bearer abcdefghijklmnop')).toContain('Bearer <token>');
    expect(scrubText('call +32 496 31 12 12 now')).toBe('call <phone> now');
    expect(scrubText('{"fb_dtsg":"NAcOabcdef123"}')).toBe('{"fb_dtsg":"<token>"}');
  });

  it('drops identity and plumbing fields rather than carrying them along', () => {
    const v = scrubValue({ owner: 'someone@x.com', companyId: 'c1', sessionId: 's1', gscToken: 'abc', keep: 'yes' });
    expect(v).toEqual({ keep: 'yes' });
  });

  it('redacts by key name too, because a token does not arrive under a helpful label', () => {
    expect(scrubValue({ apiKey: 'zzzz', password: 'hunter2', cookie: 'a=b' }))
      .toEqual({ apiKey: '<redacted>', password: '<redacted>', cookie: '<redacted>' });
  });

  it('scrubs inside a tool call\'s arguments, where a person pastes things', () => {
    const t = turnsOf(job({
      steps: [
        { n: 1, kind: 'tool', tool: 'type', args: { text: 'my email is carla@example.com' } },
      ],
    }));
    expect(t[0].action.args.text).toBe('my email is <email>');
  });

  it('does not recurse forever on a self-referential record', () => {
    const a = { name: 'x' }; a.self = a;
    expect(() => scrubValue(a)).not.toThrow();
  });
});

describe('a turn is a decision, not a page dump', () => {
  it('pairs what was seen with the tool that was called next', () => {
    const t = turnsOf(job());
    expect(t).toHaveLength(3);
    expect(t[0].action.tool).toBe('google');
    expect(t[0].observed).toEqual([]);                    // nothing seen before the first call
    expect(t[1].action.tool).toBe('open');
    expect(t[1].observed[0].text).toContain('8 results'); // it saw the search result first
  });

  it('caps the observation text, so the set teaches acting and not reading', () => {
    const long = 'x'.repeat(5000);
    const t = turnsOf(job({ steps: [
      { n: 1, kind: 'read', text: long },
      { n: 2, kind: 'tool', tool: 'open', args: {} },
    ] }), { maxObs: 100 });
    expect(t[0].observed[0].text.length).toBe(100);
  });

  it('keeps only the recent history, because a fortieth step does not need the first', () => {
    const steps = [];
    for (let i = 1; i <= 20; i++) steps.push({ n: i, kind: 'read', text: `obs ${i}` });
    steps.push({ n: 21, kind: 'tool', tool: 'open', args: {} });
    const t = turnsOf(job({ steps }), { maxHistory: 4 });
    expect(t[0].observed).toHaveLength(4);
    expect(t[0].observed[3].text).toBe('obs 20');
  });

  it('produces nothing from a job with no goal', () => {
    expect(turnsOf(job({ goal: '' }))).toEqual([]);
  });
});

describe('the label comes from verifiers, never from the report', () => {
  it('a glowing report with a failed check is bronze and is left out', () => {
    /* It claimed a file. None appeared. The report says otherwise, and the report does not win. */
    const bad = job({
      report: 'Exported the video successfully.',
      steps: [
        { n: 1, kind: 'tool', tool: 'download_url', args: { url: 'https://x/v.mp4' } },
        { n: 2, kind: 'read', text: 'saved' },
      ],
    });
    const out = build([bad], { files: () => [] });
    expect(out.manifest.tiers.bronze).toBe(1);
    expect(out.manifest.claimsCaught.fileWasProduced).toBe(1);
    expect(out.train).toHaveLength(0);
  });

  it('an approved act is gold, and gold is kept', () => {
    const good = job({ proposals: [{ pid: 'p1', state: 'approved' }] });
    const out = build([good], {});
    expect(out.manifest.tiers.gold).toBe(1);
    expect(out.train.length + out.eval.length).toBeGreaterThan(0);
  });

  it('counts what the verifiers caught, because that is the number worth watching', () => {
    const lied = job({
      steps: [{ n: 1, kind: 'tool', tool: 'type', args: { text: 'a sentence that never lands' } },
              { n: 2, kind: 'read', text: 'a completely different page' }],
    });
    expect(build([lied], {}).manifest.claimsCaught.typedTextLanded).toBe(1);
  });
});

describe('a click by number needs the list the number came from', () => {
  /*
   * Every run recorded before look and open began carrying their marks drew a numbered list and
   * threw it away. The turn that survives reads: given a page you cannot see, emit 19. The
   * rebuild of 2026-09-23 had 3,073 of these and 12 that were learnable.
   */
  const clicked = (marks) => job({
    proposals: [{ pid: 'p1', state: 'approved' }],
    steps: [
      { n: 1, kind: 'you', text: 'Find three suppliers' },
      { n: 2, kind: 'tool', tool: 'look', args: {} },
      { n: 3, kind: 'look', text: 'the page', ...(marks ? { marks } : {}) },
      { n: 4, kind: 'tool', tool: 'click', args: { index: 19 } },
      { n: 5, kind: 'read', text: 'the listing' },
    ],
  });

  it('throws away an index whose list was never recorded', () => {
    const out = build([clicked(null)], {});
    const kept = [...out.train, ...out.eval];
    expect(kept.some((x) => x.action.tool === 'click')).toBe(false);
    expect(out.manifest.droppedTurns['an index with no list to read it from']).toBeGreaterThan(0);
  });

  it('keeps the same turn once the list travels with it', () => {
    const out = build([clicked('[19] Website laten maken | incl. hosting')], {});
    const kept = [...out.train, ...out.eval];
    expect(kept.some((x) => x.action.tool === 'click' && x.action.args.index === 19)).toBe(true);
  });

  it('leaves calls that name what they want alone, once the page they came from is on record', () => {
    /* A url carries its own subject - but only if the read it was chosen from is in the record.
       The fixture's reads carry content here; without it the same turn is a blind decision. */
    const sighted = job({ proposals: [{ pid: 'p1', state: 'approved' }] });
    for (const s of sighted.steps) if (s.kind === 'read') s.content = 'Example Ltd - sales@example.com - +32 496 31 12 12 - https://example.com/contact';
    const out = build([sighted], {});
    const kept = [...out.train, ...out.eval];
    expect(kept.some((x) => x.action.tool === 'open')).toBe(true);
  });
});

describe('a decision needs the page it was made from', () => {
  /*
   * The finding behind both failed rounds. 58% of a training slice followed a read whose content
   * was never recorded - "read the page (14592 characters)" and nothing else - and those turns'
   * answers were dig 284, open 215, run_script 213, finish 114: the exact tools that scored zero.
   * Both rounds collapsed onto look. Same rule as the index with no list, one level up.
   */
  const blind = () => job({
    proposals: [{ pid: 'p1', state: 'approved' }],
    steps: [
      { n: 1, kind: 'you', text: 'Find three suppliers' },
      { n: 2, kind: 'tool', tool: 'open', args: { url: 'https://example.com' } },
      { n: 3, kind: 'read', text: 'read the page (14592 characters)' },
      { n: 4, kind: 'tool', tool: 'open', args: { url: 'https://example.com/suppliers' } },
      { n: 5, kind: 'read', text: 'read the page (2210 characters)' },
    ],
  });

  it('drops a decision that followed a read whose content was never recorded', () => {
    const out = build([blind()], {});
    const kept = [...out.train, ...out.eval].filter((x) => x.action.tool === 'open' && x.action.args.url.endsWith('/suppliers'));
    expect(kept).toHaveLength(0);
    expect(out.manifest.droppedTurns['a decision with no page to read it from']).toBeGreaterThan(0);
  });

  it('keeps the same decision once the page travels with it', () => {
    const j = blind();
    j.steps[2].content = 'Example Ltd. Suppliers: https://example.com/suppliers. Contact: sales@example.com';
    const out = build([j], {});
    const kept = [...out.train, ...out.eval].filter((x) => x.action.tool === 'open' && x.action.args.url.endsWith('/suppliers'));
    expect(kept).toHaveLength(1);
    expect(kept[0].observed[kept[0].observed.length - 1].content).toMatch(/suppliers/);
  });

  it('never drops the opening move, which is decided from the goal alone', () => {
    /* What do you reach for when you know nothing yet - the most informative turn there is,
       and one that by definition has no page to be blind to. */
    const out = build([blind()], {});
    const first = [...out.train, ...out.eval].filter((x) => x.action.tool === 'open' && x.action.args.url === 'https://example.com');
    expect(first).toHaveLength(1);
  });
});
describe('the exam does not pay for what training spent', () => {
  /*
   * The per-tool cap counter was shared between the training pass and the evaluation pass, and
   * training is built first. Measured on the real set of 2026-09-23: `open` is 15% of training
   * and the most common action there is, and the eval split held ZERO of it — 0 of 3,057 turns,
   * 1,518 dropped to that rule, 30 tools on the exam against 49 in training. The model was never
   * scored on the thing it does most.
   */
  it('still scores a tool that training used its whole quota of', () => {
    const jobs = [];
    for (let i = 0; i < 40; i++) {
      jobs.push(job({
        id: `j-${i}`, proposals: [{ pid: `p${i}`, state: 'approved' }],
        goal: `open a lot of pages ${i}`,
        steps: [
          { n: 1, kind: 'you', text: `open a lot of pages ${i}` },
          { n: 2, kind: 'tool', tool: 'open', args: { url: `https://a${i}.example` } },
          { n: 3, kind: 'read', text: `page ${i}` },
          { n: 4, kind: 'tool', tool: 'open', args: { url: `https://b${i}.example` } },
          { n: 5, kind: 'read', text: `other ${i}` },
        ],
      }));
    }
    /* A cap low enough that the training pass exhausts it outright. */
    const out = build(jobs, {}, { toolCap: 2 });
    expect(out.train.filter((x) => x.action.tool === 'open').length).toBe(2);
    expect(out.eval.length).toBeGreaterThan(0);
    expect(out.eval.some((x) => x.action.tool === 'open')).toBe(true);
  });
});
describe('the evaluation split is cut by job, before any turn exists', () => {
  it('never puts two turns from the same job on both sides', () => {
    const jobs = [];
    for (let i = 0; i < 60; i++) jobs.push(job({ id: `j-${i}`, goal: `find suppliers batch ${i}`, proposals: [{ state: 'approved' }] }));
    const out = build(jobs, {}, { evalFraction: 0.3 });
    const trainJobs = new Set(out.train.map((t) => t.jobId));
    const evalJobs = new Set(out.eval.map((t) => t.jobId));
    for (const id of evalJobs) expect(trainJobs.has(id)).toBe(false);
  });

  it('is deterministic, so a rebuild does not quietly move the exam', () => {
    const jobs = [];
    for (let i = 0; i < 40; i++) jobs.push(job({ id: `j-${i}`, goal: `find suppliers batch ${i}`, proposals: [{ state: 'approved' }] }));
    const a = build(jobs, {}, { evalFraction: 0.25 }).eval.map((t) => t.jobId);
    const b = build(jobs, {}, { evalFraction: 0.25 }).eval.map((t) => t.jobId);
    expect(a).toEqual(b);
  });

  it('actually holds some back', () => {
    /* Distinct goals, because real jobs have them — and the dedupe key includes the goal, so a
        fixture of byte-identical jobs collapses to one turn and measures nothing. */
    const jobs = [];
    for (let i = 0; i < 80; i++) jobs.push(job({ id: `j-${i}`, goal: `find suppliers batch ${i}`, proposals: [{ state: 'approved' }] }));
    const out = build(jobs, {}, { evalFraction: 0.2 });
    expect(out.eval.length).toBeGreaterThan(0);
    expect(out.train.length).toBeGreaterThan(out.eval.length);
  });
});

describe('no single role is allowed to drown the rest', () => {
  it('caps per role, because 292 notification sweeps against 84 screenshots is not a balance', () => {
    const jobs = [];
    for (let i = 0; i < 200; i++) jobs.push(job({ id: `loud-${i}`, goal: `sweep notifications ${i}`, role: 'facebook-notification-watch', proposals: [{ state: 'approved' }] }));
    for (let i = 0; i < 20; i++) jobs.push(job({ id: `quiet-${i}`, goal: `take a shot ${i}`, role: 'learn.shot', proposals: [{ state: 'approved' }] }));
    const out = build(jobs, {}, { perRoleCap: 30, evalFraction: 0 });
    const byRole = {};
    out.train.forEach((t) => { byRole[t.role] = (byRole[t.role] || 0) + 1; });
    /* Turns, not jobs — but the loud role must no longer be ten times the quiet one. */
    expect(out.manifest.roles['facebook-notification-watch']).toBeGreaterThan(30);
    const loudJobs = new Set(out.train.filter((t) => t.role === 'facebook-notification-watch').map((t) => t.jobId));
    expect(loudJobs.size).toBe(30);
  });
});

describe('the output is what a trainer eats', () => {
  it('is one JSON object per line, with the tool call as the assistant turn', () => {
    const out = build([job({ proposals: [{ state: 'approved' }] })], {}, { evalFraction: 0 });
    const lines = toJsonl(out.train).split('\n');
    expect(lines.length).toBe(out.train.length);
    const first = JSON.parse(lines[0]);
    expect(first.messages).toHaveLength(3);
    expect(first.messages[2].role).toBe('assistant');
    expect(JSON.parse(first.messages[2].content).tool).toBe('google');
    expect(first.meta.tier).toBe('gold');
  });

  it('carries the goal and the role into the prompt, since that is what a specialist needs', () => {
    const out = build([job({ proposals: [{ state: 'approved' }] })], {}, { evalFraction: 0 });
    const first = JSON.parse(toJsonl(out.train).split('\n')[0]);
    expect(first.messages[0].content).toContain('research.web');
    expect(first.messages[1].content).toContain('Find three suppliers');
  });

  it('survives an empty corpus without pretending it built something', () => {
    const out = build([], {});
    expect(out.train).toEqual([]);
    expect(out.manifest.jobsSeen).toBe(0);
  });
});

describe('a verdict per step', () => {
  /* A wrong click inside a gold run used to be labelled gold. The record says otherwise right
     after the call, and that is the label. */
  const step = (kind, text, extra = {}) => ({ kind, text, at: 'x', ...extra });
  /* A report, so the run is silver rather than void - a void run never reaches the filters. */
  const job = () => ({ id: 'j-sv', goal: 'Find the price', role: 'general', profile: 'default', status: 'done', report: 'The price is 12 euro.', steps: [
    step('you', 'Find the price'),
    step('tool', 'open(https://a.org)', { tool: 'open', args: { url: 'https://a.org' } }), step('open', 'https://a.org'),
    step('tool', 'run_script()', { tool: 'run_script', args: { code: 'location.href=1' } }), step('blocked', 'run_script refused: a script may not navigate — use open'),
    step('tool', 'look()', { tool: 'look', args: {} }), step('look', 'A — 3 things to click', { marks: '[1] a\n[2] b\n[3] c' }),
    step('tool', 'choose_option(2)', { tool: 'choose_option', args: { index: 2, value: 'x' } }), step('blocked', 'choose_option [2]: no option matches'),
    step('tool', 'click(1)', { tool: 'click', args: { index: 1 } }), step('click', 'clicked [1]'),
    step('tool', 'finish()', { tool: 'finish', args: { summary: 'done' } }), step('done', 'done'),
  ] });

  it('marks the refused calls wrong and leaves the rest unknown', () => {
    const turns = turnsOf(job());
    expect(turns.map((t) => `${t.action.tool}:${t.step}`)).toEqual(['open:unknown', 'run_script:wrong', 'look:unknown', 'choose_option:wrong', 'click:unknown', 'finish:unknown']);
    expect(turns[1].stepWhy).toBe('the call was refused');
  });

  it('keeps a wrong step out of the training set and puts it on the reject pile, marked', () => {
    const out = build([job()], { files: () => [], recordings: () => [], runs: () => null }, { evalFraction: 0 });
    const trainTools = out.train.map((t) => t.action.tool);
    expect(trainTools).not.toContain('run_script');
    expect(trainTools).not.toContain('choose_option');
    expect(out.manifest.droppedTurns['a step the record shows was wrong']).toBe(2);
    const wrong = out.reject.filter((t) => (t.failed || []).some((f) => /^step:/.test(f)));
    expect(wrong.map((t) => t.action.tool).sort()).toEqual(['choose_option', 'run_script']);
    expect(out.manifest.wrongSteps).toBe(2);
    const line = JSON.parse(toJsonl(out.train).split('\n')[0]);
    expect(line.meta.step).toBe('unknown');
  });
});