@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,13 +1,16 @@
1
1
  import {
2
2
  type Example,
3
3
  examplesOf,
4
+ type JevState,
4
5
  type JudgedFile,
6
+ type ReadState,
5
7
  type Rule,
6
8
  type RuleId,
7
9
  type Rules,
8
10
  } from "#config.ts";
9
11
  import { type Range, sectionsOf } from "#excerpt.ts";
10
- import { Context, type Effect, Record, Schema } from "effect";
12
+ import type { SystemOneQuestion, SystemOneRequest } from "#providers/systemOne.ts";
13
+ import { Context, Effect, LogLevel, Record, References, Schema } from "effect";
11
14
 
12
15
  export type { Rules };
13
16
  export type Lines = ReadonlyArray<string>;
@@ -54,23 +57,26 @@ export class Jev extends Context.Service<
54
57
  /**
55
58
  * Each rule's probability that the file breaks it. A rule in `sampled`
56
59
  * also carries the linter check's question, whose answer comes back
57
- * under `linter`. `file` is what a rule's `appendState` gets.
60
+ * under `linter`. `file` is what a rule's `appendState` gets;
61
+ * `readState` is the shared context already prepared for the file.
58
62
  */
59
63
  readonly judge: (
60
64
  lines: Lines,
61
65
  rules: Rules,
62
66
  sampled: ReadonlyArray<RuleId>,
63
67
  file: JudgedFile,
68
+ readState?: ReadState,
64
69
  ) => Effect.Effect<Judged, JevUnavailable | JevOverflow | JevBlocked>;
65
70
  /**
66
71
  * Per rule, the section of the file that most clearly breaks it, and
67
- * whether the file shows enough to decide. A rule Jev answers either
68
- * question for with nothing it offered is left out.
72
+ * whether the file and its prepared context show enough to decide.
73
+ * A rule Jev answers either question for with nothing it offered is left out.
69
74
  */
70
75
  readonly locate: (
71
76
  lines: Lines,
72
77
  rules: Rules,
73
78
  file: JudgedFile,
79
+ readState?: ReadState,
74
80
  ) => Effect.Effect<Record<RuleId, Located>, JevUnavailable | JevOverflow | JevBlocked>;
75
81
  /**
76
82
  * Per rule, the partner Jev names as impossible to follow in the same code,
@@ -95,7 +101,7 @@ export class Jev extends Context.Service<
95
101
  /** Where a finding is, and how sure Jev is that the file shows enough to decide it. */
96
102
  export interface Located {
97
103
  readonly section: Range;
98
- /** Jev's probability that everything judging the rule turns on is in the file. */
104
+ /** Jev's probability that the file and its shared context show everything judging the rule turns on. */
99
105
  readonly sufficiency: number;
100
106
  /** The lines in the section the code that breaks the rule starts and ends on, when Jev named them. */
101
107
  readonly lines?: Range;
@@ -123,6 +129,7 @@ const REQUEST_CONTEXT = 64_000;
123
129
  const WRAPPER = 1_000;
124
130
 
125
131
  const encoder = new TextEncoder();
132
+ const bytesOf = (value: unknown): number => encoder.encode(JSON.stringify(value)).length;
126
133
 
127
134
  /**
128
135
  * An estimate, meant to run high, of the tokens Jev reads for a value: one
@@ -130,23 +137,54 @@ const encoder = new TextEncoder();
130
137
  * bytes a token, and this ran 12 to 16 percent high on the eval's requests.
131
138
  * Text that packs more tokens into its bytes, such as CJK, can outrun it.
132
139
  */
133
- export const tokensOf = (value: unknown): number =>
134
- Math.ceil(encoder.encode(JSON.stringify(value)).length / 3);
140
+ export const tokensOf = (value: unknown): number => Math.ceil(bytesOf(value) / 3);
141
+
142
+ const READ_COMMENTS: Readonly<Record<string, string>> = {
143
+ symbols:
144
+ "Compiler-inferred types, documentation, and JSDoc for symbols in `code`; keys are symbol names.",
145
+ references:
146
+ "Declarations of symbols used in `code`. Each key is `symbol:pathname`: the symbol name as used in `code` followed by its repository-relative declaration path. Each value is a complete declaration excerpt.",
147
+ workspacePackages:
148
+ "Workspace packages imported by `code`; keys are package names and values are repository-relative package directories. Only packages imported by this file are listed.",
149
+ };
150
+
151
+ /** Explain the known context maps while preserving caller-provided descriptions and target code. */
152
+ export const describeState = (state: JevState): JevState => {
153
+ const described = { ...state };
154
+ for (const [name, comment] of Object.entries(READ_COMMENTS)) {
155
+ const value = state[name];
156
+ if (value !== null && typeof value === "object" && !Array.isArray(value))
157
+ described[name] = { __comment: comment, ...value };
158
+ }
159
+ if (
160
+ described.__comment === undefined &&
161
+ Object.keys(state).some((name) => name !== "code" && name !== "__comment")
162
+ )
163
+ described.__comment =
164
+ "Only `code` is the audit target; it is split into numbered sections. All other state entries provide context, not additional code to audit.";
165
+ return described;
166
+ };
135
167
 
136
168
  /**
137
169
  * The file as Jev reads it: its sections, by `sectionsOf`, under their
138
- * numbers from 1, so the locate question can name one. The only thing the
139
- * questions share. On the eval it judged as well as the file with every line
140
- * numbered, and located as well as a choice among the lines.
170
+ * numbers from 1, so the locate question can name one. Prepared context is
171
+ * added beside the code, never in its place. On the eval it judged as well as
172
+ * the file with every line numbered, and located as well as a choice among the lines.
141
173
  */
142
- const stateOf = (lines: Lines) => ({
143
- code: Object.fromEntries(
144
- sectionsOf(lines).map((section, index) => [
145
- String(index + 1),
146
- lines.slice(section.first - 1, section.last).join("\n"),
147
- ]),
148
- ),
149
- });
174
+ const stateOf = (
175
+ lines: Lines,
176
+ readState: ReadState = {},
177
+ sections: ReadonlyArray<Range> = sectionsOf(lines),
178
+ ): JevState =>
179
+ describeState({
180
+ ...readState,
181
+ code: Object.fromEntries(
182
+ sections.map((section, index) => [
183
+ String(index + 1),
184
+ lines.slice(section.first - 1, section.last).join("\n"),
185
+ ]),
186
+ ),
187
+ });
150
188
 
151
189
  /** The field an example goes under in a question: its word, with `should not` as `should_not`. */
152
190
  const fieldOf = (example: Example): string => example.word.replace(" ", "_");
@@ -198,7 +236,7 @@ const comparedState = (rules: ReadonlyArray<ComparedRule>, mentioned: Iterable<n
198
236
  * asking whether code "must be written the way `must` shows": the same words,
199
237
  * but a pattern to compare rather than code to match.
200
238
  */
201
- export const judgeQuestion = (rule: Rule) => {
239
+ const judgeQuestionWith = (rule: Rule, fields: ReturnType<typeof ruleFields>) => {
202
240
  const { good, bad } = examplesOf(rule);
203
241
  const avoided = fieldOf(bad ?? { word: "never", code: "" });
204
242
  const example = bad === undefined ? "" : `, for example by doing what \`${avoided}\` shows`;
@@ -216,7 +254,7 @@ export const judgeQuestion = (rule: Rule) => {
216
254
  ];
217
255
  return {
218
256
  type: "noul" as const,
219
- instructions: { question, ...ruleFields(rule) },
257
+ instructions: { question, ...fields },
220
258
  criteria: {
221
259
  true: `${yes}, in code that \`rule\` is about`,
222
260
  false: `${no}, or has no code that \`rule\` is about`,
@@ -224,6 +262,8 @@ export const judgeQuestion = (rule: Rule) => {
224
262
  };
225
263
  };
226
264
 
265
+ export const judgeQuestion = (rule: Rule) => judgeQuestionWith(rule, ruleFields(rule));
266
+
227
267
  /** What the offending section does, for the locate question. */
228
268
  const offense = (rule: Rule): string => {
229
269
  const { good, bad } = examplesOf(rule);
@@ -269,8 +309,11 @@ type MatcherKind = keyof MatcherScores;
269
309
  * `appliesTo`, whether any of that code is as described, and for `excludeIf`,
270
310
  * whether all of it is. It carries the rule as the judge question does.
271
311
  */
272
- export const matcherQuestion = (rule: Rule, kind: MatcherKind, matcher: string) => {
273
- const { instructions } = judgeQuestion(rule);
312
+ const matcherQuestionWith = (
313
+ instructions: ReturnType<typeof judgeQuestion>["instructions"],
314
+ kind: MatcherKind,
315
+ matcher: string,
316
+ ) => {
274
317
  const [quantity, yes, no] =
275
318
  kind === "appliesTo" ? ["any", "Some", "None"] : ["all", "All", "Not all"];
276
319
  return {
@@ -287,6 +330,9 @@ export const matcherQuestion = (rule: Rule, kind: MatcherKind, matcher: string)
287
330
  };
288
331
  };
289
332
 
333
+ export const matcherQuestion = (rule: Rule, kind: MatcherKind, matcher: string) =>
334
+ matcherQuestionWith(judgeQuestion(rule).instructions, kind, matcher);
335
+
290
336
  /** The key a rule's matcher question rides under, beside its judge question. */
291
337
  export const matcherKey = (kind: MatcherKind, id: RuleId, index: number): string =>
292
338
  `${kind}:${id}:${index}`;
@@ -302,9 +348,9 @@ export const matcherQuestions = (id: RuleId, rule: Rule) =>
302
348
  ),
303
349
  );
304
350
 
305
- /** The tokens a file's code leaves in Jev's context for any one question beside it, as `fits` counts them. */
306
- export const questionRoom = (lines: Lines): number =>
307
- QUESTION_CONTEXT - WRAPPER - tokensOf(stateOf(lines));
351
+ /** The room for one question beside the file and its shared prepared context. Hooks are not predicted. */
352
+ export const questionRoom = (lines: Lines, readState: ReadState = {}): number =>
353
+ QUESTION_CONTEXT - WRAPPER - tokensOf(stateOf(lines, readState));
308
354
 
309
355
  /**
310
356
  * One question per rule, one per matcher of each rule, and the linter
@@ -315,9 +361,10 @@ export const judgeBody = (
315
361
  lines: Lines,
316
362
  rules: Rules,
317
363
  sampled: ReadonlyArray<RuleId> = [],
364
+ readState: ReadState = {},
318
365
  ) => ({
319
366
  model,
320
- state: stateOf(lines),
367
+ state: stateOf(lines, readState),
321
368
  questions: {
322
369
  ...Record.map(rules, (rule): ReturnType<typeof judgeQuestion> => judgeQuestion(rule)),
323
370
  ...Object.fromEntries(
@@ -340,19 +387,34 @@ export const judgeBody = (
340
387
  * in a field, ranked real findings worse, and read no easier. Of findings
341
388
  * it scored below 0.6, 62% were false, where 22% of all were.
342
389
  */
343
- export const sufficiencyQuestion = (rule: Rule) => ({
344
- type: "noul" as const,
345
- instructions: {
346
- question:
347
- "Can you tell whether `code` breaks `rule` from `code` alone, without knowing what other files, libraries, services, or configuration do?",
348
- ...ruleFields(rule),
349
- },
350
- criteria: {
351
- true: "`code` shows everything needed to tell whether it breaks `rule`",
352
- false:
353
- "Whether `code` breaks `rule` depends on something `code` does not show, such as another file, a library, a service, or configuration",
354
- },
355
- });
390
+ const sufficiencyQuestionWith = (fields: ReturnType<typeof ruleFields>, readState: ReadState) => {
391
+ const keys = Object.keys(readState).filter((key) => key !== "code" && key !== "__comment");
392
+ const context =
393
+ keys.length === 0 ? undefined : ["`code`", ...keys.map((key) => `\`${key}\``)].join(" and ");
394
+ return {
395
+ type: "noul" as const,
396
+ instructions: {
397
+ question:
398
+ context === undefined
399
+ ? "Can you tell whether `code` breaks `rule` from `code` alone, without knowing what other files, libraries, services, or configuration do?"
400
+ : `Can you tell whether \`code\` breaks \`rule\` from ${context}, without knowing what other files, libraries, services, or configuration do beyond what they show?`,
401
+ ...fields,
402
+ },
403
+ criteria: {
404
+ true:
405
+ context === undefined
406
+ ? "`code` shows everything needed to tell whether it breaks `rule`"
407
+ : `${context} show everything needed to tell whether \`code\` breaks \`rule\``,
408
+ false:
409
+ context === undefined
410
+ ? "Whether `code` breaks `rule` depends on something `code` does not show, such as another file, a library, a service, or configuration"
411
+ : `Whether \`code\` breaks \`rule\` depends on something ${context} do not show, such as another file, a library, a service, or configuration`,
412
+ },
413
+ };
414
+ };
415
+
416
+ export const sufficiencyQuestion = (rule: Rule, readState: ReadState = {}) =>
417
+ sufficiencyQuestionWith(ruleFields(rule), readState);
356
418
 
357
419
  /** The key a rule's sufficiency question rides under, beside its locate question. */
358
420
  export const sufficiencyKey = (id: RuleId): string => `sufficient:${id}`;
@@ -367,20 +429,39 @@ export const edgeKey = (edge: "start" | "end", id: RuleId, section: number): str
367
429
  * line's text, since the state carries no line numbers.
368
430
  */
369
431
  const edgeQuestion = (
370
- rule: Rule,
432
+ fields: ReturnType<typeof ruleFields>,
371
433
  edge: "start" | "end",
372
434
  key: string,
373
- lines: Lines,
374
- numbers: ReadonlyArray<number>,
435
+ criteria: Readonly<Record<string, string>>,
375
436
  ) => ({
376
437
  type: "choice" as const,
377
438
  instructions: {
378
439
  question: `Which line of \`code["${key}"]\` does the code that breaks \`rule\` ${edge} on?`,
379
- ...ruleFields(rule),
440
+ ...fields,
380
441
  },
381
- criteria: Object.fromEntries(
382
- numbers.map((line) => [String(line), (lines[line - 1] ?? "").trim().slice(0, 120)]),
383
- ),
442
+ criteria,
443
+ });
444
+
445
+ const lineCriteria = (lines: Lines, section: Range): Record<string, string> => {
446
+ const criteria: Record<string, string> = {};
447
+ for (let line = section.first; line <= section.last; line++) {
448
+ const text = (lines[line - 1] ?? "").trim();
449
+ if (text !== "") criteria[String(line)] = text.slice(0, 120);
450
+ }
451
+ return criteria;
452
+ };
453
+
454
+ const sectionQuestion = (
455
+ rule: Rule,
456
+ fields: ReturnType<typeof ruleFields>,
457
+ criteria: Readonly<Record<string, null>>,
458
+ ) => ({
459
+ type: "choice" as const,
460
+ instructions: {
461
+ question: `Which entry of \`code\` most clearly ${offense(rule)}?`,
462
+ ...fields,
463
+ },
464
+ criteria,
384
465
  });
385
466
 
386
467
  /**
@@ -394,80 +475,150 @@ const edgeQuestion = (
394
475
  * held a violation for 94% of real findings, and 98% where the section was
395
476
  * right, and were most often one line.
396
477
  */
397
- export const locateBody = (model: string, lines: Lines, rules: Rules) => {
398
- const state = stateOf(lines);
478
+ export const locateBody = (
479
+ model: string,
480
+ lines: Lines,
481
+ rules: Rules,
482
+ readState: ReadState = {},
483
+ ) => {
484
+ const sections = sectionsOf(lines);
485
+ const state = stateOf(lines, readState, sections);
399
486
  const criteria = Record.map(state.code, () => null);
400
- const choosing = Object.keys(criteria).length > 1;
401
- const withCode = sectionsOf(lines).map(({ first, last }) =>
402
- Array.from({ length: last - first + 1 }, (_, index) => first + index).filter(
403
- (line) => (lines[line - 1] ?? "").trim() !== "",
404
- ),
405
- );
487
+ const choosing = sections.length > 1;
488
+ const withCode = sections.map((section) => lineCriteria(lines, section));
406
489
  return {
407
490
  model,
408
491
  state,
409
492
  questions: Object.fromEntries(
410
- Object.entries(rules).flatMap(([id, rule]) => [
411
- ...(choosing
412
- ? [
413
- [
414
- id,
415
- {
416
- type: "choice" as const,
417
- instructions: {
418
- question: `Which entry of \`code\` most clearly ${offense(rule)}?`,
419
- ...ruleFields(rule),
420
- },
421
- criteria,
422
- },
423
- ] as const,
424
- ]
425
- : []),
426
- ...withCode.flatMap((numbers, index) =>
427
- numbers.length === 0
428
- ? []
429
- : (["start", "end"] as const).map(
430
- (edge) =>
431
- [
432
- edgeKey(edge, id, index + 1),
433
- edgeQuestion(rule, edge, String(index + 1), lines, numbers),
434
- ] as const,
435
- ),
436
- ),
437
- [sufficiencyKey(id), sufficiencyQuestion(rule)] as const,
438
- ]),
493
+ Object.entries(rules).flatMap(([id, rule]) => {
494
+ const fields = ruleFields(rule);
495
+ return [
496
+ ...(choosing ? [[id, sectionQuestion(rule, fields, criteria)] as const] : []),
497
+ ...withCode.flatMap((options, index) =>
498
+ Record.isEmptyRecord(options)
499
+ ? []
500
+ : (["start", "end"] as const).map(
501
+ (edge) =>
502
+ [
503
+ edgeKey(edge, id, index + 1),
504
+ edgeQuestion(fields, edge, String(index + 1), options),
505
+ ] as const,
506
+ ),
507
+ ),
508
+ [sufficiencyKey(id), sufficiencyQuestionWith(fields, readState)] as const,
509
+ ];
510
+ }),
439
511
  ),
440
512
  };
441
513
  };
442
514
 
443
515
  /**
444
516
  * Whether Jev can take every request a file may need: its sections within
445
- * one choice, and its code beside the longest question any rule asks.
517
+ * one choice, and the shared state beside its longest question. Hooks are not predicted.
446
518
  */
447
- export const fits = (lines: Lines, rules: Rules): boolean => {
448
- if (sectionsOf(lines).length > CHOICE_LIMIT) return false;
449
- const questions = [
450
- ...Object.values(judgeBody("", lines, rules).questions),
451
- ...Object.values(locateBody("", lines, rules).questions),
452
- ];
453
- const longest = Math.max(0, ...questions.map(tokensOf));
454
- return tokensOf(stateOf(lines)) + longest <= QUESTION_CONTEXT - WRAPPER;
519
+ export const fits = (lines: Lines, rules: Rules, readState: ReadState = {}): boolean => {
520
+ const sections = sectionsOf(lines);
521
+ if (sections.length > CHOICE_LIMIT) return false;
522
+ const state = stateOf(lines, readState, sections);
523
+ const room = QUESTION_CONTEXT - WRAPPER - tokensOf(state);
524
+ if (room < 0) return false;
525
+ if (Record.isEmptyRecord(rules)) return true;
526
+
527
+ // These ids can overwrite generated questions; retain the bodies' last-write semantics.
528
+ if (Object.keys(rules).some((id) => /^(appliesTo|excludeIf|start|end|sufficient):/.test(id))) {
529
+ return [
530
+ judgeBody("", lines, rules, [], readState),
531
+ locateBody("", lines, rules, readState),
532
+ ].every((body) =>
533
+ Object.values(body.questions).every((question) => tokensOf(question) <= room),
534
+ );
535
+ }
536
+
537
+ const emptyFields = { rule: "" };
538
+ const fieldBytes = bytesOf(emptyFields);
539
+ const limit = room * 3;
540
+ const criteriaBytes = bytesOf(Record.map(state.code, () => null)) - 2;
541
+ let longest = bytesOf(sufficiencyQuestionWith(emptyFields, readState));
542
+ for (const [index, section] of sections.entries()) {
543
+ const options = lineCriteria(lines, section);
544
+ if (Record.isEmptyRecord(options)) continue;
545
+ // `start` is two bytes longer than `end`; fields and criteria are identical.
546
+ longest = Math.max(
547
+ longest,
548
+ bytesOf(edgeQuestion(emptyFields, "start", String(index + 1), options)),
549
+ );
550
+ }
551
+ for (const rule of Object.values(rules)) {
552
+ // Replace the template's fields in bytes, rounding only the complete question.
553
+ const extra = bytesOf(ruleFields(rule)) - fieldBytes;
554
+ if (longest + extra > limit) return false;
555
+ const judge = judgeQuestionWith(rule, emptyFields);
556
+ if (bytesOf(judge) + extra > limit) return false;
557
+ if (
558
+ sections.length > 1 &&
559
+ bytesOf(sectionQuestion(rule, emptyFields, {})) + criteriaBytes + extra > limit
560
+ ) {
561
+ return false;
562
+ }
563
+ for (const kind of MATCHER_KINDS) {
564
+ for (const matcher of rule[kind] ?? []) {
565
+ if (bytesOf(matcherQuestionWith(judge.instructions, kind, matcher)) + extra > limit) {
566
+ return false;
567
+ }
568
+ }
569
+ }
570
+ }
571
+ return true;
455
572
  };
456
573
 
457
- export interface Body {
458
- readonly model: string;
459
- readonly state: unknown;
460
- readonly questions: Readonly<Record<string, unknown>>;
461
- }
574
+ /** A request to Jev, its questions typed as `Q`: `Question`, as adhere asks them, or anything an eval arm asks. */
575
+ export type Body<Q = unknown> = SystemOneRequest<Q>;
576
+
577
+ type RequestPayload =
578
+ | Body<SystemOneQuestion>
579
+ | { readonly judge: Body<SystemOneQuestion>; readonly locate: Body<SystemOneQuestion> };
580
+
581
+ const traceBody = (body: Body<SystemOneQuestion>) => ({
582
+ ...body,
583
+ state: { code: body.state.code, ...body.state },
584
+ questions: Record.map(body.questions, (question, id) =>
585
+ question.type === "choice" && /^(start|end):/.test(id)
586
+ ? { ...question, criteria: undefined }
587
+ : question,
588
+ ),
589
+ });
590
+
591
+ /** Debug/Trace put target code first and omit line-option copies; All retains the complete request. */
592
+ export const logRequestPayload = (
593
+ message: string,
594
+ payload: () => RequestPayload,
595
+ level: "Debug" | "Trace" = "Trace",
596
+ ): Effect.Effect<void> =>
597
+ Effect.gen(function* () {
598
+ const minimum = yield* References.MinimumLogLevel;
599
+ if (LogLevel.isGreaterThan(minimum, level)) return;
600
+ const full = payload();
601
+ const visible =
602
+ minimum === "All"
603
+ ? full
604
+ : "questions" in full
605
+ ? traceBody(full)
606
+ : { judge: traceBody(full.judge), locate: traceBody(full.locate) };
607
+ // Consume escaped backslashes first so source literals such as "\\n" remain literals.
608
+ const text = JSON.stringify(visible, null, 2).replace(/\\\\|\\[nt]/g, (escaped) =>
609
+ escaped === "\\n" ? "\n" : escaped === "\\t" ? "\t" : escaped,
610
+ );
611
+ yield* (level === "Debug" ? Effect.logDebug : Effect.logTrace)(`${message}\n${text}`);
612
+ });
462
613
 
463
614
  /**
464
615
  * A body as requests Jev can take: its questions in order, with a new
465
616
  * request whenever the next question would not fit beside the state and
466
617
  * the questions before it.
467
618
  */
468
- export const requestsOf = (body: Body): ReadonlyArray<Body> => {
619
+ export const requestsOf = <Q>(body: Body<Q>): ReadonlyArray<Body<Q>> => {
469
620
  const room = REQUEST_CONTEXT - WRAPPER - tokensOf(body.state);
470
- const requests: Array<{ readonly questions: Record<string, unknown>; tokens: number }> = [];
621
+ const requests: Array<{ readonly questions: Record<string, Q>; tokens: number }> = [];
471
622
  for (const [id, question] of Object.entries(body.questions)) {
472
623
  const tokens = tokensOf(question);
473
624
  const last = requests.at(-1);
@@ -481,26 +632,30 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
481
632
  return requests.map(({ questions }) => ({ ...body, questions }));
482
633
  };
483
634
 
484
- /** Whether a rule reads state of its own: what its `appendState` adds, or what its `reads` names. */
485
- const readsOwnState = (rule: Rule): boolean =>
486
- rule.appendState !== undefined || (rule.reads ?? []).length > 0;
487
-
488
635
  /**
489
- * The rules as requests carry them: every rule that reads only the code in
490
- * one body, and each rule with state of its own in a body of its own, so
491
- * that what is added to the state for it only its own questions read.
636
+ * All rules share one body except those with `appendState`, each in a body
637
+ * of its own so only its questions read what its hook adds to the shared state.
492
638
  */
493
639
  export const requestGroups = (rules: Rules): ReadonlyArray<Rules> => {
494
- const shared = Record.filter(rules, (rule) => !readsOwnState(rule));
640
+ const shared = Record.filter(rules, (rule) => rule.appendState === undefined);
495
641
  return [
496
642
  ...(Record.isEmptyRecord(shared) ? [] : [shared]),
497
- ...Object.entries(rules).flatMap(([id, rule]) => (readsOwnState(rule) ? [{ [id]: rule }] : [])),
643
+ ...Object.entries(rules).flatMap(([id, rule]) =>
644
+ rule.appendState === undefined ? [] : [{ [id]: rule }],
645
+ ),
498
646
  ];
499
647
  };
500
648
 
501
649
  /** The judge requests for `rules` over a file: a body per group, split to fit, as `judge` sends them. */
502
- const judgeRequestsOf = (lines: Lines, rules: Rules, sampled: ReadonlyArray<RuleId>) =>
503
- requestGroups(rules).flatMap((group) => requestsOf(judgeBody("", lines, group, sampled)));
650
+ const judgeRequestsOf = (
651
+ lines: Lines,
652
+ rules: Rules,
653
+ sampled: ReadonlyArray<RuleId>,
654
+ readState: ReadState,
655
+ ) =>
656
+ requestGroups(rules).flatMap((group) =>
657
+ requestsOf(judgeBody("", lines, group, sampled, readState)),
658
+ );
504
659
 
505
660
  /**
506
661
  * The requests judging `rules` over a file takes, and about how many input
@@ -512,8 +667,9 @@ export const judgeLoad = (
512
667
  lines: Lines,
513
668
  rules: Rules,
514
669
  sampled: ReadonlyArray<RuleId> = [],
670
+ readState: ReadState = {},
515
671
  ): { readonly requests: number; readonly tokens: number } => {
516
- const requests = judgeRequestsOf(lines, rules, sampled);
672
+ const requests = judgeRequestsOf(lines, rules, sampled, readState);
517
673
  return {
518
674
  requests: requests.length,
519
675
  tokens: requests.reduce((sum, request) => sum + tokensOf(request), 0),
@@ -525,11 +681,13 @@ export const judgeRequests = (
525
681
  lines: Lines,
526
682
  rules: Rules,
527
683
  sampled: ReadonlyArray<RuleId> = [],
528
- ): number => judgeRequestsOf(lines, rules, sampled).length;
684
+ readState: ReadState = {},
685
+ ): number => judgeRequestsOf(lines, rules, sampled, readState).length;
529
686
 
530
687
  /** The requests locating `rules` in a file takes, a body per group split to fit, as `locate` sends them. */
531
- export const locateRequests = (lines: Lines, rules: Rules): number =>
532
- requestGroups(rules).flatMap((group) => requestsOf(locateBody("", lines, group))).length;
688
+ export const locateRequests = (lines: Lines, rules: Rules, readState: ReadState = {}): number =>
689
+ requestGroups(rules).flatMap((group) => requestsOf(locateBody("", lines, group, readState)))
690
+ .length;
533
691
 
534
692
  /** A choice keeps one criterion for "none", which leaves this many for partners. */
535
693
  const PARTNERS_PER_QUESTION = CHOICE_LIMIT - 1;
@@ -596,17 +754,15 @@ export const contradictBody = (model: string, rules: ReadonlyArray<ComparedRule>
596
754
  * it was one the question offered. "none", or a stray answer, names no pair.
597
755
  */
598
756
  export const namedPairs = (
599
- answers: Readonly<Record<string, { readonly choice: string }>>,
757
+ answers: Readonly<Record<string, string>>,
600
758
  partners: ReadonlyArray<ReadonlyArray<number>>,
601
759
  ): ReadonlyArray<Pair> =>
602
- Object.entries(answers).flatMap(([key, { choice }]): ReadonlyArray<Pair> => {
760
+ Object.entries(answers).flatMap(([key, choice]): ReadonlyArray<Pair> => {
603
761
  const rule = Number(key.split(":")[0]);
604
762
  const partner = Number(choice);
605
763
  return partners[rule]?.includes(partner) === true ? [[rule, partner]] : [];
606
764
  });
607
765
 
608
766
  /** The probability a contradiction answer gives its pair; 0 when it gives none. */
609
- export const pairProbability = (
610
- answers: Readonly<Record<string, { readonly noul: number }>>,
611
- pair: Pair,
612
- ): number => answers[pairKey(pair)]?.noul ?? 0;
767
+ export const pairProbability = (answers: Readonly<Record<string, number>>, pair: Pair): number =>
768
+ answers[pairKey(pair)] ?? 0;