src/model-learning.ts
99 lines and 6 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| sameModel | 53 to 57 | 6 | 100% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| readModelLearning | 59 to 105 | 8 | 100% | WatchCRAP 8 | No data: mutation testing has not run on this repo yet |
| <anonymous:79> | 79 to 81 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:85> | 85 to 85 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:95> | 95 to 95 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:96> | 96 to 96 | 1 | 100% | OkCRAP 1 | No 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}.` : ""}`,
};
}