portable-agent-layer 0.107.0 → 0.109.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,8 @@
2
2
  * Adaptation rules: "when this happens, steer like this", learned from the
3
3
  * user's corrections. A rule starts as a draft and only steers once the user
4
4
  * approves it. A denied rule stays on record so the same rule is not drafted again.
5
+ * An opinion is an approved rule without a trigger: it steers every session, so
6
+ * the opinions together are capped, and a new one past the cap is refused.
5
7
  */
6
8
 
7
9
  import { randomUUID } from "node:crypto";
@@ -9,8 +11,11 @@ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
11
  import { resolve } from "node:path";
10
12
  import { paths } from "./paths";
11
13
 
12
- export type RuleStatus = "draft" | "approved" | "denied";
13
- type Decision = Exclude<RuleStatus, "draft">;
14
+ type RuleStatus = "draft" | "approved" | "denied" | "retired";
15
+ type Decision = "approved" | "denied" | "opinion";
16
+
17
+ const OPINION_CAP = 8;
18
+ const OPINION_CHARS = 1500;
14
19
 
15
20
  export interface RuleTrigger {
16
21
  /** Whether the pattern is matched against the user's prompt or the assistant's reply. */
@@ -28,21 +33,78 @@ export interface TriggerProof {
28
33
 
29
34
  export interface DraftInput {
30
35
  when: string;
31
- trigger: RuleTrigger;
36
+ /** Absent when the situation shows in no text a pattern could match; it can only be an opinion. */
37
+ trigger?: RuleTrigger;
32
38
  steering: string;
39
+ /** The correction kind the rule prevents; its rate measures the rule. */
40
+ kind?: string;
33
41
  evidence: string[];
34
42
  /** How to tell from a reply whether the steering was followed. */
35
43
  check?: string;
36
44
  proof?: TriggerProof;
37
45
  /** The approved rule whose trigger this draft replaces with a wider one. */
38
46
  widens?: string;
47
+ /** Its trigger failed the proof, so it can only be kept as an opinion or denied. */
48
+ offer?: "opinion-only";
49
+ /** Why it cannot be kept as an opinion: too few corrections of its kind, or another targets it. */
50
+ opinionGate?: OpinionGate;
39
51
  }
40
52
 
53
+ export type OpinionGate = { corrections: number } | { duplicates: string };
54
+
41
55
  export interface AdaptationRule extends DraftInput {
42
56
  id: string;
43
57
  status: RuleStatus;
58
+ /** "always" makes an approved rule an opinion, injected every session. */
59
+ delivery?: "always";
44
60
  createdAt: string;
45
61
  decidedAt?: string;
62
+ retiredAt?: string;
63
+ }
64
+
65
+ export function isOpinion(rule: AdaptationRule): boolean {
66
+ return rule.status === "approved" && rule.delivery === "always";
67
+ }
68
+
69
+ type Deliverable = Pick<AdaptationRule, "delivery" | "trigger"> & { status: string };
70
+
71
+ export function isTriggerRule<R extends Deliverable>(
72
+ rule: R
73
+ ): rule is R & { trigger: RuleTrigger } {
74
+ return rule.status === "approved" && rule.delivery !== "always" && !!rule.trigger;
75
+ }
76
+
77
+ function opinionLine(rule: Pick<AdaptationRule, "when" | "steering">): string {
78
+ return `- ${rule.when}: ${rule.steering}`;
79
+ }
80
+
81
+ export function opinionsContext(): string {
82
+ const opinions = readRules().filter(isOpinion);
83
+ if (opinions.length === 0) return "";
84
+ return [
85
+ "## Opinions",
86
+ "The user approved these from past corrections. Follow them on every turn:",
87
+ ...opinions.map(opinionLine),
88
+ ].join("\n");
89
+ }
90
+
91
+ function sameKindOpinion(rules: AdaptationRule[], added: AdaptationRule): string | null {
92
+ const taken = added.kind && rules.find((r) => isOpinion(r) && r.kind === added.kind);
93
+ return taken
94
+ ? `Opinion ${taken.id} already targets ${added.kind}, and two would share one rate; retire it first`
95
+ : null;
96
+ }
97
+
98
+ function opinionRefusal(rules: AdaptationRule[], added: AdaptationRule): string | null {
99
+ const opinions = [...rules.filter(isOpinion), added];
100
+ const sameKind = sameKindOpinion(rules, added);
101
+ if (sameKind) return sameKind;
102
+ if (opinions.length > OPINION_CAP)
103
+ return `Opinions are capped at ${OPINION_CAP}; retire one first`;
104
+ const size = opinions.map(opinionLine).join("\n").length;
105
+ if (size > OPINION_CHARS)
106
+ return `Opinions are capped at ${OPINION_CHARS} characters, this one would make ${size}; retire one first`;
107
+ return null;
46
108
  }
47
109
 
48
110
  type DecideResult = { ok: true; rule: AdaptationRule } | { ok: false; reason: string };
@@ -101,18 +163,85 @@ function approveWidening(
101
163
  return { ok: true, rule: widened };
102
164
  }
103
165
 
166
+ function decided(rule: AdaptationRule, decision: Decision): AdaptationRule {
167
+ const decidedAt = new Date().toISOString();
168
+ if (decision === "opinion")
169
+ return { ...rule, status: "approved", delivery: "always", decidedAt };
170
+ return { ...rule, status: decision, decidedAt };
171
+ }
172
+
173
+ function replaceRule(rules: AdaptationRule[], rule: AdaptationRule): DecideResult {
174
+ writeRules(rules.map((r) => (r.id === rule.id ? rule : r)));
175
+ return { ok: true, rule };
176
+ }
177
+
178
+ function gateReason(gate: OpinionGate): string {
179
+ return "corrections" in gate
180
+ ? `its kind has only ${gate.corrections} corrections`
181
+ : `${gate.duplicates} already targets its kind`;
182
+ }
183
+
184
+ function decisionRefusal(rule: AdaptationRule, decision: Decision): string | null {
185
+ if (decision === "opinion" && rule.widens)
186
+ return `Rule ${rule.id} widens a trigger; it cannot be an opinion`;
187
+ if (decision === "opinion" && !rule.kind)
188
+ return `Rule ${rule.id} has no correction kind, so its effect could not be measured`;
189
+ if (decision === "opinion" && rule.opinionGate)
190
+ return `Rule ${rule.id} cannot be an opinion: ${gateReason(rule.opinionGate)}`;
191
+ if (decision === "approved" && !rule.trigger)
192
+ return `Rule ${rule.id} has no trigger; keep it as an opinion or deny it`;
193
+ if (decision === "approved" && rule.offer === "opinion-only")
194
+ return `Rule ${rule.id}'s trigger did not prove; keep it as an opinion or deny it`;
195
+ return null;
196
+ }
197
+
104
198
  export function decideRule(id: string, decision: Decision): DecideResult {
105
199
  const rules = readRules();
106
200
  const rule = rules.find((r) => r.id === id);
107
201
  if (!rule) return { ok: false, reason: `No rule with id ${id}` };
108
202
  if (rule.status !== "draft")
109
203
  return { ok: false, reason: `Rule ${id} was already ${rule.status}` };
204
+ const refused = decisionRefusal(rule, decision);
205
+ if (refused) return { ok: false, reason: refused };
110
206
  if (rule.widens && decision === "approved") return approveWidening(rules, rule);
111
- const decided: AdaptationRule = {
112
- ...rule,
113
- status: decision,
114
- decidedAt: new Date().toISOString(),
207
+ const result = decided(rule, decision);
208
+ const refusal = isOpinion(result) ? opinionRefusal(rules, result) : null;
209
+ return refusal ? { ok: false, reason: refusal } : replaceRule(rules, result);
210
+ }
211
+
212
+ export interface OpinionInput {
213
+ when: string;
214
+ steering: string;
215
+ kind: string;
216
+ evidence: string[];
217
+ }
218
+
219
+ export function addOpinion(input: OpinionInput): DecideResult {
220
+ const now = new Date().toISOString();
221
+ const opinion: AdaptationRule = {
222
+ ...input,
223
+ id: randomUUID().slice(0, 8),
224
+ status: "approved",
225
+ delivery: "always",
226
+ createdAt: now,
227
+ decidedAt: now,
115
228
  };
116
- writeRules(rules.map((r) => (r.id === id ? decided : r)));
117
- return { ok: true, rule: decided };
229
+ const rules = readRules();
230
+ const refusal = opinionRefusal(rules, opinion);
231
+ if (refusal) return { ok: false, reason: refusal };
232
+ writeRules([...rules, opinion]);
233
+ return { ok: true, rule: opinion };
234
+ }
235
+
236
+ export function retireRule(id: string): DecideResult {
237
+ const rules = readRules();
238
+ const rule = rules.find((r) => r.id === id);
239
+ if (!rule) return { ok: false, reason: `No rule with id ${id}` };
240
+ if (rule.status !== "approved")
241
+ return { ok: false, reason: `Rule ${id} is ${rule.status}, not approved` };
242
+ return replaceRule(rules, {
243
+ ...rule,
244
+ status: "retired",
245
+ retiredAt: new Date().toISOString(),
246
+ });
118
247
  }
@@ -5,7 +5,12 @@
5
5
  */
6
6
 
7
7
  import { appendFileSync } from "node:fs";
8
- import { type AdaptationRule, type RuleTrigger, readRules } from "./adaptation-rules";
8
+ import {
9
+ type AdaptationRule,
10
+ isTriggerRule,
11
+ type RuleTrigger,
12
+ readRules,
13
+ } from "./adaptation-rules";
9
14
  import { alreadySentBack } from "./claim-log";
10
15
  import { hookFinalReply, hookSessionId } from "./hook-turn";
11
16
  import { replyEnd } from "./interaction-samples";
@@ -20,7 +25,7 @@ const REMINDER_HEADER =
20
25
  type Side = RuleTrigger["side"];
21
26
 
22
27
  function triggers(rule: AdaptationRule, side: Side, text: string): boolean {
23
- if (rule.status !== "approved" || rule.trigger.side !== side) return false;
28
+ if (!isTriggerRule(rule) || rule.trigger.side !== side) return false;
24
29
  try {
25
30
  return new RegExp(rule.trigger.pattern, "i").test(text);
26
31
  } catch {
@@ -6,7 +6,15 @@
6
6
  * other's lines; a month is deleted whole once it ends before the window.
7
7
  */
8
8
 
9
- import { appendFileSync, existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
9
+ import {
10
+ appendFileSync,
11
+ existsSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ renameSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from "node:fs";
10
18
  import { resolve } from "node:path";
11
19
  import { paths } from "./paths";
12
20
 
@@ -24,6 +32,15 @@ export interface TurnInput {
24
32
  issue: string;
25
33
  /** Set on a correction: whether the second label agreed. */
26
34
  confirmed?: boolean;
35
+ /** Set on a confirmed correction: its kind, under the kind set's version. */
36
+ correctionKind?: StoredCorrectionKind;
37
+ }
38
+
39
+ interface StoredCorrectionKind {
40
+ kind: string;
41
+ suggested: string;
42
+ description: string;
43
+ version: number;
27
44
  }
28
45
 
29
46
  export interface Turn extends TurnInput {
@@ -99,6 +116,31 @@ function parseLine(line: string): Turn | null {
99
116
  }
100
117
  }
101
118
 
119
+ export function turnKey(turn: Turn): string {
120
+ return `${turn.session} ${turn.ts}`;
121
+ }
122
+
123
+ function replacedLine(line: string, replacements: Map<string, Turn>): string {
124
+ const turn = parseLine(line);
125
+ const replacement = turn && replacements.get(turnKey(turn));
126
+ return replacement ? JSON.stringify(replacement) : line;
127
+ }
128
+
129
+ /**
130
+ * Rewrites the given turns in place. Each month file is read and replaced in one
131
+ * synchronous step, leaving only microseconds in which another session's append
132
+ * could be lost; a backfill runs once, so that window is accepted.
133
+ */
134
+ export function replaceTurns(replacements: Map<string, Turn>): void {
135
+ for (const file of monthFiles()) {
136
+ const path = resolve(paths.adaptation(), file);
137
+ const lines = readFileSync(path, "utf-8").split("\n");
138
+ const rewritten = lines.map((line) => replacedLine(line, replacements));
139
+ writeFileSync(`${path}.tmp`, rewritten.join("\n"));
140
+ renameSync(`${path}.tmp`, path);
141
+ }
142
+ }
143
+
102
144
  export function readTurns(days: number = WINDOW_DAYS, now: Date = new Date()): Turn[] {
103
145
  const cutoff = now.getTime() - days * DAY_MS;
104
146
  return monthFiles()
@@ -5,11 +5,11 @@
5
5
 
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
+ import { opinionsContext } from "./adaptation-rules";
8
9
  import type { AgentName } from "./agent-registry";
9
10
  import { resolveAnchor } from "./anchor";
10
11
  import { dueNudgeSections } from "./daily-nudge";
11
12
  import { loadHandoffContext } from "./handoff-context";
12
- import { loadOpinionContext } from "./opinions";
13
13
  import { paths, toPath } from "./paths";
14
14
  import { loadActiveProjectsContext } from "./projects";
15
15
  import { loadRecentNotes } from "./relationship";
@@ -215,7 +215,7 @@ export function buildSystemReminder(
215
215
  const failures =
216
216
  settings.isEnabled("failurePatterns") && !skipSemiStatic ? loadFailurePatterns() : "";
217
217
  const opinions =
218
- !skipSemiStatic && settings.isEnabled("opinions") ? loadOpinionContext() : "";
218
+ !skipSemiStatic && settings.isEnabled("opinions") ? opinionsContext() : "";
219
219
  const selfModel =
220
220
  !skipSemiStatic && settings.isEnabled("selfModel") ? loadSelfModel() : "";
221
221
  const steering = skipSemiStatic ? "" : loadSteeringRules();
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Labels the confirmed corrections that carry no kind of the current version, so
3
+ * the rate of each kind has one labelling across its history. A relabel runs over
4
+ * every confirmed correction again.
5
+ */
6
+
7
+ import { readTurns, replaceTurns, type Turn, turnKey } from "./adaptation-turns";
8
+ import {
9
+ type CorrectionKind,
10
+ correctionKindRequest,
11
+ hasCurrentKind,
12
+ parseCorrectionKind,
13
+ withCorrectionKind,
14
+ } from "./correction-kind";
15
+ import { inference } from "./inference";
16
+
17
+ export type KindLabeller = (turn: Turn) => Promise<CorrectionKind | null>;
18
+
19
+ export interface BackfillResult {
20
+ labelled: number;
21
+ failed: number;
22
+ }
23
+
24
+ export interface BackfillOptions {
25
+ label?: KindLabeller;
26
+ relabel?: boolean;
27
+ onLabelled?: (turn: Turn, kind: CorrectionKind | null) => void;
28
+ }
29
+
30
+ export function correctionsToLabel(turns: Turn[], relabel = false): Turn[] {
31
+ return turns.filter(
32
+ (turn) => turn.confirmed === true && (relabel || !hasCurrentKind(turn))
33
+ );
34
+ }
35
+
36
+ export async function labelWithInference(turn: Turn): Promise<CorrectionKind | null> {
37
+ const request = correctionKindRequest(turn.replyEnd, turn.message, turn.issue);
38
+ const result = await inference(request);
39
+ return result.success ? parseCorrectionKind(result.output) : null;
40
+ }
41
+
42
+ export async function backfillCorrectionKinds(
43
+ options: BackfillOptions = {}
44
+ ): Promise<BackfillResult> {
45
+ const label = options.label ?? labelWithInference;
46
+ const replacements = new Map<string, Turn>();
47
+ let failed = 0;
48
+ for (const turn of correctionsToLabel(readTurns(), options.relabel)) {
49
+ const kind = await label(turn);
50
+ options.onLabelled?.(turn, kind);
51
+ if (!kind) failed++;
52
+ else replacements.set(turnKey(turn), { ...turn, ...withCorrectionKind(turn, kind) });
53
+ }
54
+ if (replacements.size > 0) replaceTurns(replacements);
55
+ return { labelled: replacements.size, failed };
56
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The kind of mistake a confirmed correction points at. Rates of each kind
3
+ * before and after an opinion is approved measure whether the opinion works, so
4
+ * the set is closed; a correction that fits none lands in `other` with the
5
+ * model's own name for it, which a later check can promote to a kind.
6
+ */
7
+
8
+ import type { TurnInput } from "./adaptation-turns";
9
+
10
+ /** Bump on every change to the kinds or the prompt: rates compare only labels of one version. */
11
+ export const CORRECTION_KINDS_VERSION = 2;
12
+
13
+ export function hasCurrentKind<T extends TurnInput>(
14
+ turn: T
15
+ ): turn is T & Required<Pick<TurnInput, "correctionKind">> {
16
+ return turn.correctionKind?.version === CORRECTION_KINDS_VERSION;
17
+ }
18
+
19
+ const NAMED_KINDS = {
20
+ "missed-the-question":
21
+ "the reply did not answer what the user asked, or buried the answer",
22
+ "unverified-claim":
23
+ "the reply stated a fact or the state of something (tests pass, CI is green, a tool is missing, a fix is merged) that was wrong or never checked",
24
+ "off-instruction":
25
+ "the reply went against an instruction the user had given in an earlier message: it reordered their steps, worked on a different target than the one they named, changed the scope, or put the work somewhere else, such as a separate PR",
26
+ "revisited-decision":
27
+ "the reply proposed or kept something the user had already ruled out or decided against earlier",
28
+ "stopped-short":
29
+ "the reply deferred, paused or handed back work the user wanted done now, or waited for something (a merge, a review, an answer) instead of starting",
30
+ "wrong-format":
31
+ "the reply ignored the shape or length the user asked for, such as a table, a list or one line, or padded the answer",
32
+ "no-verdict":
33
+ "the user wanted a judgement and the reply gave none: it agreed without reasoning, listed options without picking one, or criticised without an alternative",
34
+ "dismissed-user-view":
35
+ "the reply argued past or brushed aside the user's own view or hypothesis",
36
+ "undefined-reference":
37
+ "the reply used a term, name, number or id the user did not know or had not been told; the user says they don't know it, or asks what it is or to list it",
38
+ "skipped-existing":
39
+ "the reply designed or built something without checking what already exists: code, a mechanism, a tool, a convention or prior art",
40
+ "status-quo-bias":
41
+ "the reply treated the current setup as a fixed constraint instead of considering a better design",
42
+ } as const;
43
+
44
+ type NamedKind = keyof typeof NAMED_KINDS;
45
+ export type CorrectionKindName = NamedKind | "other";
46
+
47
+ export const CORRECTION_KINDS = [
48
+ ...(Object.keys(NAMED_KINDS) as NamedKind[]),
49
+ "other",
50
+ ] as const satisfies readonly CorrectionKindName[];
51
+
52
+ export interface CorrectionKind {
53
+ kind: CorrectionKindName;
54
+ suggested: string;
55
+ description: string;
56
+ }
57
+
58
+ const KIND_LINES = Object.entries(NAMED_KINDS)
59
+ .map(([kind, meaning]) => `- ${kind}: ${meaning}.`)
60
+ .join("\n");
61
+
62
+ export const CORRECTION_KIND_SYSTEM = `The user corrected an AI assistant's reply. Name the kind of mistake the reply made.
63
+
64
+ Pick the one kind that best explains why the user had to correct the reply:
65
+ ${KIND_LINES}
66
+ - other: none of the kinds above fits.
67
+
68
+ Judge the mistake, not the topic: a wrong claim about CI is unverified-claim, and a question about CI the reply never answered is missed-the-question.
69
+
70
+ The user's correction is often a question itself. That alone does not make it missed-the-question: missed-the-question means the reply failed to answer something the user had asked before the reply. When the correction asks a new question, find the mistake that made the user ask it.
71
+
72
+ The first reader's words are a hint, not the answer. It writes "did not answer" or "did not address the question" for many kinds of mistake, so do not pick missed-the-question because of that wording; judge the reply and the message yourself.
73
+
74
+ missed-the-question is the most general kind, so pick it last: only when no more specific kind below explains the mistake.
75
+
76
+ When kinds overlap, prefer the most specific one:
77
+ - the user does not know or understand something the reply referred to ("what is X?", "I don't get", "list them"): undefined-reference, even when they ask about it;
78
+ - the user asked whether something is worth it, right, or should be done, and the reply gave no clear yes or no: no-verdict;
79
+ - the user asked why, and the reply argued for something else instead of giving the reason: missed-the-question, not dismissed-user-view, even when the issue says the reply contradicted the user. dismissed-user-view needs a view or idea the user had put forward that the reply rejected;
80
+ - the user says they meant a different thing than the reply worked on: off-instruction;
81
+ - a correction nearly always tells the assistant what to do instead. That alone is never off-instruction, which needs an instruction from an earlier message that the reply broke;- the user reminds the reply of something agreed or ruled out earlier ("didn't we say", "we discussed"): revisited-decision;
82
+ - the reply put off work the user wanted now, or said it would start only after something else happened (a merge, a review, an answer): stopped-short, even when it also changed the order.
83
+
84
+ Pick a kind only when its description fits the mistake itself. When a kind fits only loosely, answer other.
85
+
86
+ For other, set suggested to a short lowercase kebab-case name for the mistake, and description to one sentence saying what the reply did wrong. For any other kind, set both to an empty string.`;
87
+
88
+ const CORRECTION_KIND_SCHEMA = {
89
+ type: "object",
90
+ properties: {
91
+ kind: { enum: [...CORRECTION_KINDS] },
92
+ suggested: { type: "string" },
93
+ description: { type: "string" },
94
+ },
95
+ required: ["kind", "suggested", "description"],
96
+ additionalProperties: false,
97
+ } as const;
98
+
99
+ export function correctionKindSchema() {
100
+ return CORRECTION_KIND_SCHEMA;
101
+ }
102
+
103
+ const MESSAGE_MAX = 800;
104
+
105
+ export function correctionKindUser(replyEnd: string, message: string, issue: string) {
106
+ return `The end of the assistant's reply:\n<reply_end>\n${replyEnd}\n</reply_end>\n\nThe user's correction:\n<message>\n${message.slice(0, MESSAGE_MAX)}\n</message>\n\nWhat the reply got wrong, in a first reader's words:\n<issue>\n${issue}\n</issue>`;
107
+ }
108
+
109
+ export function correctionKindRequest(
110
+ replyEnd: string,
111
+ message: string,
112
+ issue: string,
113
+ sessionId?: string
114
+ ) {
115
+ return {
116
+ system: CORRECTION_KIND_SYSTEM,
117
+ user: correctionKindUser(replyEnd, message, issue),
118
+ maxTokens: 200,
119
+ timeout: 90_000,
120
+ jsonSchema: CORRECTION_KIND_SCHEMA,
121
+ caller: "correction-kind",
122
+ sessionId,
123
+ };
124
+ }
125
+
126
+ function text(value: unknown): string {
127
+ return typeof value === "string" ? value.trim() : "";
128
+ }
129
+
130
+ function isCorrectionKind(value: unknown): value is CorrectionKindName {
131
+ return CORRECTION_KINDS.includes(value as CorrectionKindName);
132
+ }
133
+
134
+ function unlistedKind(kind: unknown, description: string): CorrectionKind | null {
135
+ const suggested = text(kind);
136
+ return suggested ? { kind: "other", suggested, description } : null;
137
+ }
138
+
139
+ export function parseCorrectionKind(
140
+ output: string | null | undefined
141
+ ): CorrectionKind | null {
142
+ if (!output) return null;
143
+ try {
144
+ const parsed = JSON.parse(output) as Record<string, unknown>;
145
+ const description = text(parsed.description);
146
+ if (!isCorrectionKind(parsed.kind)) return unlistedKind(parsed.kind, description);
147
+ if (parsed.kind !== "other")
148
+ return { kind: parsed.kind, suggested: "", description: "" };
149
+ return { kind: "other", suggested: text(parsed.suggested), description };
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ export function needsCorrectionKind(turn: TurnInput | null): turn is TurnInput {
156
+ return turn?.confirmed === true;
157
+ }
158
+
159
+ export function withCorrectionKind(
160
+ turn: TurnInput,
161
+ kind: CorrectionKind | null
162
+ ): TurnInput {
163
+ if (!kind) return turn;
164
+ return { ...turn, correctionKind: { ...kind, version: CORRECTION_KINDS_VERSION } };
165
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * How often each kind of correction happens, per 100 labelled turns. An opinion
3
+ * works when the rate of its kind drops after it is approved.
4
+ */
5
+
6
+ import { type AdaptationRule, isOpinion } from "./adaptation-rules";
7
+ import type { Turn } from "./adaptation-turns";
8
+ import { hasCurrentKind } from "./correction-kind";
9
+
10
+ interface KindRate {
11
+ kind: string;
12
+ count: number;
13
+ per100: number;
14
+ suggestions: string[];
15
+ }
16
+
17
+ export interface KindRates {
18
+ turns: number;
19
+ corrections: number;
20
+ unlabelled: number;
21
+ /** Labelled by an older prompt or kind set; backfill relabels them. */
22
+ stale: number;
23
+ kinds: KindRate[];
24
+ }
25
+
26
+ function confirmedCorrections(turns: Turn[]): Turn[] {
27
+ return turns.filter((turn) => turn.confirmed === true);
28
+ }
29
+
30
+ function per100(count: number, turns: number): number {
31
+ return turns === 0 ? 0 : Math.round((count / turns) * 1000) / 10;
32
+ }
33
+
34
+ function rateOf(kind: string, labelled: Turn[], turns: number): KindRate {
35
+ const ofKind = labelled.filter((turn) => turn.correctionKind?.kind === kind);
36
+ const suggestions = ofKind
37
+ .map((turn) => turn.correctionKind?.suggested ?? "")
38
+ .filter((name) => name !== "");
39
+ return {
40
+ kind,
41
+ count: ofKind.length,
42
+ per100: per100(ofKind.length, turns),
43
+ suggestions,
44
+ };
45
+ }
46
+
47
+ export function kindRates(turns: Turn[]): KindRates {
48
+ const corrections = confirmedCorrections(turns);
49
+ const labelled = corrections.filter((turn) => turn.correctionKind);
50
+ const kinds = [...new Set(labelled.map((turn) => turn.correctionKind?.kind ?? ""))]
51
+ .map((kind) => rateOf(kind, labelled, turns.length))
52
+ .sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
53
+ return {
54
+ turns: turns.length,
55
+ corrections: corrections.length,
56
+ unlabelled: corrections.length - labelled.length,
57
+ stale: labelled.filter((turn) => !hasCurrentKind(turn)).length,
58
+ kinds,
59
+ };
60
+ }
61
+
62
+ const MIN_TURNS_AFTER = 200;
63
+ const MIN_CORRECTIONS_BEFORE = 5;
64
+ const MAX_WINDOW_MS = 30 * 86_400_000;
65
+
66
+ interface WindowRate {
67
+ turns: number;
68
+ count: number;
69
+ per100: number;
70
+ }
71
+
72
+ interface Windows {
73
+ before: WindowRate;
74
+ after: WindowRate;
75
+ }
76
+
77
+ export interface OpinionEffect extends Windows {
78
+ kind: string;
79
+ /** The kinds no opinion targets, over the same windows: what changed anyway. */
80
+ control: Windows;
81
+ verdict: "stale-labels" | "too-early" | "too-few" | "lower" | "not-lower";
82
+ }
83
+
84
+ function hasStaleLabels(turns: Turn[]): boolean {
85
+ return confirmedCorrections(turns).some((turn) => !hasCurrentKind(turn));
86
+ }
87
+
88
+ function windowRate(counts: (kind: string) => boolean, turns: Turn[]): WindowRate {
89
+ const count = kindRates(turns)
90
+ .kinds.filter((k) => counts(k.kind))
91
+ .reduce((sum, k) => sum + k.count, 0);
92
+ return { turns: turns.length, count, per100: per100(count, turns.length) };
93
+ }
94
+
95
+ function ratio({ before, after }: Windows): number {
96
+ return before.per100 === 0 ? 1 : after.per100 / before.per100;
97
+ }
98
+
99
+ function effectVerdict(own: Windows, control: Windows): OpinionEffect["verdict"] {
100
+ if (own.after.turns < MIN_TURNS_AFTER) return "too-early";
101
+ if (own.before.count < MIN_CORRECTIONS_BEFORE) return "too-few";
102
+ return ratio(own) < ratio(control) ? "lower" : "not-lower";
103
+ }
104
+
105
+ export function opinionEffect(
106
+ kind: string,
107
+ approvedAt: Date,
108
+ turns: Turn[],
109
+ opinionKinds: string[] = [kind],
110
+ now: Date = new Date()
111
+ ): OpinionEffect {
112
+ const cut = approvedAt.getTime();
113
+ const span = Math.min(MAX_WINDOW_MS, now.getTime() - cut);
114
+ const at = (turn: Turn) => Date.parse(turn.ts);
115
+ const beforeTurns = turns.filter((t) => at(t) >= cut - span && at(t) < cut);
116
+ const afterTurns = turns.filter((t) => at(t) >= cut && at(t) < cut + span);
117
+ const windows = (counts: (k: string) => boolean): Windows => ({
118
+ before: windowRate(counts, beforeTurns),
119
+ after: windowRate(counts, afterTurns),
120
+ });
121
+ const own = windows((k) => k === kind);
122
+ const control = windows((k) => k !== kind && !opinionKinds.includes(k));
123
+ const verdict = hasStaleLabels([...beforeTurns, ...afterTurns])
124
+ ? "stale-labels"
125
+ : effectVerdict(own, control);
126
+ return { kind, ...own, control, verdict };
127
+ }
128
+
129
+ export const EFFECT_WINDOW_DAYS = (2 * MAX_WINDOW_MS) / 86_400_000;
130
+
131
+ export function opinionEffectOf(
132
+ rule: AdaptationRule,
133
+ rules: AdaptationRule[],
134
+ turns: Turn[],
135
+ now: Date = new Date()
136
+ ): OpinionEffect | null {
137
+ if (!isOpinion(rule) || !rule.kind || !rule.decidedAt) return null;
138
+ const opinionKinds = rules.filter(isOpinion).flatMap((r) => (r.kind ? [r.kind] : []));
139
+ return opinionEffect(rule.kind, new Date(rule.decidedAt), turns, opinionKinds, now);
140
+ }
141
+
142
+ export function splitRates(
143
+ turns: Turn[],
144
+ at: Date
145
+ ): { before: KindRates; after: KindRates } {
146
+ const cut = at.getTime();
147
+ return {
148
+ before: kindRates(turns.filter((turn) => Date.parse(turn.ts) < cut)),
149
+ after: kindRates(turns.filter((turn) => Date.parse(turn.ts) >= cut)),
150
+ };
151
+ }