@drkmttr/adhere 0.4.1 → 0.6.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.
Files changed (30) hide show
  1. package/README.md +55 -21
  2. package/package.json +6 -6
  3. package/presets/effect/basics/fn-for-named-effects.md +1 -1
  4. package/presets/effect/basics/gen-for-sequencing.md +1 -1
  5. package/presets/effect/basics/instrument-with-pipe.md +1 -1
  6. package/presets/effect/config/business-logic-depends-on-config-service.md +1 -1
  7. package/presets/effect/config/secrets-are-redacted.md +1 -1
  8. package/presets/effect/config/validate-with-schema.md +1 -1
  9. package/presets/effect/data/brand-meaningful-primitives.md +1 -1
  10. package/presets/effect/data/decode-json-with-schema.md +1 -1
  11. package/presets/effect/data/records-are-schema-classes.md +1 -1
  12. package/presets/effect/data/variants-are-tagged-unions.md +1 -1
  13. package/presets/effect/errors/catch-defects-at-boundaries-only.md +1 -1
  14. package/presets/effect/errors/defects-for-bugs.md +1 -1
  15. package/presets/effect/errors/domain-errors-are-tagged.md +1 -1
  16. package/presets/effect/errors/wrap-external-errors.md +1 -1
  17. package/presets/effect/platform/os-access-through-platform-services.md +1 -1
  18. package/presets/effect/services/fresh-layer-per-test.md +1 -1
  19. package/presets/effect/services/memoize-parameterized-layers.md +1 -1
  20. package/presets/effect/services/methods-have-no-requirements.md +1 -1
  21. package/presets/effect/services/provide-at-entry.md +1 -1
  22. package/presets/effect/services/test-layers-are-in-memory.md +1 -1
  23. package/presets/effect/testing/test-clock-for-time.md +1 -1
  24. package/skills/adhere/SKILL.md +1 -1
  25. package/src/cli.ts +86 -8
  26. package/src/contradictions.ts +51 -57
  27. package/src/services/Jev.http.ts +80 -11
  28. package/src/services/Jev.ts +244 -36
  29. package/src/workflows/audit.ts +195 -79
  30. package/src/workflows/format.ts +42 -1
@@ -2,16 +2,24 @@ import { AdhereConfig } from "#services/AdhereConfig.ts";
2
2
  import { Credentials } from "#services/Credentials.ts";
3
3
  import {
4
4
  blockBody,
5
+ type Body,
6
+ type ComparedRule,
7
+ conflictBody,
8
+ contradictBody,
5
9
  Jev,
6
10
  JevUnavailable,
7
11
  judgeBody,
8
12
  type Lines,
9
13
  locateBody,
14
+ namedPairs,
10
15
  needsBlocks,
16
+ type Pair,
17
+ pairProbability,
18
+ requestsOf,
11
19
  type Rules,
12
20
  } from "#services/Jev.ts";
13
- import { Effect, Layer, Record, Schedule, Schema } from "effect";
14
- import { HttpClient, HttpClientResponse } from "effect/unstable/http";
21
+ import { type Cause, Effect, Layer, Record, Schedule, Schema } from "effect";
22
+ import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
15
23
  import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
16
24
 
17
25
  const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
@@ -25,6 +33,27 @@ const ChoiceAnswers = Schema.Struct({
25
33
 
26
34
  const refused = (message: string) => JevUnavailable.make({ message });
27
35
 
36
+ /** Jev's error body, on one line and cut short, or nothing when it sent none. */
37
+ const detailOf = (body: string): string => {
38
+ const text = body.trim().replace(/\s+/g, " ");
39
+ return text.length === 0 ? "" : `: ${text.slice(0, 300)}`;
40
+ };
41
+
42
+ /**
43
+ * A failed request as a refusal: for an answer outside 2xx, its status and
44
+ * what Jev said, since the status alone does not say why; otherwise the cause.
45
+ */
46
+ const refusalOf = (problem: HttpClientError.HttpClientError | Cause.TimeoutError) => {
47
+ if (!HttpClientError.isHttpClientError(problem) || problem.reason._tag !== "StatusCodeError") {
48
+ return Effect.succeed(refused(`System One request failed: ${problem.message}`));
49
+ }
50
+ const { response } = problem.reason;
51
+ return Effect.map(
52
+ Effect.orElseSucceed(response.text, () => ""),
53
+ (body) => refused(`Jev answered HTTP ${response.status}${detailOf(body)}`),
54
+ );
55
+ };
56
+
28
57
  export const JevLive = Layer.effect(Jev)(
29
58
  Effect.gen(function* () {
30
59
  const config = yield* AdhereConfig;
@@ -38,7 +67,7 @@ export const JevLive = Layer.effect(Jev)(
38
67
  }),
39
68
  );
40
69
 
41
- const ask = <A>(body: unknown, Answers: Schema.Codec<A, unknown, never, never>) =>
70
+ const ask = <A>(body: Body, Answers: Schema.Codec<A, unknown, never, never>) =>
42
71
  Effect.gen(function* () {
43
72
  // Read here, not in the layer: a run with nothing pending needs no key.
44
73
  const apiKey = yield* credentials.apiKey.pipe(
@@ -50,9 +79,7 @@ export const JevLive = Layer.effect(Jev)(
50
79
  ).pipe(Effect.orDie);
51
80
  const response = yield* client
52
81
  .execute(HttpClientRequest.bearerToken(request, apiKey))
53
- .pipe(
54
- Effect.mapError((problem) => refused(`System One request failed: ${problem.message}`)),
55
- );
82
+ .pipe(Effect.catch((problem) => Effect.flatMap(refusalOf(problem), Effect.fail)));
56
83
  return yield* HttpClientResponse.schemaBodyJson(Answers)(response).pipe(
57
84
  Effect.mapError((problem) =>
58
85
  refused(`System One response did not decode: ${problem.message}`),
@@ -60,8 +87,27 @@ export const JevLive = Layer.effect(Jev)(
60
87
  );
61
88
  });
62
89
 
90
+ /** Every answer to a body, over as many requests as Jev's context needs. */
91
+ const answersTo = <A>(
92
+ body: Body,
93
+ Answers: Schema.Codec<
94
+ { readonly answers: Readonly<Record<string, A>> },
95
+ unknown,
96
+ never,
97
+ never
98
+ >,
99
+ ) =>
100
+ Effect.map(
101
+ Effect.forEach(requestsOf(body), (request) => ask(request, Answers)),
102
+ (responses) =>
103
+ responses.reduce<Record<string, A>>(
104
+ (answers, response) => ({ ...answers, ...response.answers }),
105
+ {},
106
+ ),
107
+ );
108
+
63
109
  const judge = Effect.fn("Jev.judge")(function* (lines: Lines, rules: Rules) {
64
- const { answers } = yield* ask(judgeBody(config.model, lines, rules), NoulAnswers);
110
+ const answers = yield* answersTo(judgeBody(config.model, lines, rules), NoulAnswers);
65
111
  return Record.map(answers, (answer) => answer.noul);
66
112
  });
67
113
 
@@ -70,12 +116,35 @@ export const JevLive = Layer.effect(Jev)(
70
116
 
71
117
  const locate = Effect.fn("Jev.locate")(function* (lines: Lines, rules: Rules) {
72
118
  const blocks = needsBlocks(lines)
73
- ? chosen((yield* ask(blockBody(config.model, lines, rules), ChoiceAnswers)).answers)
119
+ ? chosen(yield* answersTo(blockBody(config.model, lines, rules), ChoiceAnswers))
74
120
  : undefined;
75
- const { answers } = yield* ask(locateBody(config.model, lines, rules, blocks), ChoiceAnswers);
76
- return chosen(answers);
121
+ return chosen(
122
+ yield* answersTo(locateBody(config.model, lines, rules, blocks), ChoiceAnswers),
123
+ );
124
+ });
125
+
126
+ const conflicts = Effect.fn("Jev.conflicts")(function* (
127
+ rules: ReadonlyArray<ComparedRule>,
128
+ partners: ReadonlyArray<ReadonlyArray<number>>,
129
+ ) {
130
+ const answers = yield* answersTo(conflictBody(config.model, rules, partners), ChoiceAnswers);
131
+ return namedPairs(answers, partners);
132
+ });
133
+
134
+ const contradicts = Effect.fn("Jev.contradicts")(function* (
135
+ rules: ReadonlyArray<ComparedRule>,
136
+ pairs: ReadonlyArray<Pair>,
137
+ ) {
138
+ return yield* Effect.forEach(
139
+ pairs,
140
+ (pair) =>
141
+ Effect.map(ask(contradictBody(config.model, rules, pair), NoulAnswers), ({ answers }) =>
142
+ pairProbability(answers, pair),
143
+ ),
144
+ { concurrency: 8 },
145
+ );
77
146
  });
78
147
 
79
- return Jev.of({ judge, locate });
148
+ return Jev.of({ judge, locate, conflicts, contradicts });
80
149
  }),
81
150
  );
@@ -19,13 +19,58 @@ export class Jev extends Context.Service<
19
19
  lines: Lines,
20
20
  rules: Rules,
21
21
  ) => Effect.Effect<Record<RuleId, number>, JevUnavailable>;
22
+ /**
23
+ * Per rule, the partner Jev names as impossible to follow in the same code,
24
+ * as `[rule, partner]` indexes into `rules`. A rule Jev names none for is
25
+ * left out.
26
+ */
27
+ readonly conflicts: (
28
+ rules: ReadonlyArray<ComparedRule>,
29
+ partners: ReadonlyArray<ReadonlyArray<number>>,
30
+ ) => Effect.Effect<ReadonlyArray<Pair>, JevUnavailable>;
31
+ /**
32
+ * Per pair, in order, Jev's probability that no code can follow both rules,
33
+ * each pair asked on its own.
34
+ */
35
+ readonly contradicts: (
36
+ rules: ReadonlyArray<ComparedRule>,
37
+ pairs: ReadonlyArray<Pair>,
38
+ ) => Effect.Effect<ReadonlyArray<number>, JevUnavailable>;
22
39
  }
23
40
  >()("@drkmttr/adhere/services/Jev") {}
24
41
 
42
+ /** A rule compared with other rules rather than with a file. */
43
+ export interface ComparedRule {
44
+ readonly id: RuleId;
45
+ readonly rule: Rule;
46
+ }
47
+
48
+ /** Two rules, as indexes into the rules being compared. */
49
+ export type Pair = readonly [number, number];
50
+
25
51
  /** A `choice` question accepts at most this many criteria. */
26
52
  const CHOICE_LIMIT = 255;
27
53
  const LINES_PER_BLOCK = 20;
28
- export const MAX_LINES = CHOICE_LIMIT * LINES_PER_BLOCK;
54
+ const MAX_LINES = CHOICE_LIMIT * LINES_PER_BLOCK;
55
+
56
+ /**
57
+ * Jev 1.13's context, from its model card: the state with its longest
58
+ * question, and the state with every question in the request.
59
+ */
60
+ const QUESTION_CONTEXT = 32_000;
61
+ const REQUEST_CONTEXT = 64_000;
62
+ /** Room for what Jev wraps around a request, which `tokensOf` does not see. */
63
+ const WRAPPER = 1_000;
64
+
65
+ const encoder = new TextEncoder();
66
+
67
+ /**
68
+ * An estimate, meant to run high, of the tokens Jev reads for a value: one
69
+ * per three bytes of its JSON. Code runs a little over three bytes a token,
70
+ * prose about four.
71
+ */
72
+ export const tokensOf = (value: unknown): number =>
73
+ Math.ceil(encoder.encode(JSON.stringify(value)).length / 3);
29
74
 
30
75
  export const snippetOf = (line: string): string => line.trim().slice(0, 120);
31
76
 
@@ -34,37 +79,61 @@ const isBlank = (line: string): boolean => line.trim().length === 0;
34
79
  export const needsBlocks = (lines: Lines): boolean =>
35
80
  lines.filter((line) => !isBlank(line)).length > CHOICE_LIMIT;
36
81
 
37
- const stateOf = (lines: Lines, rules: Rules) => ({
82
+ /** The file, numbered so a line question can name a line. The only thing the questions share. */
83
+ const stateOf = (lines: Lines) => ({
38
84
  code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
39
- rules: Record.map(rules, ({ description, reference, avoid }) => ({
40
- description,
41
- ...(reference === undefined ? {} : { reference }),
42
- ...(avoid === undefined ? {} : { avoid }),
43
- })),
44
85
  });
45
86
 
46
- const field = (id: RuleId, key: "avoid" | "description" | "reference") =>
47
- `state.rules["${id}"].${key}`;
87
+ /** One side of a noul: what it means, with the rule's code for that side as an example. */
88
+ const sideOf = (what: string, example: string | undefined) =>
89
+ example === undefined ? { what } : { what, examples: [example] };
90
+
91
+ /** A rule's text as a comparison of rules reads it. Code a rule lacks is left out, not sent empty. */
92
+ const ruleState = ({ description, reference, avoid }: Rule) => ({
93
+ description,
94
+ ...(reference === undefined ? {} : { reference }),
95
+ ...(avoid === undefined ? {} : { avoid }),
96
+ });
48
97
 
49
98
  /**
50
- * A rule with a reference asks whether the file diverges from it, with the
51
- * code to avoid as an example when there is some. A rule without one asks
52
- * whether the file contains the code to avoid.
99
+ * The compared rules a question mentions, keyed by index: ids alone would
100
+ * collide, since a nested rule can share its id with the rule it shadows.
53
101
  */
54
- const judgeInstructions = (id: RuleId, rule: Rule): string =>
55
- rule.reference === undefined
56
- ? `Does state.code contain the pattern shown in ${field(id, "avoid")}, which ${field(id, "description")} rules out? Answer no if nothing in this file resembles it.`
57
- : `Does state.code diverge from the pattern shown in ${field(id, "reference")}${
58
- rule.avoid === undefined ? "" : `, for example by doing what ${field(id, "avoid")} shows`
59
- }, as described by ${field(id, "description")}? Answer no if the pattern does not apply to this file.`;
60
-
61
- /** What the offending line does, for the line and block questions. */
62
- const offense = (id: RuleId, rule: Rule): string =>
63
- rule.reference === undefined
64
- ? `shows the pattern in ${field(id, "avoid")}`
65
- : rule.avoid === undefined
66
- ? `diverges from ${field(id, "reference")}`
67
- : `diverges from ${field(id, "reference")} or resembles ${field(id, "avoid")}`;
102
+ const comparedState = (rules: ReadonlyArray<ComparedRule>, mentioned: Iterable<number>) => {
103
+ const state: Record<string, { readonly id: RuleId } & ReturnType<typeof ruleState>> = {};
104
+ for (const index of [...new Set(mentioned)].sort((a, b) => a - b)) {
105
+ const compared = rules[index];
106
+ if (compared !== undefined) {
107
+ state[String(index)] = { id: compared.id, ...ruleState(compared.rule) };
108
+ }
109
+ }
110
+ return { rules: state };
111
+ };
112
+
113
+ /**
114
+ * Whether the file breaks the rule. The rule rides in the question, not the
115
+ * state, so each question reads its own rule and no other. The criteria draw
116
+ * the line: code to avoid is a yes, and the reference is a no, as is a file
117
+ * with nothing the rule covers.
118
+ */
119
+ export const judgeQuestion = (rule: Rule) => ({
120
+ type: "noul" as const,
121
+ instructions: { question: "Does `code` break `rule`?", rule: rule.description },
122
+ criteria: {
123
+ true: sideOf("Part of `code` breaks `rule`", rule.avoid),
124
+ false: sideOf(
125
+ "Every part of `code` that `rule` covers follows it, or `rule` covers no part of `code`",
126
+ rule.reference,
127
+ ),
128
+ },
129
+ });
130
+
131
+ /** The rule as a line question carries it: the description, and its code on either side. */
132
+ const ruleFields = (rule: Rule) => ({
133
+ rule: rule.description,
134
+ ...(rule.reference === undefined ? {} : { follows_rule: rule.reference }),
135
+ ...(rule.avoid === undefined ? {} : { breaks_rule: rule.avoid }),
136
+ });
68
137
 
69
138
  const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
70
139
  const criteria: Record<string, string> = {};
@@ -77,11 +146,8 @@ const lineCriteria = (lines: Lines, from: number, to: number): Record<string, st
77
146
 
78
147
  export const judgeBody = (model: string, lines: Lines, rules: Rules) => ({
79
148
  model,
80
- state: stateOf(lines, rules),
81
- questions: Record.map(rules, (rule, id) => ({
82
- type: "noul" as const,
83
- instructions: judgeInstructions(id, rule),
84
- })),
149
+ state: stateOf(lines),
150
+ questions: Record.map(rules, (rule) => judgeQuestion(rule)),
85
151
  });
86
152
 
87
153
  export const locateBody = (
@@ -91,12 +157,15 @@ export const locateBody = (
91
157
  blocks?: Readonly<Record<RuleId, number>>,
92
158
  ) => ({
93
159
  model,
94
- state: stateOf(lines, rules),
160
+ state: stateOf(lines),
95
161
  questions: Record.map(rules, (rule, id) => {
96
162
  const block = blocks?.[id];
97
163
  return {
98
164
  type: "choice" as const,
99
- instructions: `Which line of state.code most clearly ${offense(id, rule)}?`,
165
+ instructions: {
166
+ question: "Which line of `code` most clearly breaks `rule`?",
167
+ ...ruleFields(rule),
168
+ },
100
169
  criteria:
101
170
  block === undefined
102
171
  ? lineCriteria(lines, 1, lines.length)
@@ -115,11 +184,150 @@ export const blockBody = (model: string, lines: Lines, rules: Rules) => {
115
184
  }
116
185
  return {
117
186
  model,
118
- state: stateOf(lines, rules),
119
- questions: Record.map(rules, (rule, id) => ({
187
+ state: stateOf(lines),
188
+ questions: Record.map(rules, (rule) => ({
120
189
  type: "choice" as const,
121
- instructions: `Which block of state.code contains the line that most clearly ${offense(id, rule)}?`,
190
+ instructions: {
191
+ question: "Which block of `code` contains the line that most clearly breaks `rule`?",
192
+ ...ruleFields(rule),
193
+ },
122
194
  criteria,
123
195
  })),
124
196
  };
125
197
  };
198
+
199
+ /**
200
+ * Whether Jev can take every request a file may need: its lines within two
201
+ * line choices, and its code beside the longest question any rule asks.
202
+ */
203
+ export const fits = (lines: Lines, rules: Rules): boolean => {
204
+ if (lines.length > MAX_LINES) return false;
205
+ const located = needsBlocks(lines) ? blockBody("", lines, rules) : locateBody("", lines, rules);
206
+ const questions = [
207
+ ...Object.values(judgeBody("", lines, rules).questions),
208
+ ...Object.values(located.questions),
209
+ ];
210
+ const longest = Math.max(0, ...questions.map(tokensOf));
211
+ return tokensOf(stateOf(lines)) + longest <= QUESTION_CONTEXT - WRAPPER;
212
+ };
213
+
214
+ export interface Body {
215
+ readonly model: string;
216
+ readonly state: unknown;
217
+ readonly questions: Readonly<Record<string, unknown>>;
218
+ }
219
+
220
+ /**
221
+ * A body as requests Jev can take: its questions in order, with a new
222
+ * request whenever the next question would not fit beside the state and
223
+ * the questions before it.
224
+ */
225
+ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
226
+ const room = REQUEST_CONTEXT - WRAPPER - tokensOf(body.state);
227
+ const requests: Array<{ readonly questions: Record<string, unknown>; tokens: number }> = [];
228
+ for (const [id, question] of Object.entries(body.questions)) {
229
+ const tokens = tokensOf(question);
230
+ const last = requests.at(-1);
231
+ if (last !== undefined && last.tokens + tokens <= room) {
232
+ last.questions[id] = question;
233
+ last.tokens += tokens;
234
+ } else {
235
+ requests.push({ questions: { [id]: question }, tokens });
236
+ }
237
+ }
238
+ return requests.map(({ questions }) => ({ ...body, questions }));
239
+ };
240
+
241
+ /** The requests judging `rules` over a file takes: the judge body, split to fit, as `judge` sends it. */
242
+ export const judgeRequests = (lines: Lines, rules: Rules): number =>
243
+ requestsOf(judgeBody("", lines, rules)).length;
244
+
245
+ /**
246
+ * The requests locating `rules` in a file takes, split to fit as `locate`
247
+ * sends them: a block choice first for a long file, then a line choice. For a
248
+ * long file the line choice is counted before a block is chosen, so it can
249
+ * run high.
250
+ */
251
+ export const locateRequests = (lines: Lines, rules: Rules): number =>
252
+ (needsBlocks(lines) ? requestsOf(blockBody("", lines, rules)).length : 0) +
253
+ requestsOf(locateBody("", lines, rules)).length;
254
+
255
+ /** A choice keeps one criterion for "none", which leaves this many for partners. */
256
+ const PARTNERS_PER_QUESTION = CHOICE_LIMIT - 1;
257
+
258
+ interface ChoiceQuestion {
259
+ readonly type: "choice";
260
+ readonly instructions: string;
261
+ readonly criteria: Record<string, string>;
262
+ }
263
+
264
+ /**
265
+ * Per rule, one `choice` among the rules that apply to the same files: which
266
+ * of them cannot be followed in the same code, or none. The questions grow
267
+ * with the rules, not with every pair of them. A rule with more partners than
268
+ * a choice holds gets one question per chunk, keyed `rule:chunk`.
269
+ */
270
+ export const conflictBody = (
271
+ model: string,
272
+ rules: ReadonlyArray<ComparedRule>,
273
+ partners: ReadonlyArray<ReadonlyArray<number>>,
274
+ ) => {
275
+ const questions: Record<string, ChoiceQuestion> = {};
276
+ const mentioned: Array<number> = [];
277
+ partners.forEach((some, index) => {
278
+ for (let chunk = 0; chunk * PARTNERS_PER_QUESTION < some.length; chunk++) {
279
+ const from = chunk * PARTNERS_PER_QUESTION;
280
+ const offered = some.slice(from, from + PARTNERS_PER_QUESTION);
281
+ const criteria: Record<string, string> = { none: "None of them" };
282
+ for (const partner of offered) {
283
+ criteria[String(partner)] = rules[partner]?.rule.description ?? "";
284
+ }
285
+ mentioned.push(index, ...offered);
286
+ questions[`${index}:${chunk}`] = {
287
+ type: "choice",
288
+ instructions: `Each choice other than none is the rule in state.rules under its key. Which of them demands the opposite of what state.rules["${index}"] demands of the same code, so that following one breaks the other? Choose none if code can follow each of them alongside it.`,
289
+ criteria,
290
+ };
291
+ }
292
+ });
293
+ return { model, state: comparedState(rules, mentioned), questions };
294
+ };
295
+
296
+ const pairKey = ([first, second]: Pair): string => `${first}-${second}`;
297
+
298
+ /**
299
+ * A `noul` for one pair Jev named: do the two rules demand opposite things of
300
+ * the same code? One pair per request, with only its two rules in the state.
301
+ * Other rules beside them dilute the judgment: over the effect preset, a real
302
+ * contradiction scored 0.89 alone and 0.59 among the other named pairs.
303
+ */
304
+ export const contradictBody = (model: string, rules: ReadonlyArray<ComparedRule>, pair: Pair) => ({
305
+ model,
306
+ state: comparedState(rules, pair),
307
+ questions: {
308
+ [pairKey(pair)]: {
309
+ type: "noul" as const,
310
+ instructions: `Do state.rules["${pair[0]}"] and state.rules["${pair[1]}"] demand opposite things of the same code, so that following one breaks the other? Answer no if code can follow both, or if they are about different things.`,
311
+ },
312
+ },
313
+ });
314
+
315
+ /**
316
+ * The pairs a conflict answer names: per question, the partner Jev chose, when
317
+ * it was one the question offered. "none", or a stray answer, names no pair.
318
+ */
319
+ export const namedPairs = (
320
+ answers: Readonly<Record<string, { readonly choice: string }>>,
321
+ partners: ReadonlyArray<ReadonlyArray<number>>,
322
+ ): ReadonlyArray<Pair> =>
323
+ Object.entries(answers).flatMap(([key, { choice }]): ReadonlyArray<Pair> => {
324
+ const rule = Number(key.split(":")[0]);
325
+ const partner = Number(choice);
326
+ return partners[rule]?.includes(partner) === true ? [[rule, partner]] : [];
327
+ });
328
+
329
+ /** The probability a contradiction answer gives its pair; 0 when it gives none. */
330
+ export const pairProbability = (
331
+ answers: Readonly<Record<string, { readonly noul: number }>>,
332
+ pair: Pair,
333
+ ): number => answers[pairKey(pair)]?.noul ?? 0;