fix(act): scope model-default tripwire to the applied project

Review fixes for #616:
- modelDefaultRow and the under-20-edit-turns gate now aggregate only the
  target project's sessions (derived from changes[0].path, separators
  normalized before dirname), matching the per-project baseline captured at
  apply time instead of comparing against all projects.
- baseline.candidateModel labels the candidate explicitly, with a
  backward-compatible fallback to metrics key order for existing journals.
- measured model-default rows render a correlation marker instead of a
  formatTokens(0) token claim.
- zero-matching-projects now reports an honest project-not-found note; clean
  rows route through confidenceFor like every other kind.
- tests: tripwire fires on a same-project regression that global aggregation
  would mask (fails on pre-fix code), clean and not-measurable cases,
  Windows-separator journal paths with an excluded masking project.
This commit is contained in:
ozymandiashh 2026-07-16 19:23:27 +03:00
parent 34ca31e81b
commit 7cb1bd9f98
4 changed files with 198 additions and 11 deletions

View file

@ -161,6 +161,7 @@ export async function buildApplyModelDefaultPlan(recommendation: ModelDefaultRec
capturedAt: new Date().toISOString(),
estimatedTokens: 0,
sessions: recommendation.currentEditTurns + recommendation.candidateEditTurns,
candidateModel: recommendation.candidateModel,
metrics: {
[recommendation.candidateModel]: recommendation.candidateOneShotRate,
[recommendation.currentModel]: recommendation.currentOneShotRate,

View file

@ -1,4 +1,5 @@
import { existsSync } from 'fs'
import { dirname } from 'node:path'
import type { DateRange, ProjectSummary, SessionSummary } from '../types.js'
import type { ActionBaseline, ActionKind, ActionRecord } from './types.js'
import type { FindingPlan } from './plans.js'
@ -124,6 +125,26 @@ function sessionsInWindow(projects: ProjectSummary[], start: Date, end: Date): S
return out
}
function projectPathKey(path: string): string {
const normalized = path.trim().replace(/\\/g, '/').replace(/\/+$/, '')
return normalized.toLowerCase()
}
function modelDefaultSessionsInWindow(
rec: ActionRecord, projects: ProjectSummary[], start: Date, end: Date,
): { projectFound: boolean; sessions: SessionSummary[] } {
const settingsPath = rec.changes[0]?.path
if (!settingsPath) return { projectFound: false, sessions: [] }
const normalizedSettingsPath = settingsPath.replace(/\\/g, '/')
const targetProjectPath = dirname(dirname(normalizedSettingsPath))
const targetKey = projectPathKey(targetProjectPath)
const targetProjects = projects.filter(project => projectPathKey(project.projectPath) === targetKey)
return {
projectFound: targetProjects.length > 0,
sessions: sessionsInWindow(targetProjects, start, end),
}
}
function countToolCalls(sessions: SessionSummary[], names: ReadonlySet<string>): number {
let n = 0
for (const s of sessions) {
@ -286,12 +307,16 @@ async function guardRow(
async function modelDefaultRow(
base: ActReportRow, rec: ActionRecord, sessions: SessionSummary[],
baseline: ActionBaseline, afterStart: Date, now: Date,
baseline: ActionBaseline, afterStart: Date, now: Date, projectFound: boolean,
): Promise<ActReportRow> {
if (!projectFound) {
return { ...base, note: 'not measurable: project not found in current data (path may have changed)' }
}
const models = Object.keys(baseline.metrics)
if (models.length < 2) return { ...base, note: 'not measurable: invalid baseline' }
const candidateModel = models[0]!
const preApplyRate = baseline.metrics[candidateModel]!
const candidateModel = baseline.candidateModel ?? models[0]!
const preApplyRate = baseline.metrics[candidateModel]
if (preApplyRate === undefined) return { ...base, note: 'not measurable: invalid baseline' }
const mockProject: ProjectSummary = {
project: 'mock',
@ -326,12 +351,15 @@ async function modelDefaultRow(
...base,
status: 'measured',
realizedTokens: 0,
confidence: 'normal',
confidence: confidenceFor(sessions.length, baseline, afterStart, now),
note: `correlation, not attribution: one-shot rate ${(preApplyRate * 100).toFixed(1)}% -> ${(postApplyRate * 100).toFixed(1)}%`
}
}
async function computeRow(rec: ActionRecord, sessions: SessionSummary[], afterStart: Date, now: Date, opts: ActReportOptions): Promise<ActReportRow> {
async function computeRow(
rec: ActionRecord, sessions: SessionSummary[], afterStart: Date, now: Date,
opts: ActReportOptions, modelDefaultProjectFound = true,
): Promise<ActReportRow> {
const estimatedAtApply = rec.baseline?.estimatedTokens ?? 0
const base: ActReportRow = {
id: rec.id,
@ -354,7 +382,7 @@ async function computeRow(rec: ActionRecord, sessions: SessionSummary[], afterSt
if (rec.kind === 'claude-md-rule') return readEditRow(base, sessions, baseline, afterStart, now)
if (rec.kind === 'shell-config') return { ...base, note: 'not measurable: bash result token sizes are not retained in the summary' }
if (rec.kind === 'guard-install') return guardRow(base, afterStart, now, baseline, opts)
if (rec.kind === 'model-default') return modelDefaultRow(base, rec, sessions, baseline, afterStart, now)
if (rec.kind === 'model-default') return modelDefaultRow(base, rec, sessions, baseline, afterStart, now, modelDefaultProjectFound)
return { ...base, note: 'not measurable: kind is not tracked by act report' }
}
@ -409,7 +437,11 @@ export async function computeActReport(opts: ActReportOptions = {}): Promise<Act
const rows: ActReportRow[] = []
for (const rec of eligible) {
const afterStart = new Date(Math.max(new Date(rec.at).getTime(), windowStart.getTime()))
rows.push(await computeRow(rec, sessionsInWindow(projects, afterStart, now), afterStart, now, opts))
const modelDefaultWindow = rec.kind === 'model-default'
? modelDefaultSessionsInWindow(rec, projects, afterStart, now)
: undefined
const sessions = modelDefaultWindow?.sessions ?? sessionsInWindow(projects, afterStart, now)
rows.push(await computeRow(rec, sessions, afterStart, now, opts, modelDefaultWindow?.projectFound))
}
const measuredRows = rows.filter(r => r.status === 'measured' && isTokenKind(r.kind))
@ -453,6 +485,7 @@ function realizedCell(r: ActReportRow): string {
if (r.status === 'reverted') return 'reverted'
if (r.status === 'not-measurable') return 'not measurable'
if (r.correlation) return `abandoned ${r.correlation.abandonedPctThen}% -> ${r.correlation.abandonedPctNow}% (corr.)`
if (r.kind === 'model-default') return 'correlation'
return formatTokens(r.realizedTokens)
}

View file

@ -33,6 +33,8 @@ export type ActionBaseline = {
estimatedTokens: number
sessions: number
metrics: Record<string, number>
// model-default only: identifies the candidate independently of metrics key order.
candidateModel?: string
}
export type ActionRecord = {

View file

@ -11,7 +11,7 @@ import {
renderActReport,
} from '../src/act/report.js'
import type { ActionRecord } from '../src/act/types.js'
import type { ProjectSummary } from '../src/types.js'
import type { ClassifiedTurn, ProjectSummary } from '../src/types.js'
type Session = ProjectSummary['sessions'][number]
@ -58,10 +58,13 @@ function makeSession(id: string, firstTimestamp: string, over: Partial<Session>
}
}
function projectOf(sessions: Session[]): ProjectSummary {
function projectOf(
sessions: Session[],
over: { project?: string; projectPath?: string } = {},
): ProjectSummary {
return {
project: 'app',
projectPath: '/tmp/app',
project: over.project ?? 'app',
projectPath: over.projectPath ?? '/tmp/app',
sessions,
totalCostUSD: sessions.reduce((s, x) => s + x.totalCostUSD, 0),
totalSavingsUSD: 0,
@ -89,6 +92,79 @@ function mcpRecord(over: Partial<ActionRecord> = {}): ActionRecord {
}
}
function modelEditTurns(model: string, editTurns: number, oneShotTurns: number): ClassifiedTurn[] {
return Array.from({ length: editTurns }, (_, i) => ({
userMessage: 'edit the code',
timestamp: daysAgo(5),
sessionId: `model-${model}-${i}`,
category: 'coding',
retries: i < oneShotTurns ? 0 : 1,
hasEdits: true,
assistantCalls: [{
provider: 'claude',
model,
usage: {
inputTokens: 100,
outputTokens: 50,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 1,
tools: ['Edit'],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: daysAgo(5),
bashCommands: [],
deduplicationKey: `model-${model}-${i}`,
}],
}))
}
function modelProject(
project: string, projectPath: string, model: string, editTurns: number, oneShotTurns: number,
): ProjectSummary {
const turns = modelEditTurns(model, editTurns, oneShotTurns)
const session = makeSession(`model-${project}`, daysAgo(5), {
project,
apiCalls: turns.length,
turns,
})
return projectOf([session], { project, projectPath })
}
function modelDefaultRecord(over: Partial<ActionRecord> = {}): ActionRecord {
const at = daysAgo(10)
return {
id: 'md1',
at,
kind: 'model-default',
findingId: 'model-default:app',
description: 'Set Claude Code default model to candidate-model for app',
changes: [{
path: '/tmp/app/.claude/settings.json',
backup: null,
op: 'edit',
afterHash: '',
}],
status: 'applied',
baseline: {
windowDays: 30,
capturedAt: at,
estimatedTokens: 0,
sessions: 60,
metrics: { 'candidate-model': 0.9, 'current-model': 0.95 },
},
...over,
}
}
const load = (projects: ProjectSummary[]) => async () => projects
describe('mcp realized delta', () => {
@ -132,6 +208,81 @@ describe('mcp realized delta', () => {
})
})
describe('model-default quality tripwire', () => {
it('fires for a >5pp same-project regression even when another project would mask it globally', async () => {
const actionsDir = await writeJournal([modelDefaultRecord()])
const target = modelProject('app', '/tmp/app', 'candidate-model', 20, 10)
const masking = modelProject('other', '/tmp/other', 'candidate-model', 80, 80)
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([target, masking]) })
const row = report.rows[0]!
expect(row.status).toBe('measured')
// Scope-fix pin: pre-fix global aggregation reports 90.0% instead of 50.0%.
expect(row.note).toBe('quality regression, consider undo: one-shot rate 90.0% -> 50.0%')
expect(row.confidence).toBe('low')
})
it('reports correlation without a regression and uses the labeled candidate model', async () => {
const rec = modelDefaultRecord({
baseline: {
windowDays: 30,
capturedAt: daysAgo(10),
estimatedTokens: 0,
sessions: 60,
candidateModel: 'candidate-model',
metrics: { 'current-model': 0.95, 'candidate-model': 0.75 },
},
})
const actionsDir = await writeJournal([rec])
const target = modelProject('app', '/tmp/app', 'candidate-model', 20, 16)
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([target]) })
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.confidence).toBe('low')
expect(row.note).toBe('correlation, not attribution: one-shot rate 75.0% -> 80.0%')
expect(renderActReport(report)).toMatch(/Set Claude Code default model to candidate-model for app\s+│\s+-\s+│\s+correlation\s+│/)
})
it('is not measurable with fewer than 20 candidate edit turns in the target project', async () => {
const actionsDir = await writeJournal([modelDefaultRecord()])
const target = modelProject('app', '/tmp/app', 'candidate-model', 19, 19)
const unrelated = modelProject('other', '/tmp/other', 'candidate-model', 50, 50)
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([target, unrelated]) })
const row = report.rows[0]!
expect(row.status).toBe('not-measurable')
expect(row.note).toBe('not measurable: < 20 edit turns for candidate-model since apply')
})
it('reports a missing scoped project separately from insufficient edit turns', async () => {
const actionsDir = await writeJournal([modelDefaultRecord()])
const unrelated = modelProject('other', '/tmp/other', 'candidate-model', 50, 50)
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([unrelated]) })
const row = report.rows[0]!
expect(row.status).toBe('not-measurable')
expect(row.note).toBe('not measurable: project not found in current data (path may have changed)')
})
it('matches a backslash-separated journal path to a forward-slash project path', async () => {
const rec = modelDefaultRecord({
changes: [{
path: 'C:\\work\\app\\.claude\\settings.json',
backup: null,
op: 'edit',
afterHash: '',
}],
})
const actionsDir = await writeJournal([rec])
const target = modelProject('app', 'C:/work/app', 'candidate-model', 20, 10)
const masking = modelProject('other', 'C:/work/other', 'candidate-model', 80, 80)
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([target, masking]) })
expect(report.rows[0]!.note).toBe('quality regression, consider undo: one-shot rate 90.0% -> 50.0%')
})
})
describe('confidence markers', () => {
it('marks low when fewer than 20 post-window sessions', async () => {
const actionsDir = await writeJournal([mcpRecord()])