diff --git a/src/index.ts b/src/index.ts index f75eaaa7..b9984b72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,20 @@ import { daemonStatus } from "./internal/daemon.js"; import { readCliVersion, readManifestVersions } from "./internal/version.js"; import { installedPackageRoot } from "./internal/package-root.js"; +export { + PREFERENCE_REJECTION_CLASSES, + admitPreferencePairs, + isSha256, + sha256TokenSequence as sha256PreferenceTokenSequence, + type PreferenceAdmissionConfig, + type PreferenceAdmissionRejection, + type PreferenceAdmissionResult, + type PreferencePair, + type PreferenceSemantic, + type PreferenceToolCall, + type VerifiedRejectedPayload, +} from "./preference-admission.js"; + export { compileTraceFoundry, createTraceReplayPlan, importTraceReviews, runTraceReplays } from "./trace-foundry.js"; export { serveTraceFoundry } from "./trace-foundry-server.js"; export { buildRejectionGuidance, classifyRejection, computeRecoveryOverJournals, computeRecoveryRates, loadGuidanceFile, readRolloutJournals, synthesizeMinimalExample } from "./rejection-guidance.js"; diff --git a/src/preference-admission.ts b/src/preference-admission.ts new file mode 100644 index 00000000..95137cb5 --- /dev/null +++ b/src/preference-admission.ts @@ -0,0 +1,262 @@ +import { createHash } from "node:crypto"; + +export type PreferenceToolCall = { + name: string; + arguments: unknown; + effects: readonly string[]; +}; + +export type PreferenceSemantic = { + kind: "tool_calls" | "no_action"; + toolCalls: readonly PreferenceToolCall[]; +}; + +export type PreferencePair = { + pairId: string; + rowId: string; + split: "train" | "dev" | "holdout"; + promptHistorySha256: string; + actionRequired: boolean; + actionFamily?: string; + multiEffectContinuation: boolean; + chosenTokens: readonly number[]; + chosenToolCalls: readonly PreferenceToolCall[]; + expectedToolCalls: readonly PreferenceToolCall[]; + expectedRejectionClass: string; + rejectedPayloadSha256: string; + sourceCapabilitySha256: string; + allowedTrainRowIdsSha256: string; + sourceRowIdSha256: string; + rendererSha256: string; + toolSchemaSha256: string; + runtimeSha256: string; +}; + +export type VerifiedRejectedPayload = { + payloadSha256: string; + classification: string; + semantic: PreferenceSemantic; +}; + +export type PreferenceAdmissionConfig = { + expectedSourceCapabilitySha256: string; + expectedAllowedTrainRowIdsSha256: string; + allowedTrainRowIds: ReadonlySet; + expectedRendererSha256: string; + expectedToolSchemaSha256: string; + expectedRuntimeSha256: string; + requiredRejectionClasses: readonly string[]; + desiredRejectionClasses?: readonly string[]; + requiredActionFamilies?: readonly string[]; + requireMultiEffectContinuation?: boolean; + allowMultipleRejectionsPerRow?: boolean; + verifyRejectedPayload: (pair: PreferencePair) => VerifiedRejectedPayload | null; +}; + +export type PreferenceAdmissionRejection = { + pairId: string | null; + rowId?: string; + reason: string; +}; + +export type PreferenceAdmissionResult = { + admitted: boolean; + pairCount: number; + actionPairCount: number; + noActionPairCount: number; + rejectionClusters: Record; + rejectedPairIds: string[]; + rejections: PreferenceAdmissionRejection[]; + globalRejections: PreferenceAdmissionRejection[]; + missingDesiredRejectionClasses: string[]; +}; + +export const PREFERENCE_REJECTION_CLASSES = Object.freeze([ + "wrong_tool", + "wrong_arguments_or_effects", + "missing_continuation", + "false_positive_mutation", + "collapse_sentinel", +]); + +const SHA256_PATTERN = /^[0-9a-f]{64}$/; + +function stableJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`) + .join(",")}}`; +} + +function semanticForChosen(pair: PreferencePair): PreferenceSemantic { + return { + kind: pair.actionRequired ? "tool_calls" : "no_action", + toolCalls: pair.chosenToolCalls, + }; +} + +function sameToolCall(actual: PreferenceToolCall, expected: PreferenceToolCall): boolean { + return actual.name === expected.name + && stableJson(actual.arguments) === stableJson(expected.arguments) + && stableJson(actual.effects) === stableJson(expected.effects); +} + +function sameSemantic(left: PreferenceSemantic, right: PreferenceSemantic): boolean { + return left.kind === right.kind + && left.toolCalls.length === right.toolCalls.length + && left.toolCalls.every((call, index) => sameToolCall(call, right.toolCalls[index])); +} + +function hasToolContract(pair: PreferencePair): boolean { + return pair.actionRequired + ? pair.chosenToolCalls.length > 0 + && pair.chosenToolCalls.length === pair.expectedToolCalls.length + && pair.chosenToolCalls.every((call, index) => sameToolCall(call, pair.expectedToolCalls[index])) + : pair.chosenToolCalls.length === 0 && pair.expectedToolCalls.length === 0; +} + +function addRejection( + result: { + rejectionClusters: Record; + rejectedPairIds: string[]; + rejections: PreferenceAdmissionRejection[]; + }, + pair: PreferencePair, + reason: string, +): void { + result.rejectionClusters[reason] = (result.rejectionClusters[reason] ?? 0) + 1; + result.rejectedPairIds.push(pair.pairId); + result.rejections.push({ pairId: pair.pairId, rowId: pair.rowId, reason }); +} + +function addGlobalRejection( + result: { + rejectionClusters: Record; + globalRejections: PreferenceAdmissionRejection[]; + }, + reason: string, + pairIds: readonly string[], +): void { + result.rejectionClusters[reason] = (result.rejectionClusters[reason] ?? 0) + 1; + result.globalRejections.push({ pairId: null, rowId: pairIds.join(","), reason }); +} + +export function sha256TokenSequence(tokens: readonly number[]): string { + return createHash("sha256").update(JSON.stringify(tokens)).digest("hex"); +} + +export function isSha256(value: string): boolean { + return SHA256_PATTERN.test(value); +} + +export function admitPreferencePairs( + pairs: readonly PreferencePair[], + config: PreferenceAdmissionConfig, +): PreferenceAdmissionResult { + const result = { + rejectionClusters: {} as Record, + rejectedPairIds: [] as string[], + rejections: [] as PreferenceAdmissionRejection[], + globalRejections: [] as PreferenceAdmissionRejection[], + }; + let actionPairCount = 0; + let noActionPairCount = 0; + const rowIds = new Map(); + const pairIds = new Set(); + const observedClasses = new Set(); + const observedFamilies = new Set(); + const globalPairIds = pairs.map((pair) => pair.pairId); + + for (const pair of pairs) { + if (pair.actionRequired) actionPairCount += 1; + else noActionPairCount += 1; + rowIds.set(pair.rowId, [...(rowIds.get(pair.rowId) ?? []), pair.pairId]); + + if (pairIds.has(pair.pairId)) addRejection(result, pair, "duplicate_pair_id"); + pairIds.add(pair.pairId); + if (!config.allowMultipleRejectionsPerRow && (rowIds.get(pair.rowId)?.length ?? 0) >= 2) { + addRejection(result, pair, "duplicate_source_row_id"); + } + if (pair.split !== "train" || !config.allowedTrainRowIds.has(pair.rowId)) { + addRejection(result, pair, "row_outside_frozen_train_allowlist"); + } + if (pair.sourceCapabilitySha256 !== config.expectedSourceCapabilitySha256) { + addRejection(result, pair, "source_capability_mismatch"); + } + if (pair.allowedTrainRowIdsSha256 !== config.expectedAllowedTrainRowIdsSha256) { + addRejection(result, pair, "allowlist_hash_mismatch"); + } + if (pair.rendererSha256 !== config.expectedRendererSha256) addRejection(result, pair, "renderer_mismatch"); + if (pair.toolSchemaSha256 !== config.expectedToolSchemaSha256) addRejection(result, pair, "tool_schema_mismatch"); + if (pair.runtimeSha256 !== config.expectedRuntimeSha256) addRejection(result, pair, "runtime_mismatch"); + + const hashes = [ + pair.promptHistorySha256, + pair.rejectedPayloadSha256, + pair.sourceCapabilitySha256, + pair.allowedTrainRowIdsSha256, + pair.sourceRowIdSha256, + pair.rendererSha256, + pair.toolSchemaSha256, + pair.runtimeSha256, + ]; + if (hashes.some((hash) => !isSha256(hash))) addRejection(result, pair, "invalid_sha256"); + if (pair.actionRequired && pair.actionFamily) observedFamilies.add(pair.actionFamily); + if (!hasToolContract(pair)) addRejection(result, pair, "chosen_contract_mismatch"); + if (pair.actionRequired + && pair.chosenTokens.length === 3 + && pair.chosenTokens[0] === 16071 + && pair.chosenTokens[1] === 95597 + && pair.chosenTokens[2] === 11) { + addRejection(result, pair, "chosen_collapse_sentinel"); + } + const verified = config.verifyRejectedPayload(pair); + if (verified === null) { + addRejection(result, pair, "missing_rejected_payload_preimage"); + continue; + } + if (!isSha256(verified.payloadSha256) || verified.payloadSha256 !== pair.rejectedPayloadSha256) { + addRejection(result, pair, "rejected_payload_hash_mismatch"); + } + if (verified.classification !== pair.expectedRejectionClass) { + addRejection(result, pair, "rejection_class_mismatch"); + } + if (!PREFERENCE_REJECTION_CLASSES.includes(verified.classification)) { + addRejection(result, pair, "unknown_rejection_class"); + } + observedClasses.add(verified.classification); + if (sameSemantic(semanticForChosen(pair), verified.semantic)) { + addRejection(result, pair, "identical_chosen_rejected_semantics"); + } + } + + if (pairs.length === 0) addGlobalRejection(result, "missing_pairs", globalPairIds); + if (noActionPairCount > actionPairCount) addGlobalRejection(result, "no_action_dominates", globalPairIds); + if (actionPairCount === 0) addGlobalRejection(result, "missing_action_pairs", globalPairIds); + for (const requiredFamily of config.requiredActionFamilies ?? []) { + if (!observedFamilies.has(requiredFamily)) { + addGlobalRejection(result, `missing_action_family:${requiredFamily}`, globalPairIds); + } + } + if (config.requireMultiEffectContinuation && !pairs.some((pair) => pair.actionRequired && pair.multiEffectContinuation)) { + addGlobalRejection(result, "missing_multi_effect_continuation", globalPairIds); + } + for (const requiredClass of config.requiredRejectionClasses) { + if (!observedClasses.has(requiredClass)) { + addGlobalRejection(result, `missing_rejection_class:${requiredClass}`, globalPairIds); + } + } + const missingDesiredRejectionClasses = (config.desiredRejectionClasses ?? []) + .filter((desiredClass) => !observedClasses.has(desiredClass)); + + return { + admitted: result.rejections.length === 0 && result.globalRejections.length === 0, + pairCount: pairs.length, + actionPairCount, + noActionPairCount, + ...result, + missingDesiredRejectionClasses, + }; +} diff --git a/tests/preference-admission.test.mjs b/tests/preference-admission.test.mjs new file mode 100644 index 00000000..ecd2950b --- /dev/null +++ b/tests/preference-admission.test.mjs @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + PREFERENCE_REJECTION_CLASSES, + admitPreferencePairs, +} from "../dist/preference-admission.js"; + +const hash = "a".repeat(64); +const allowlist = new Set(["row-1", "row-2"]); + +function pair(overrides = {}) { + return { + pairId: "pair-1", + rowId: "row-1", + split: "train", + promptHistorySha256: hash, + actionRequired: true, + actionFamily: "write", + multiEffectContinuation: false, + chosenTokens: [1, 2, 3], + chosenToolCalls: [{ name: "write", arguments: { id: "x" }, effects: ["updated:x"] }], + expectedToolCalls: [{ name: "write", arguments: { id: "x" }, effects: ["updated:x"] }], + expectedRejectionClass: "wrong_tool", + rejectedPayloadSha256: "b".repeat(64), + sourceCapabilitySha256: hash, + allowedTrainRowIdsSha256: hash, + sourceRowIdSha256: hash, + rendererSha256: hash, + toolSchemaSha256: hash, + runtimeSha256: hash, + ...overrides, + }; +} + +function config(verifyRejectedPayload, overrides = {}) { + return { + expectedSourceCapabilitySha256: hash, + expectedAllowedTrainRowIdsSha256: hash, + allowedTrainRowIds: allowlist, + expectedRendererSha256: hash, + expectedToolSchemaSha256: hash, + expectedRuntimeSha256: hash, + requiredRejectionClasses: ["wrong_tool", "false_positive_mutation"], + requiredActionFamilies: ["write"], + requireMultiEffectContinuation: true, + verifyRejectedPayload, + ...overrides, + }; +} + +function verified(overrides = {}) { + return { + payloadSha256: "b".repeat(64), + classification: "wrong_tool", + semantic: { kind: "no_action", toolCalls: [] }, + ...overrides, + }; +} + +describe("preference admission", () => { + it("admits verified, balanced, hash-bound pairs with family coverage", () => { + const result = admitPreferencePairs([ + pair({ multiEffectContinuation: true }), + pair({ + pairId: "pair-2", + rowId: "row-2", + actionRequired: false, + actionFamily: undefined, + chosenToolCalls: [], + expectedToolCalls: [], + expectedRejectionClass: "false_positive_mutation", + rejectedPayloadSha256: "c".repeat(64), + }), + ], config((candidate) => candidate.pairId === "pair-1" + ? verified() + : verified({ + payloadSha256: "c".repeat(64), + classification: "false_positive_mutation", + semantic: { kind: "tool_calls", toolCalls: [{ name: "write", arguments: { id: "x" }, effects: ["updated:x"] }] }, + }))); + assert.equal(result.admitted, true); + }); + + it("fails closed on lied availability and nonhex hashes", () => { + const result = admitPreferencePairs([ + pair({ rejectedPayloadSha256: "z".repeat(64) }), + ], config(() => null, { requireMultiEffectContinuation: false })); + assert.equal(result.admitted, false); + assert.match(result.rejections.map((item) => item.reason).join(","), /invalid_sha256/); + assert.match(result.rejections.map((item) => item.reason).join(","), /missing_rejected_payload_preimage/); + }); + + it("rejects duplicates, cross-split rows, mixed provenance, and semantic matches", () => { + const result = admitPreferencePairs([ + pair({ pairId: "duplicate", split: "dev", runtimeSha256: "d".repeat(64) }), + pair({ pairId: "duplicate", rowId: "row-2", sourceCapabilitySha256: "e".repeat(64) }), + pair({ pairId: "duplicate-3" }), + ], config(() => verified({ + semantic: { kind: "tool_calls", toolCalls: [{ name: "write", arguments: { id: "x" }, effects: ["updated:x"] }] }, + }), { requireMultiEffectContinuation: false })); + assert.equal(result.admitted, false); + const reasons = result.rejections.map((item) => item.reason).join(","); + assert.match(reasons, /duplicate_pair_id/); + assert.equal(result.rejections.filter((item) => item.reason === "duplicate_source_row_id").length, 1); + assert.match(reasons, /row_outside_frozen_train_allowlist/); + assert.match(reasons, /runtime_mismatch/); + assert.match(reasons, /source_capability_mismatch/); + assert.match(reasons, /identical_chosen_rejected_semantics/); + }); + + it("rejects the collapse sentinel for action-required rows", () => { + const result = admitPreferencePairs([ + pair({ chosenTokens: [16071, 95597, 11] }), + ], config(() => verified(), { requireMultiEffectContinuation: false })); + assert.equal(result.admitted, false); + assert.equal(result.rejections.filter((item) => item.reason === "chosen_collapse_sentinel").length, 1); + }); + + it("rejects every source-row occurrence after the first", () => { + const result = admitPreferencePairs([ + pair({ pairId: "row-dup-1" }), + pair({ pairId: "row-dup-2" }), + pair({ pairId: "row-dup-3" }), + ], config(() => verified(), { requireMultiEffectContinuation: false })); + assert.equal(result.admitted, false); + assert.equal(result.rejections.filter((item) => item.reason === "duplicate_source_row_id").length, 2); + }); + + it("rejects caller-mislabeled classes and reports global coverage failures", () => { + const result = admitPreferencePairs([ + pair({ expectedRejectionClass: "false_positive_mutation" }), + ], config(() => verified(), { + requiredActionFamilies: ["read", "write"], + requireMultiEffectContinuation: true, + requiredRejectionClasses: [...PREFERENCE_REJECTION_CLASSES], + desiredRejectionClasses: ["wrong_arguments_or_effects"], + })); + assert.equal(result.admitted, false); + assert.match(result.rejections.map((item) => item.reason).join(","), /rejection_class_mismatch/); + const globals = result.globalRejections.map((item) => item.reason).join(","); + assert.match(globals, /missing_action_family:read/); + assert.match(globals, /missing_multi_effect_continuation/); + assert.match(globals, /missing_rejection_class:collapse_sentinel/); + assert.deepEqual(result.missingDesiredRejectionClasses, ["wrong_arguments_or_effects"]); + }); + + it("accepts the no-action sentinel when the row contract is no-action", () => { + const result = admitPreferencePairs([ + pair({ + multiEffectContinuation: true, + expectedRejectionClass: "false_positive_mutation", + }), + pair({ + pairId: "pair-2", + rowId: "row-2", + actionRequired: false, + actionFamily: undefined, + chosenTokens: [16071, 95597, 11], + chosenToolCalls: [], + expectedToolCalls: [], + expectedRejectionClass: "false_positive_mutation", + }), + ], config((candidate) => candidate.pairId === "pair-2" + ? verified({ + classification: "false_positive_mutation", + semantic: { + kind: "tool_calls", + toolCalls: [{ name: "write", arguments: { id: "x" }, effects: ["updated:x"] }], + }, + }) + : verified({ + classification: "false_positive_mutation", + semantic: { kind: "no_action", toolCalls: [] }, + }), { + requireMultiEffectContinuation: false, + requiredRejectionClasses: ["false_positive_mutation"], + })); + assert.equal(result.admitted, true); + }); + + it("distinguishes identical tokens with opposite action signs by semantic outcome", () => { + const result = admitPreferencePairs([ + pair({ multiEffectContinuation: true }), + pair({ + pairId: "pair-2", + rowId: "row-2", + actionRequired: false, + actionFamily: undefined, + chosenTokens: [16071, 95597, 11], + chosenToolCalls: [], + expectedToolCalls: [], + expectedRejectionClass: "wrong_tool", + }), + ], config((candidate) => candidate.pairId === "pair-2" + ? verified({ + semantic: { + kind: "tool_calls", + toolCalls: [{ name: "write", arguments: { id: "x" }, effects: ["updated:x"] }], + }, + }) + : verified({ + semantic: { kind: "no_action", toolCalls: [] }, + }), { + requireMultiEffectContinuation: false, + requiredRejectionClasses: ["wrong_tool"], + })); + assert.equal(result.admitted, true); + }); +});