@drkmttr/adhere 0.15.0 → 0.18.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.
@@ -1,10 +1,20 @@
1
- import type { AppendState, JevState, JudgedFile, RuleId } from "#config.ts";
1
+ import type { AppendState, JevState, JudgedFile, ReadState, RuleId } from "#config.ts";
2
2
  import { sectionsOf } from "#excerpt.ts";
3
+ import { jev } from "#providers/jev.ts";
4
+ import {
5
+ Answered,
6
+ type Provider,
7
+ type Question,
8
+ RequestFailed,
9
+ type State,
10
+ } from "#providers/provider.ts";
11
+ import { questionsOf, type SystemOneQuestion } from "#providers/systemOne.ts";
3
12
  import { AdhereConfig } from "#services/AdhereConfig.ts";
4
13
  import { Credentials } from "#services/Credentials.ts";
5
14
  import {
6
15
  type Body,
7
16
  type ComparedRule,
17
+ describeState,
8
18
  edgeKey,
9
19
  conflictBody,
10
20
  contradictBody,
@@ -27,62 +37,50 @@ import {
27
37
  type Rules,
28
38
  sufficiencyKey,
29
39
  tokensOf,
40
+ logRequestPayload,
30
41
  } from "#services/Jev.ts";
31
42
  import {
32
- type Cause,
33
43
  Duration,
34
44
  Effect,
35
45
  Layer,
36
- Option,
46
+ Predicate,
37
47
  Record,
48
+ Redacted,
38
49
  Result,
39
50
  Schedule,
40
51
  Schema,
41
52
  } from "effect";
42
- import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http";
43
- import * as HttpClientRequest from "effect/http/HttpClientRequest";
53
+ import { FetchHttpClient } from "effect/http";
44
54
  import { RateLimiter } from "effect/persistence";
45
55
 
46
- const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
47
-
48
- const NoulAnswers = Schema.Struct({
49
- answers: Schema.Record(Schema.String, Schema.Struct({ noul: Schema.Finite })),
56
+ /** A provider's answers to `noul`s, which the eval's harness reads too, to `choice`s, and to a locate request's both. */
57
+ export const NoulAnswers = Schema.Struct({
58
+ ...Answered.fields,
59
+ answers: Schema.Record(Schema.String, Schema.Finite),
50
60
  });
51
61
  const ChoiceAnswers = Schema.Struct({
52
- answers: Schema.Record(Schema.String, Schema.Struct({ choice: Schema.String })),
53
- });
54
- /** A locate request's answers: a choice per rule, and a noul per sufficiency question. */
55
- const LocateAnswers = Schema.Struct({
56
- answers: Schema.Record(
57
- Schema.String,
58
- Schema.Struct({
59
- choice: Schema.optionalKey(Schema.String),
60
- noul: Schema.optionalKey(Schema.Finite),
61
- }),
62
- ),
62
+ ...Answered.fields,
63
+ answers: Schema.Record(Schema.String, Schema.String),
63
64
  });
65
+ const LocateAnswers = Answered;
64
66
 
65
67
  const refused = (message: string) => JevUnavailable.make({ message });
66
68
 
69
+ const messageOf = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause));
70
+
67
71
  /** Bun's API, for a rule's hook. Read off `globalThis`, as presets.ts reads it: Vitest runs this on Node. */
68
72
  const bun = (globalThis as { readonly Bun?: unknown }).Bun as Parameters<AppendState>[2];
69
73
 
70
74
  /**
71
- * A body as it goes out: when its rule's `reads` names the workspace's
72
- * packages, with them in its state, and when it has `appendState`, with what
73
- * the hook returns spread over that. A hook that fails refuses the run.
75
+ * A prepared body as it goes out: `appendState` sees the shared enriched
76
+ * state, with what it returns spread over this group's state alone.
77
+ * A hook that fails refuses the run.
74
78
  */
75
79
  const hooked = (
76
- sent: Body & { readonly state: JevState },
80
+ body: Body<SystemOneQuestion> & { readonly state: JevState },
77
81
  group: Rules,
78
82
  file: JudgedFile,
79
- workspacePackages: Readonly<Record<string, string>>,
80
- ): Effect.Effect<Body, JevUnavailable> => {
81
- const body = Object.values(group).some(
82
- (rule) => rule.reads?.includes("workspacePackages") === true,
83
- )
84
- ? { ...sent, state: { ...sent.state, workspacePackages } }
85
- : sent;
83
+ ): Effect.Effect<Body<SystemOneQuestion>, JevUnavailable> => {
86
84
  const [hook] = Object.entries(group).flatMap(([id, rule]) =>
87
85
  rule.appendState === undefined ? [] : [[id, rule.appendState] as const],
88
86
  );
@@ -91,71 +89,80 @@ const hooked = (
91
89
  return Effect.tryPromise({
92
90
  try: async () => ({
93
91
  ...body,
94
- state: { ...body.state, ...(await appendState(body.state, file, bun)) },
92
+ state: describeState({ ...body.state, ...(await appendState(body.state, file, bun)) }),
95
93
  }),
96
- catch: (cause) =>
97
- refused(
98
- `the appendState of ${id} failed: ${cause instanceof Error ? cause.message : String(cause)}`,
99
- ),
94
+ catch: (cause) => refused(`the appendState of ${id} failed: ${messageOf(cause)}`),
100
95
  });
101
96
  };
102
97
 
103
- /** A failed attempt, for the log: the status and Ray ID when Jev's side answered, the cause otherwise. */
104
- const attemptFailure = (
105
- problem: HttpClientError.HttpClientError | RateLimiter.RateLimiterError | Cause.TimeoutError,
106
- ): string =>
107
- HttpClientError.isHttpClientError(problem) && problem.reason._tag === "StatusCodeError"
108
- ? `HTTP ${problem.reason.response.status}, Ray ID ${problem.reason.response.headers["cf-ray"] ?? "none"}`
109
- : problem.message;
110
-
111
- /** Jev's error body, on one line and cut short, or nothing when it sent none. */
112
- const detailOf = (body: string): string => {
113
- const text = body.trim().replace(/\s+/g, " ");
114
- return text.length === 0 ? "" : `: ${text.slice(0, 300)}`;
98
+ /**
99
+ * A provider's rejection, as the run takes it: a `ContextOverflow` skips the
100
+ * file, a `RequestBlocked` reports it blocked, and anything else refuses the
101
+ * run with what the provider said. Read by tag, not class, so a provider
102
+ * built against another copy of adhere is read the same.
103
+ */
104
+ const rejected = (cause: unknown): JevOverflow | JevBlocked | JevUnavailable => {
105
+ if (Predicate.isTagged(cause, "ContextOverflow")) return JevOverflow.make();
106
+ if (Predicate.isTagged(cause, "RequestBlocked") && Predicate.hasProperty(cause, "reference")) {
107
+ return JevBlocked.make({ ray: String(cause.reference) });
108
+ }
109
+ return refused(messageOf(cause));
115
110
  };
116
111
 
117
- /** What Jev answers, with a 400, to a request over its context. */
118
- const Overflowed = Schema.fromJsonString(
119
- Schema.Struct({ detail: Schema.Struct({ error_type: Schema.Literal("max_tokens_exceeded") }) }),
120
- );
121
- const isOverflow = (body: string): boolean =>
122
- Option.isSome(Schema.decodeUnknownOption(Overflowed)(body));
123
-
124
- /** A firewall's block page: HTML where Jev answers JSON, even for its own errors. */
125
- const isBlockPage = (response: HttpClientResponse.HttpClientResponse, body: string): boolean =>
126
- (response.headers["content-type"] ?? "").includes("text/html") ||
127
- body.trimStart().startsWith("<");
112
+ /** Whether a failed attempt is worth another: a `RequestFailed`, read by tag, with no answer, a 408, a 429, or a 5xx. */
113
+ const isTransient = (cause: unknown): boolean => {
114
+ if (!Predicate.isTagged(cause, "RequestFailed") || !Predicate.hasProperty(cause, "status")) {
115
+ return false;
116
+ }
117
+ const { status } = cause;
118
+ return (
119
+ status === 0 ||
120
+ status === 408 ||
121
+ status === 429 ||
122
+ (Predicate.isNumber(status) && status >= 500)
123
+ );
124
+ };
128
125
 
129
126
  /**
130
- * A request that failed. A 400 saying the request is over Jev's context is a
131
- * `JevOverflow`, which skips the file. Any other answer outside 2xx refuses
132
- * the run with its status and what Jev said, since the status alone does not
133
- * say why, and a request that got no answer refuses with the cause.
127
+ * One request to `provider`, as adhere asks every provider: each attempt
128
+ * after `pace`, given 30 seconds before its signal aborts it, and logged when
129
+ * it fails. One that failed transiently is tried again up to three times,
130
+ * after half a second, one, and two. What it answers decodes as `Answers`.
131
+ * The eval's harness asks its providers this way too.
134
132
  */
135
- const failed = (
136
- problem: HttpClientError.HttpClientError | RateLimiter.RateLimiterError | Cause.TimeoutError,
137
- ) => {
138
- if (!HttpClientError.isHttpClientError(problem) || problem.reason._tag !== "StatusCodeError") {
139
- return Effect.fail(refused(`System One request failed: ${problem.message}`));
140
- }
141
- const { response } = problem.reason;
142
- return Effect.flatMap(
143
- Effect.orElseSucceed(response.text, () => "").pipe(
144
- Effect.tap((body) =>
145
- Effect.logDebug(
146
- `Jev answered HTTP ${response.status}, ${response.headers["content-type"] ?? "no content type"}, ${body.length} characters, Ray ID ${response.headers["cf-ray"] ?? "none"}`,
133
+ export const askProvider = <A>(
134
+ provider: Provider,
135
+ state: State,
136
+ questions: ReadonlyArray<Question>,
137
+ Answers: Schema.Codec<A, unknown, never, never>,
138
+ apiKey?: string,
139
+ pace: Effect.Effect<unknown, unknown> = Effect.void,
140
+ ): Effect.Effect<A, JevOverflow | JevBlocked | JevUnavailable> =>
141
+ pace.pipe(
142
+ Effect.andThen(
143
+ Effect.tryPromise({
144
+ try: (signal) =>
145
+ provider.ask(state, questions, apiKey === undefined ? { signal } : { apiKey, signal }),
146
+ catch: (cause) => cause,
147
+ }).pipe(
148
+ Effect.timeoutOrElse({
149
+ duration: "30 seconds",
150
+ orElse: () =>
151
+ Effect.fail(new RequestFailed(`${provider.model} did not answer in 30 seconds`, 0)),
152
+ }),
153
+ ),
154
+ ),
155
+ Effect.tapError((cause) => Effect.logDebug(`attempt failed: ${messageOf(cause)}`)),
156
+ Effect.retry({ while: isTransient, times: 3, schedule: Schedule.exponential("500 millis") }),
157
+ Effect.mapError(rejected),
158
+ Effect.flatMap((answered) =>
159
+ Schema.decodeUnknownEffect(Answers)(answered).pipe(
160
+ Effect.mapError((problem) =>
161
+ refused(`${provider.model}'s answers did not decode: ${problem.message}`),
147
162
  ),
148
163
  ),
149
- Effect.tap((body) => Effect.logTrace(`Jev's answer: ${body.slice(0, 4000)}`)),
150
164
  ),
151
- (body): Effect.Effect<never, JevOverflow | JevBlocked | JevUnavailable> =>
152
- response.status === 400 && isOverflow(body)
153
- ? Effect.fail(JevOverflow.make())
154
- : response.status === 403 && isBlockPage(response, body)
155
- ? Effect.fail(JevBlocked.make({ ray: response.headers["cf-ray"] ?? "none given" }))
156
- : Effect.fail(refused(`Jev answered HTTP ${response.status}${detailOf(body)}`)),
157
165
  );
158
- };
159
166
 
160
167
  /**
161
168
  * Comparing rules has no file to skip, so a request over Jev's context, or one
@@ -181,70 +188,75 @@ export const JevLive = Layer.effect(Jev)(
181
188
  Effect.gen(function* () {
182
189
  const config = yield* AdhereConfig;
183
190
  const credentials = yield* Credentials;
184
- const http = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
185
- // `--rpm`: one request per 60/rpm seconds, evenly spaced rather than in a
186
- // burst each minute. Before the retries, so a retry waits its turn too.
187
- type Failure = HttpClientError.HttpClientError | RateLimiter.RateLimiterError;
188
- const paced: HttpClient.HttpClient.With<Failure> =
189
- config.rpm === undefined
190
- ? // The same client, typed to fail the ways the throttled one can.
191
- HttpClient.transformResponse(
192
- http,
193
- (response): Effect.Effect<HttpClientResponse.HttpClientResponse, Failure> => response,
194
- )
195
- : http.pipe(
196
- HttpClient.withRateLimiter({
197
- limiter: yield* RateLimiter.RateLimiter,
198
- key: "jev",
199
- algorithm: "token-bucket",
200
- limit: 1,
201
- window: Duration.millis(60_000 / config.rpm),
202
- }),
191
+ const fetch = yield* FetchHttpClient.Fetch;
192
+ const limiter = yield* RateLimiter.RateLimiter;
193
+
194
+ const provider: Provider = config.provider ?? jev({ model: config.model, fetch });
195
+ const previewed = new Set<string>();
196
+ // The key it asks with, when it names one `adhere login` saves. Read when
197
+ // a request needs it, not in the layer: a run with nothing pending needs none.
198
+ const apiKey: Effect.Effect<string | undefined, JevUnavailable> =
199
+ provider.savedKey === undefined
200
+ ? Effect.succeed(undefined)
201
+ : yield* Effect.cached(
202
+ credentials.key(provider.savedKey).pipe(
203
+ Effect.map(Redacted.value),
204
+ Effect.mapError((problem) => refused(problem.message)),
205
+ ),
203
206
  );
204
- const client = paced.pipe(
205
- HttpClient.transformResponse(Effect.timeout("30 seconds")),
206
- // Every failed attempt, including those retried, so a log shows what retrying hid.
207
- HttpClient.transformResponse(
208
- Effect.tapError((problem) => Effect.logDebug(`attempt failed: ${attemptFailure(problem)}`)),
209
- ),
210
- HttpClient.retryTransient({
211
- schedule: Schedule.exponential("500 millis"),
212
- times: 3,
213
- }),
214
- );
215
207
 
216
- /** One request. `kind` names the question for the log: judge, locate, blocks, and so on. */
217
- const ask = <A>(kind: string, body: Body, Answers: Schema.Codec<A, unknown, never, never>) =>
208
+ // `--rpm`: one attempt per 60/rpm seconds, evenly spaced rather than in a
209
+ // burst each minute, so a retry waits its turn too.
210
+ const pace =
211
+ config.rpm === undefined
212
+ ? Effect.void
213
+ : RateLimiter.sleep(limiter, {
214
+ key: "provider",
215
+ algorithm: "token-bucket",
216
+ limit: 1,
217
+ window: Duration.millis(60_000 / config.rpm),
218
+ });
219
+
220
+ /** One request. `kind` names the question for the log: judge, locate, conflicts, and so on. */
221
+ const ask = <A>(
222
+ kind: string,
223
+ body: Body<SystemOneQuestion>,
224
+ Answers: Schema.Codec<A, unknown, never, never>,
225
+ ) =>
218
226
  Effect.gen(function* () {
219
- // Read here, not in the layer: a run with nothing pending needs no key.
220
- const apiKey = yield* credentials.apiKey.pipe(
221
- Effect.mapError((problem) => refused(problem.message)),
222
- );
223
- const request = yield* HttpClientRequest.bodyJson(
224
- HttpClientRequest.post(SYSTEM_ONE),
225
- body,
226
- ).pipe(Effect.orDie);
227
+ const key = yield* apiKey;
227
228
  const questions = Object.keys(body.questions).length;
228
229
  yield* Effect.logDebug(
229
- `${kind}: sending ${questions} ${questions === 1 ? "question" : "questions"}, about ${tokensOf(body)} tokens`,
230
+ `${kind}: asking ${provider.model} ${questions} ${questions === 1 ? "question" : "questions"}, about ${tokensOf(body)} tokens`,
230
231
  );
231
- const [elapsed, response] = yield* Effect.timed(
232
- client.execute(HttpClientRequest.bearerToken(request, apiKey)).pipe(Effect.catch(failed)),
232
+ if ((kind === "judge" || kind === "locate") && !previewed.has(kind)) {
233
+ previewed.add(kind);
234
+ yield* logRequestPayload(
235
+ `${kind}: first ${kind === "locate" ? "match-to-lines " : ""}request payload`,
236
+ () => body,
237
+ "Debug",
238
+ );
239
+ }
240
+ const [elapsed, answered] = yield* Effect.timed(
241
+ askProvider(provider, body.state, questionsOf(body.questions), Answers, key, pace).pipe(
242
+ Effect.tapErrorTag("JevOverflow", () =>
243
+ logRequestPayload(
244
+ `${kind}: too long request payload (provider overflow)`,
245
+ () => body,
246
+ ),
247
+ ),
248
+ ),
233
249
  );
234
250
  yield* Effect.logDebug(
235
- `${kind}: HTTP ${response.status} in ${Math.round(Duration.toMillis(elapsed))} ms, Ray ID ${response.headers["cf-ray"] ?? "none"}`,
236
- );
237
- return yield* HttpClientResponse.schemaBodyJson(Answers)(response).pipe(
238
- Effect.mapError((problem) =>
239
- refused(`System One response did not decode: ${problem.message}`),
240
- ),
251
+ `${kind}: ${provider.model} answered in ${Math.round(Duration.toMillis(elapsed))} ms`,
241
252
  );
253
+ return answered;
242
254
  });
243
255
 
244
256
  /** Every answer to a body, over as many requests as Jev's context needs. */
245
257
  const answersTo = <A>(
246
258
  kind: string,
247
- body: Body,
259
+ body: Body<SystemOneQuestion>,
248
260
  Answers: Schema.Codec<
249
261
  { readonly answers: Readonly<Record<string, A>> },
250
262
  unknown,
@@ -269,7 +281,7 @@ export const JevLive = Layer.effect(Jev)(
269
281
  const answersAbout = <A>(
270
282
  kind: string,
271
283
  rules: Rules,
272
- bodyOf: (group: Rules) => Body & { readonly state: JevState },
284
+ bodyOf: (group: Rules) => Body<SystemOneQuestion> & { readonly state: JevState },
273
285
  file: JudgedFile,
274
286
  Answers: Schema.Codec<
275
287
  { readonly answers: Readonly<Record<string, A>> },
@@ -280,9 +292,8 @@ export const JevLive = Layer.effect(Jev)(
280
292
  ) =>
281
293
  Effect.map(
282
294
  Effect.forEach(requestGroups(rules), (group) =>
283
- Effect.flatMap(
284
- hooked(bodyOf(group), group, file, config.workspacePackages ?? {}),
285
- (body) => answersTo(kind, body, Answers),
295
+ Effect.flatMap(hooked(bodyOf(group), group, file), (body) =>
296
+ answersTo(kind, body, Answers),
286
297
  ),
287
298
  ),
288
299
  (groups) =>
@@ -294,46 +305,47 @@ export const JevLive = Layer.effect(Jev)(
294
305
  rules: Rules,
295
306
  sampled: ReadonlyArray<RuleId>,
296
307
  file: JudgedFile,
308
+ readState: ReadState = {},
297
309
  ) {
298
310
  const answers = yield* answersAbout(
299
311
  "judge",
300
312
  rules,
301
- (group) => judgeBody(config.model, lines, group, sampled),
313
+ (group) => judgeBody(config.model, lines, group, sampled, readState),
302
314
  file,
303
315
  NoulAnswers,
304
316
  );
305
- /** Each rule's answer, read from the question it rode under. */
306
- const answered = (ids: ReadonlyArray<RuleId>, keyOf: (id: RuleId) => string) =>
307
- Record.fromEntries(
308
- ids.flatMap((id) => {
309
- const answer = answers[keyOf(id)];
310
- return answer === undefined ? [] : [[id, answer.noul] as const];
311
- }),
312
- );
313
- // A matcher Jev left unanswered counts as a no.
314
- const scored = (
315
- kind: keyof MatcherScores,
316
- id: RuleId,
317
- matchers: ReadonlyArray<string> = [],
318
- ) => matchers.map((_, index) => answers[matcherKey(kind, id, index)]?.noul ?? 0);
317
+ const all = (scores: ReadonlyArray<number | undefined>): scores is ReadonlyArray<number> =>
318
+ scores.every((score) => score !== undefined);
319
+ // A rule is answered when its question and each of its matchers' are: a
320
+ // matcher left unanswered, as a refusal leaves one, leaves its rule
321
+ // unanswered too, to ask again on the next run rather than cache as a no.
322
+ const judged = Object.entries(rules).flatMap(([id, rule]) => {
323
+ const scored = (kind: keyof MatcherScores) =>
324
+ (rule[kind] ?? []).map((_, index) => answers[matcherKey(kind, id, index)]);
325
+ const probability = answers[id];
326
+ const appliesTo = scored("appliesTo");
327
+ const excludeIf = scored("excludeIf");
328
+ return probability === undefined || !all(appliesTo) || !all(excludeIf)
329
+ ? []
330
+ : [{ id, rule, probability, appliesTo, excludeIf }];
331
+ });
319
332
  const matchers: Record<RuleId, MatcherScores> = Record.fromEntries(
320
- Object.entries(rules).flatMap(([id, rule]) =>
333
+ judged.flatMap(({ id, rule, appliesTo, excludeIf }) =>
321
334
  rule.appliesTo === undefined && rule.excludeIf === undefined
322
335
  ? []
323
- : [
324
- [
325
- id,
326
- {
327
- appliesTo: scored("appliesTo", id, rule.appliesTo),
328
- excludeIf: scored("excludeIf", id, rule.excludeIf),
329
- },
330
- ] as const,
331
- ],
336
+ : [[id, { appliesTo, excludeIf }] as const],
332
337
  ),
333
338
  );
334
339
  return {
335
- probabilities: answered(Object.keys(rules), (id) => id),
336
- linter: answered(sampled, linterKey),
340
+ probabilities: Record.fromEntries(
341
+ judged.map(({ id, probability }) => [id, probability] as const),
342
+ ),
343
+ linter: Record.fromEntries(
344
+ sampled.flatMap((id) => {
345
+ const answer = answers[linterKey(id)];
346
+ return answer === undefined ? [] : [[id, answer] as const];
347
+ }),
348
+ ),
337
349
  ...(Record.isEmptyRecord(matchers) ? {} : { matchers }),
338
350
  } satisfies Judged;
339
351
  });
@@ -342,25 +354,26 @@ export const JevLive = Layer.effect(Jev)(
342
354
  lines: Lines,
343
355
  rules: Rules,
344
356
  file: JudgedFile,
357
+ readState: ReadState = {},
345
358
  ) {
346
359
  const sections = sectionsOf(lines);
347
360
  const answers = yield* answersAbout(
348
361
  "locate",
349
362
  rules,
350
- (group) => locateBody(config.model, lines, group),
363
+ (group) => locateBody(config.model, lines, group, readState),
351
364
  file,
352
365
  LocateAnswers,
353
366
  );
354
367
  // A file of one section has every finding in it. An answer that names no section,
355
- // or none at all, which Jev should not give, leaves the rule unlocated; lines outside
356
- // the section leave it without lines.
368
+ // or none at all, which a model should not give, leaves the rule unlocated; lines
369
+ // outside the section leave it without lines.
357
370
  return Record.filterMap(rules, (_, id) => {
358
- const chosen = sections.length === 1 ? 1 : Number(answers[id]?.choice);
371
+ const chosen = sections.length === 1 ? 1 : Number(answers[id]);
359
372
  const section = sections[chosen - 1];
360
- const sufficiency = answers[sufficiencyKey(id)]?.noul;
361
- if (section === undefined || sufficiency === undefined) return Result.failVoid;
373
+ const sufficiency = answers[sufficiencyKey(id)];
374
+ if (section === undefined || typeof sufficiency !== "number") return Result.failVoid;
362
375
  const [start, end] = (["start", "end"] as const).map((edge) =>
363
- Number(answers[edgeKey(edge, id, chosen)]?.choice),
376
+ Number(answers[edgeKey(edge, id, chosen)]),
364
377
  );
365
378
  const within = (line: number | undefined): line is number =>
366
379
  line !== undefined && section.first <= line && line <= section.last;