Skip to the content

Capsid menu

src/model-learning.ts

99 lines and 6 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.

Functions

The functions of model-learning.ts
FunctionLinesComplexityCoveredCRAPMutation
sameModel53 to 576100%WatchCRAP 6No data: mutation testing has not run on this repo yet
readModelLearning59 to 1058100%WatchCRAP 8No data: mutation testing has not run on this repo yet
<anonymous:79>79 to 811100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:85>85 to 851100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:95>95 to 951100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:96>96 to 961100%OkCRAP 1No data: mutation testing has not run on this repo yet

Imports

It imports src/model-routing.ts, src/time.ts. Imported by src/tools/jobs.ts.

Source

import { MIN_SAMPLE, RATE_FLOOR, RATE_GAIN, ROUTING_RULES, ROUTING_RULES_VERSION, TRIAL_ARMS, cellsOf, modelAlias, proposeRuleChanges, trialStatus, type Cell, type OutcomeFact, type Proposal, type TrialStatus } from "./model-routing";
import { DAY_MS } from "./time";

// WHAT THE ROUTING LEARNS (capsid/research/design-model-routing.md): per kind and model,
// the share of jobs merged with green CI and not undone since, the corrections spent,
// and the cost where telemetry recorded one, read from job_outcomes. Merge and CI are
// GitHub's, read at complete, not the driver's own account (docs/work-queue.md). A merge
// is a weak label on its own (Peralta et al., MSR 2026: reviewers fix and merge, or
// merge and revert), so a job whose pull request was later reverted (src/merge-safety.ts,
// `job-pr-reverted`) or whose deploy was rolled back does not count as work that held.
// The result proposes rule changes and applies none: the rules are code and the seat
// approves each edit.

const LEARNING_WINDOW_DAYS = 90;
// One read, bounded. A window with more outcomes than this says so, never a partial table
// presented as the whole.
const LEARNING_ROWS_MAX = 5000;

export interface ModelLearning {
  generated: string;
  window_days: number;
  outcomes_read: number;
  truncated: boolean;
  min_sample: number;
  rate_floor: number;
  rate_gain: number;
  rules_version: number;
  rules: ReadonlyArray<{ when: Record<string, string>; model: string; effort: string }>;
  trial_arms: ReadonlyArray<{ kind: string; model: string; percent: number }>;
  cells: Cell[];
  proposals: Proposal[];
  // Each trial arm against its kind's rule (src/model-routing.ts TRIAL_ARMS).
  trials: TrialStatus[];
  note: string;
}

interface Row {
  kind: string | null;
  model: string | null;
  model_actual: string | null;
  prs_merged: number | null;
  ci_green: number | null;
  corrections: number | null;
  cost_usd: number | null;
  tokens: number | null;
  undone: number;
}

// An outcome is learned under the model Capsid chose, which the Worker recorded.
// model_actual is the session's own report, so it never moves an outcome into another
// model's cell; an outcome whose session reports a different model is left out, since
// its work was not shown to be done on the chosen one. opusplan runs as Opus and Sonnet.
function sameModel(r: Row): boolean {
  const actual = modelAlias(r.model_actual);
  if (actual === null || r.model === null) return true;
  return r.model === "opusplan" ? actual === "opus" || actual === "sonnet" || actual === "opusplan" : actual === r.model;
}

export async function readModelLearning(db: D1Database, now: Date): Promise<ModelLearning> {
  const since = new Date(now.getTime() - LEARNING_WINDOW_DAYS * DAY_MS).toISOString();
  const { results } = await db
    .prepare(
      `SELECT o.job_kind AS kind, o.model_chosen AS model, o.model_actual AS model_actual, o.prs_merged AS prs_merged, o.ci_green AS ci_green,
              j.corrections_count AS corrections, o.cost_usd AS cost_usd,
              CASE WHEN o.tokens_input IS NULL AND o.tokens_output IS NULL THEN NULL ELSE COALESCE(o.tokens_input, 0) + COALESCE(o.tokens_output, 0) END AS tokens,
              (EXISTS (SELECT 1 FROM audit_log a WHERE a.action = 'job-pr-reverted' AND json_extract(a.params, '$.job_id') = o.job_id)
               OR EXISTS (SELECT 1 FROM job_outcome_prs p JOIN deploy_outcomes d
                            ON lower(p.pr_url) = lower('https://github.com/' || d.repo || '/pull/' || d.pr)
                          WHERE p.job_id = o.job_id AND d.outcome IN ('rolled_back', 'rollback_failed'))) AS undone
       FROM job_outcomes o LEFT JOIN jobs j ON j.id = o.job_id
       WHERE o.job_kind IS NOT NULL AND o.recorded_at >= ?1
       ORDER BY o.recorded_at DESC LIMIT ?2`
    )
    .bind(since, LEARNING_ROWS_MAX + 1)
    .all<Row>();
  const rows = results ?? [];
  const truncated = rows.length > LEARNING_ROWS_MAX;
  const read = truncated ? rows.slice(0, LEARNING_ROWS_MAX) : rows;
  const facts: OutcomeFact[] = read.filter(sameModel).map((r) => ({
    kind: r.kind, model: r.model, prs_merged: r.prs_merged, ci_green: r.ci_green, corrections: r.corrections, cost_usd: r.cost_usd, tokens: r.tokens, undone: r.undone === 1,
  }));
  const mismatched = read.length - facts.length;
  const cells = cellsOf(facts);
  const proposals = proposeRuleChanges(cells);
  const enough = cells.filter((c) => c.enough).length;
  return {
    generated: now.toISOString(),
    window_days: LEARNING_WINDOW_DAYS,
    outcomes_read: read.length,
    truncated,
    min_sample: MIN_SAMPLE,
    rate_floor: RATE_FLOOR,
    rate_gain: RATE_GAIN,
    rules_version: ROUTING_RULES_VERSION,
    rules: ROUTING_RULES.map((r) => ({ when: Object.fromEntries(Object.entries(r.when)) as Record<string, string>, model: r.model, effort: r.effort })),
    trial_arms: TRIAL_ARMS.map((a) => ({ kind: a.kind, model: a.model, percent: a.percent })),
    cells,
    proposals,
    trials: trialStatus(cells),
    note:
      read.length === 0
        ? "No routed outcome in the window yet: outcomes written before migration 0032 carry no kind or model. Nothing is proposed from no evidence."
        : `${cells.length} kind and model cells, ${enough} with at least ${MIN_SAMPLE} outcomes; only those are acted on. ${proposals.length} proposed change${proposals.length === 1 ? "" : "s"}, none applied: the seat approves each rule edit.${mismatched ? ` ${mismatched} outcome${mismatched === 1 ? " was" : "s were"} left out because the session reported a model other than the one chosen.` : ""}${truncated ? ` The window holds more than ${LEARNING_ROWS_MAX} outcomes, so this is the newest ${LEARNING_ROWS_MAX}.` : ""}`,
  };
}