@drkmttr/adhere 0.15.0 → 0.17.5

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,12 +1,15 @@
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";
12
+ import type { SystemOneRequest } from "#providers/systemOne.ts";
10
13
  import { Context, type Effect, Record, Schema } from "effect";
11
14
 
12
15
  export type { Rules };
@@ -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,18 +137,22 @@ 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);
135
141
 
136
142
  /**
137
143
  * 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.
144
+ * numbers from 1, so the locate question can name one. Prepared context is
145
+ * added beside the code, never in its place. On the eval it judged as well as
146
+ * the file with every line numbered, and located as well as a choice among the lines.
141
147
  */
142
- const stateOf = (lines: Lines) => ({
148
+ const stateOf = (
149
+ lines: Lines,
150
+ readState: ReadState = {},
151
+ sections: ReadonlyArray<Range> = sectionsOf(lines),
152
+ ): JevState => ({
153
+ ...readState,
143
154
  code: Object.fromEntries(
144
- sectionsOf(lines).map((section, index) => [
155
+ sections.map((section, index) => [
145
156
  String(index + 1),
146
157
  lines.slice(section.first - 1, section.last).join("\n"),
147
158
  ]),
@@ -198,7 +209,7 @@ const comparedState = (rules: ReadonlyArray<ComparedRule>, mentioned: Iterable<n
198
209
  * asking whether code "must be written the way `must` shows": the same words,
199
210
  * but a pattern to compare rather than code to match.
200
211
  */
201
- export const judgeQuestion = (rule: Rule) => {
212
+ const judgeQuestionWith = (rule: Rule, fields: ReturnType<typeof ruleFields>) => {
202
213
  const { good, bad } = examplesOf(rule);
203
214
  const avoided = fieldOf(bad ?? { word: "never", code: "" });
204
215
  const example = bad === undefined ? "" : `, for example by doing what \`${avoided}\` shows`;
@@ -216,7 +227,7 @@ export const judgeQuestion = (rule: Rule) => {
216
227
  ];
217
228
  return {
218
229
  type: "noul" as const,
219
- instructions: { question, ...ruleFields(rule) },
230
+ instructions: { question, ...fields },
220
231
  criteria: {
221
232
  true: `${yes}, in code that \`rule\` is about`,
222
233
  false: `${no}, or has no code that \`rule\` is about`,
@@ -224,6 +235,8 @@ export const judgeQuestion = (rule: Rule) => {
224
235
  };
225
236
  };
226
237
 
238
+ export const judgeQuestion = (rule: Rule) => judgeQuestionWith(rule, ruleFields(rule));
239
+
227
240
  /** What the offending section does, for the locate question. */
228
241
  const offense = (rule: Rule): string => {
229
242
  const { good, bad } = examplesOf(rule);
@@ -269,8 +282,11 @@ type MatcherKind = keyof MatcherScores;
269
282
  * `appliesTo`, whether any of that code is as described, and for `excludeIf`,
270
283
  * whether all of it is. It carries the rule as the judge question does.
271
284
  */
272
- export const matcherQuestion = (rule: Rule, kind: MatcherKind, matcher: string) => {
273
- const { instructions } = judgeQuestion(rule);
285
+ const matcherQuestionWith = (
286
+ instructions: ReturnType<typeof judgeQuestion>["instructions"],
287
+ kind: MatcherKind,
288
+ matcher: string,
289
+ ) => {
274
290
  const [quantity, yes, no] =
275
291
  kind === "appliesTo" ? ["any", "Some", "None"] : ["all", "All", "Not all"];
276
292
  return {
@@ -287,6 +303,9 @@ export const matcherQuestion = (rule: Rule, kind: MatcherKind, matcher: string)
287
303
  };
288
304
  };
289
305
 
306
+ export const matcherQuestion = (rule: Rule, kind: MatcherKind, matcher: string) =>
307
+ matcherQuestionWith(judgeQuestion(rule).instructions, kind, matcher);
308
+
290
309
  /** The key a rule's matcher question rides under, beside its judge question. */
291
310
  export const matcherKey = (kind: MatcherKind, id: RuleId, index: number): string =>
292
311
  `${kind}:${id}:${index}`;
@@ -302,9 +321,9 @@ export const matcherQuestions = (id: RuleId, rule: Rule) =>
302
321
  ),
303
322
  );
304
323
 
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));
324
+ /** The room for one question beside the file and its shared prepared context. Hooks are not predicted. */
325
+ export const questionRoom = (lines: Lines, readState: ReadState = {}): number =>
326
+ QUESTION_CONTEXT - WRAPPER - tokensOf(stateOf(lines, readState));
308
327
 
309
328
  /**
310
329
  * One question per rule, one per matcher of each rule, and the linter
@@ -315,9 +334,10 @@ export const judgeBody = (
315
334
  lines: Lines,
316
335
  rules: Rules,
317
336
  sampled: ReadonlyArray<RuleId> = [],
337
+ readState: ReadState = {},
318
338
  ) => ({
319
339
  model,
320
- state: stateOf(lines),
340
+ state: stateOf(lines, readState),
321
341
  questions: {
322
342
  ...Record.map(rules, (rule): ReturnType<typeof judgeQuestion> => judgeQuestion(rule)),
323
343
  ...Object.fromEntries(
@@ -340,19 +360,34 @@ export const judgeBody = (
340
360
  * in a field, ranked real findings worse, and read no easier. Of findings
341
361
  * it scored below 0.6, 62% were false, where 22% of all were.
342
362
  */
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
- });
363
+ const sufficiencyQuestionWith = (fields: ReturnType<typeof ruleFields>, readState: ReadState) => {
364
+ const keys = Object.keys(readState).filter((key) => key !== "code");
365
+ const context =
366
+ keys.length === 0 ? undefined : ["`code`", ...keys.map((key) => `\`${key}\``)].join(" and ");
367
+ return {
368
+ type: "noul" as const,
369
+ instructions: {
370
+ question:
371
+ context === undefined
372
+ ? "Can you tell whether `code` breaks `rule` from `code` alone, without knowing what other files, libraries, services, or configuration do?"
373
+ : `Can you tell whether \`code\` breaks \`rule\` from ${context}, without knowing what other files, libraries, services, or configuration do beyond what they show?`,
374
+ ...fields,
375
+ },
376
+ criteria: {
377
+ true:
378
+ context === undefined
379
+ ? "`code` shows everything needed to tell whether it breaks `rule`"
380
+ : `${context} show everything needed to tell whether \`code\` breaks \`rule\``,
381
+ false:
382
+ context === undefined
383
+ ? "Whether `code` breaks `rule` depends on something `code` does not show, such as another file, a library, a service, or configuration"
384
+ : `Whether \`code\` breaks \`rule\` depends on something ${context} do not show, such as another file, a library, a service, or configuration`,
385
+ },
386
+ };
387
+ };
388
+
389
+ export const sufficiencyQuestion = (rule: Rule, readState: ReadState = {}) =>
390
+ sufficiencyQuestionWith(ruleFields(rule), readState);
356
391
 
357
392
  /** The key a rule's sufficiency question rides under, beside its locate question. */
358
393
  export const sufficiencyKey = (id: RuleId): string => `sufficient:${id}`;
@@ -367,20 +402,39 @@ export const edgeKey = (edge: "start" | "end", id: RuleId, section: number): str
367
402
  * line's text, since the state carries no line numbers.
368
403
  */
369
404
  const edgeQuestion = (
370
- rule: Rule,
405
+ fields: ReturnType<typeof ruleFields>,
371
406
  edge: "start" | "end",
372
407
  key: string,
373
- lines: Lines,
374
- numbers: ReadonlyArray<number>,
408
+ criteria: Readonly<Record<string, string>>,
375
409
  ) => ({
376
410
  type: "choice" as const,
377
411
  instructions: {
378
412
  question: `Which line of \`code["${key}"]\` does the code that breaks \`rule\` ${edge} on?`,
379
- ...ruleFields(rule),
413
+ ...fields,
380
414
  },
381
- criteria: Object.fromEntries(
382
- numbers.map((line) => [String(line), (lines[line - 1] ?? "").trim().slice(0, 120)]),
383
- ),
415
+ criteria,
416
+ });
417
+
418
+ const lineCriteria = (lines: Lines, section: Range): Record<string, string> => {
419
+ const criteria: Record<string, string> = {};
420
+ for (let line = section.first; line <= section.last; line++) {
421
+ const text = (lines[line - 1] ?? "").trim();
422
+ if (text !== "") criteria[String(line)] = text.slice(0, 120);
423
+ }
424
+ return criteria;
425
+ };
426
+
427
+ const sectionQuestion = (
428
+ rule: Rule,
429
+ fields: ReturnType<typeof ruleFields>,
430
+ criteria: Readonly<Record<string, null>>,
431
+ ) => ({
432
+ type: "choice" as const,
433
+ instructions: {
434
+ question: `Which entry of \`code\` most clearly ${offense(rule)}?`,
435
+ ...fields,
436
+ },
437
+ criteria,
384
438
  });
385
439
 
386
440
  /**
@@ -394,80 +448,113 @@ const edgeQuestion = (
394
448
  * held a violation for 94% of real findings, and 98% where the section was
395
449
  * right, and were most often one line.
396
450
  */
397
- export const locateBody = (model: string, lines: Lines, rules: Rules) => {
398
- const state = stateOf(lines);
451
+ export const locateBody = (
452
+ model: string,
453
+ lines: Lines,
454
+ rules: Rules,
455
+ readState: ReadState = {},
456
+ ) => {
457
+ const sections = sectionsOf(lines);
458
+ const state = stateOf(lines, readState, sections);
399
459
  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
- );
460
+ const choosing = sections.length > 1;
461
+ const withCode = sections.map((section) => lineCriteria(lines, section));
406
462
  return {
407
463
  model,
408
464
  state,
409
465
  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
- ]),
466
+ Object.entries(rules).flatMap(([id, rule]) => {
467
+ const fields = ruleFields(rule);
468
+ return [
469
+ ...(choosing ? [[id, sectionQuestion(rule, fields, criteria)] as const] : []),
470
+ ...withCode.flatMap((options, index) =>
471
+ Record.isEmptyRecord(options)
472
+ ? []
473
+ : (["start", "end"] as const).map(
474
+ (edge) =>
475
+ [
476
+ edgeKey(edge, id, index + 1),
477
+ edgeQuestion(fields, edge, String(index + 1), options),
478
+ ] as const,
479
+ ),
480
+ ),
481
+ [sufficiencyKey(id), sufficiencyQuestionWith(fields, readState)] as const,
482
+ ];
483
+ }),
439
484
  ),
440
485
  };
441
486
  };
442
487
 
443
488
  /**
444
489
  * 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.
490
+ * one choice, and the shared state beside its longest question. Hooks are not predicted.
446
491
  */
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;
492
+ export const fits = (lines: Lines, rules: Rules, readState: ReadState = {}): boolean => {
493
+ const sections = sectionsOf(lines);
494
+ if (sections.length > CHOICE_LIMIT) return false;
495
+ const state = stateOf(lines, readState, sections);
496
+ const room = QUESTION_CONTEXT - WRAPPER - tokensOf(state);
497
+ if (room < 0) return false;
498
+ if (Record.isEmptyRecord(rules)) return true;
499
+
500
+ // These ids can overwrite generated questions; retain the bodies' last-write semantics.
501
+ if (Object.keys(rules).some((id) => /^(appliesTo|excludeIf|start|end|sufficient):/.test(id))) {
502
+ return [
503
+ judgeBody("", lines, rules, [], readState),
504
+ locateBody("", lines, rules, readState),
505
+ ].every((body) =>
506
+ Object.values(body.questions).every((question) => tokensOf(question) <= room),
507
+ );
508
+ }
509
+
510
+ const emptyFields = { rule: "" };
511
+ const fieldBytes = bytesOf(emptyFields);
512
+ const limit = room * 3;
513
+ const criteriaBytes = bytesOf(Record.map(state.code, () => null)) - 2;
514
+ let longest = bytesOf(sufficiencyQuestionWith(emptyFields, readState));
515
+ for (const [index, section] of sections.entries()) {
516
+ const options = lineCriteria(lines, section);
517
+ if (Record.isEmptyRecord(options)) continue;
518
+ // `start` is two bytes longer than `end`; fields and criteria are identical.
519
+ longest = Math.max(
520
+ longest,
521
+ bytesOf(edgeQuestion(emptyFields, "start", String(index + 1), options)),
522
+ );
523
+ }
524
+ for (const rule of Object.values(rules)) {
525
+ // Replace the template's fields in bytes, rounding only the complete question.
526
+ const extra = bytesOf(ruleFields(rule)) - fieldBytes;
527
+ if (longest + extra > limit) return false;
528
+ const judge = judgeQuestionWith(rule, emptyFields);
529
+ if (bytesOf(judge) + extra > limit) return false;
530
+ if (
531
+ sections.length > 1 &&
532
+ bytesOf(sectionQuestion(rule, emptyFields, {})) + criteriaBytes + extra > limit
533
+ ) {
534
+ return false;
535
+ }
536
+ for (const kind of MATCHER_KINDS) {
537
+ for (const matcher of rule[kind] ?? []) {
538
+ if (bytesOf(matcherQuestionWith(judge.instructions, kind, matcher)) + extra > limit) {
539
+ return false;
540
+ }
541
+ }
542
+ }
543
+ }
544
+ return true;
455
545
  };
456
546
 
457
- export interface Body {
458
- readonly model: string;
459
- readonly state: unknown;
460
- readonly questions: Readonly<Record<string, unknown>>;
461
- }
547
+ /** A request to Jev, its questions typed as `Q`: `Question`, as adhere asks them, or anything an eval arm asks. */
548
+ export type Body<Q = unknown> = SystemOneRequest<Q>;
462
549
 
463
550
  /**
464
551
  * A body as requests Jev can take: its questions in order, with a new
465
552
  * request whenever the next question would not fit beside the state and
466
553
  * the questions before it.
467
554
  */
468
- export const requestsOf = (body: Body): ReadonlyArray<Body> => {
555
+ export const requestsOf = <Q>(body: Body<Q>): ReadonlyArray<Body<Q>> => {
469
556
  const room = REQUEST_CONTEXT - WRAPPER - tokensOf(body.state);
470
- const requests: Array<{ readonly questions: Record<string, unknown>; tokens: number }> = [];
557
+ const requests: Array<{ readonly questions: Record<string, Q>; tokens: number }> = [];
471
558
  for (const [id, question] of Object.entries(body.questions)) {
472
559
  const tokens = tokensOf(question);
473
560
  const last = requests.at(-1);
@@ -481,26 +568,30 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
481
568
  return requests.map(({ questions }) => ({ ...body, questions }));
482
569
  };
483
570
 
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
571
  /**
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.
572
+ * All rules share one body except those with `appendState`, each in a body
573
+ * of its own so only its questions read what its hook adds to the shared state.
492
574
  */
493
575
  export const requestGroups = (rules: Rules): ReadonlyArray<Rules> => {
494
- const shared = Record.filter(rules, (rule) => !readsOwnState(rule));
576
+ const shared = Record.filter(rules, (rule) => rule.appendState === undefined);
495
577
  return [
496
578
  ...(Record.isEmptyRecord(shared) ? [] : [shared]),
497
- ...Object.entries(rules).flatMap(([id, rule]) => (readsOwnState(rule) ? [{ [id]: rule }] : [])),
579
+ ...Object.entries(rules).flatMap(([id, rule]) =>
580
+ rule.appendState === undefined ? [] : [{ [id]: rule }],
581
+ ),
498
582
  ];
499
583
  };
500
584
 
501
585
  /** 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)));
586
+ const judgeRequestsOf = (
587
+ lines: Lines,
588
+ rules: Rules,
589
+ sampled: ReadonlyArray<RuleId>,
590
+ readState: ReadState,
591
+ ) =>
592
+ requestGroups(rules).flatMap((group) =>
593
+ requestsOf(judgeBody("", lines, group, sampled, readState)),
594
+ );
504
595
 
505
596
  /**
506
597
  * The requests judging `rules` over a file takes, and about how many input
@@ -512,8 +603,9 @@ export const judgeLoad = (
512
603
  lines: Lines,
513
604
  rules: Rules,
514
605
  sampled: ReadonlyArray<RuleId> = [],
606
+ readState: ReadState = {},
515
607
  ): { readonly requests: number; readonly tokens: number } => {
516
- const requests = judgeRequestsOf(lines, rules, sampled);
608
+ const requests = judgeRequestsOf(lines, rules, sampled, readState);
517
609
  return {
518
610
  requests: requests.length,
519
611
  tokens: requests.reduce((sum, request) => sum + tokensOf(request), 0),
@@ -525,11 +617,13 @@ export const judgeRequests = (
525
617
  lines: Lines,
526
618
  rules: Rules,
527
619
  sampled: ReadonlyArray<RuleId> = [],
528
- ): number => judgeRequestsOf(lines, rules, sampled).length;
620
+ readState: ReadState = {},
621
+ ): number => judgeRequestsOf(lines, rules, sampled, readState).length;
529
622
 
530
623
  /** 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;
624
+ export const locateRequests = (lines: Lines, rules: Rules, readState: ReadState = {}): number =>
625
+ requestGroups(rules).flatMap((group) => requestsOf(locateBody("", lines, group, readState)))
626
+ .length;
533
627
 
534
628
  /** A choice keeps one criterion for "none", which leaves this many for partners. */
535
629
  const PARTNERS_PER_QUESTION = CHOICE_LIMIT - 1;
@@ -596,17 +690,15 @@ export const contradictBody = (model: string, rules: ReadonlyArray<ComparedRule>
596
690
  * it was one the question offered. "none", or a stray answer, names no pair.
597
691
  */
598
692
  export const namedPairs = (
599
- answers: Readonly<Record<string, { readonly choice: string }>>,
693
+ answers: Readonly<Record<string, string>>,
600
694
  partners: ReadonlyArray<ReadonlyArray<number>>,
601
695
  ): ReadonlyArray<Pair> =>
602
- Object.entries(answers).flatMap(([key, { choice }]): ReadonlyArray<Pair> => {
696
+ Object.entries(answers).flatMap(([key, choice]): ReadonlyArray<Pair> => {
603
697
  const rule = Number(key.split(":")[0]);
604
698
  const partner = Number(choice);
605
699
  return partners[rule]?.includes(partner) === true ? [[rule, partner]] : [];
606
700
  });
607
701
 
608
702
  /** 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;
703
+ export const pairProbability = (answers: Readonly<Record<string, number>>, pair: Pair): number =>
704
+ answers[pairKey(pair)] ?? 0;