pi-system-one 1.2.1 → 1.2.3

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.
@@ -0,0 +1,565 @@
1
+ // Property tests for the accumulated system_one normalization rules.
2
+ //
3
+ // More than a dozen representation repairs have been added to
4
+ // prepareSystemOneArgs, each firing on a condition. The unit tests produce
5
+ // them one at a time, so the risk that is left is not "the last 2% of
6
+ // shapes" but "two rules that fire together produce something neither rule
7
+ // alone would". That is a deterministic question, so it is answered
8
+ // deterministically here rather than by a 30-minute LLM run.
9
+ //
10
+ // Properties checked over a generated corpus of 34,600 shapes:
11
+ // 1. idempotence - f(f(x)) deep-equals f(x)
12
+ // 2. non-mutation - the input is never modified
13
+ // 3. determinism - same input, same output, twice
14
+ // 4. type/shape - after normalization, a question's criteria container
15
+ // matches its type (choice -> object, score -> array,
16
+ // noul -> absent or object), and no question carries a
17
+ // nested `state`.
18
+ // 5. legibility - every score level the contract allows a legend to carry
19
+ // is actually named in the rendered result.
20
+ // 6. no silent drop - a question-map entry that normalization discards is
21
+ // named in a note, never dropped without a trace.
22
+ //
23
+ // Properties 1-4 assert on the REQUEST, so without 5 and 6 the tool RESULT
24
+ // was checked by nothing at all - and the result is the only System One text
25
+ // the agent reads at call time. An LLM behaviour suite scores first-call
26
+ // arguments and never inspects the result, so it cannot see either class of
27
+ // defect by construction.
28
+ //
29
+ // No model, no network: the tool is driven against a stub provider, so the
30
+ // whole sweep runs in well under a second and belongs in the unit suite.
31
+
32
+ import assert from "node:assert/strict";
33
+ import { before, describe, it } from "node:test";
34
+ import { renderSystemOneResult } from "../src/render.ts";
35
+ import {
36
+ buildSystemOneTool,
37
+ collectDiscardedQuestions,
38
+ prepareSystemOneArgs,
39
+ } from "../src/tool.ts";
40
+
41
+ // Shape invariants only mean something for requests that actually REACH the
42
+ // provider. prepareSystemOneArgs deliberately does not validate — a choice with
43
+ // a non-string array criteria is meant to be rejected downstream — so checking
44
+ // its output directly asserts things the contract never promised. This drives
45
+ // the real tool and inspects the captured wire request instead.
46
+ let captured: any = null;
47
+ const provider = {
48
+ id: "probe",
49
+ async evaluate(request: any) {
50
+ captured = request;
51
+ return { answers: {}, metadata: { provider: "probe" } };
52
+ },
53
+ };
54
+ const tool = buildSystemOneTool({ provider: provider as never });
55
+
56
+ async function reachesProvider(input: any): Promise<boolean> {
57
+ captured = null;
58
+ try {
59
+ await tool.execute(
60
+ "probe",
61
+ input as never,
62
+ undefined,
63
+ undefined,
64
+ {} as never,
65
+ );
66
+ return captured !== null;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ type Json = any;
73
+
74
+ const LEVEL_WORDS = [
75
+ "none",
76
+ "trivial",
77
+ "mild",
78
+ "moderate",
79
+ "serious",
80
+ "severe",
81
+ "catastrophic",
82
+ "negligible: within normal variance",
83
+ "Moderate: real pressure, no immediate damage",
84
+ "just-ok",
85
+ "a level whose text contains a colon: like this one",
86
+ " padded ",
87
+ "0",
88
+ "1",
89
+ "level with {braces} and (parens)",
90
+ ];
91
+
92
+ /*
93
+ * The score answer may distribute over index slots ("0".."n-1") or over the
94
+ * level values themselves, and system-one-core requires a legend labelling
95
+ * exactly the distributed keys. On the slot convention the keys are opaque, so
96
+ * a result that prints bare "0: 0.5" leaves the agent unable to name the level.
97
+ * For every rubric and both conventions, every level must appear in the render.
98
+ */
99
+ function checkLegibility(): string[] {
100
+ const bad: string[] = [];
101
+ for (const n of [2, 3, 4, 5, 8]) {
102
+ for (let seed = 0; seed < 40; seed += 1) {
103
+ const rand = rng(seed * 31 + n);
104
+ const levels: string[] = [];
105
+ for (let i = 0; i < n; i += 1)
106
+ levels.push(LEVEL_WORDS[Math.floor(rand() * LEVEL_WORDS.length)]);
107
+ const probs: Record<string, number> = {};
108
+ const legend: Record<string, string> = {};
109
+ for (let i = 0; i < n; i += 1) {
110
+ const share = i === n - 1 ? 0.5 : 0.5 / (n - 1);
111
+ probs[String(i)] = share;
112
+ legend[String(i)] = levels[i];
113
+ }
114
+ const onSlots = renderSystemOneResult({
115
+ answers: {
116
+ sev: {
117
+ type: "score",
118
+ score: (n - 1) / 2,
119
+ probabilities: probs,
120
+ legend,
121
+ confidence: 0.5,
122
+ },
123
+ },
124
+ } as any);
125
+ for (const level of levels) {
126
+ // A level equal to its own slot ("0") is ambiguous with the slot
127
+ // itself, so only require the distinct level texts to be present.
128
+ if (/^\d+$/.test(level.trim())) continue;
129
+ if (!onSlots.includes(level.trim()))
130
+ bad.push(
131
+ `level text dropped from a slot-keyed render: n=${n} seed=${seed} ${JSON.stringify(level)}`,
132
+ );
133
+ }
134
+ const onValues = renderSystemOneResult({
135
+ answers: {
136
+ sev: {
137
+ type: "score",
138
+ score: (n - 1) / 2,
139
+ probabilities: Object.fromEntries(levels.map((l) => [l, 1 / n])),
140
+ legend: Object.fromEntries(levels.map((l) => [l, l])),
141
+ confidence: 0.5,
142
+ },
143
+ },
144
+ } as any);
145
+ for (const level of levels) {
146
+ if (!onValues.includes(level.trim()))
147
+ bad.push(
148
+ `level text dropped from a value-keyed render: n=${n} seed=${seed} ${JSON.stringify(level)}`,
149
+ );
150
+ }
151
+ }
152
+ }
153
+ return bad;
154
+ }
155
+
156
+ const QUESTION_TYPES = [
157
+ "choice",
158
+ "noul",
159
+ "score",
160
+ "Choice",
161
+ "boolean",
162
+ "Rating",
163
+ ];
164
+
165
+ // Every shape we have ever seen, plus deliberate combinations of them.
166
+ function corpus(): Json[] {
167
+ const out: Json[] = [];
168
+ const criteriaShapes: Json[] = [
169
+ { a: null, b: null },
170
+ ["low", "high"],
171
+ { low: "bad", high: "good" },
172
+ { item: ["low", "high"] },
173
+ { item: { item: ["low", "high"] } },
174
+ { level: "meh" },
175
+ [
176
+ { label: "a", why: "x" },
177
+ { label: "b", why: "y" },
178
+ ],
179
+ null,
180
+ [],
181
+ "low to high",
182
+ ];
183
+ const extras: Json[] = [
184
+ {},
185
+ { description: "the question" },
186
+ { classification: "the question" },
187
+ { name: "n" },
188
+ { confidence: 0.9 },
189
+ { billing: "", onboarding: "" },
190
+ { true: "yes", false: "no" },
191
+ { state: { evidence: 1 } },
192
+ { context: "ctx" },
193
+ { approach: "yes_no" },
194
+ ];
195
+ const wrappers: Json[] = [
196
+ (q: Json) => ({ state: "S", questions: { q1: q } }),
197
+ (q: Json) => ({ state: "S", questions: [q] }),
198
+ (q: Json) => ({ context: "S", question: { q1: q } }),
199
+ (q: Json) => ({ evidence: "S", queries: { q1: q } }),
200
+ (q: Json) => ({ state: "S", questions: { item: [{ name: "q1", ...q }] } }),
201
+ (q: Json) => ({ state: "S", questions: { outer: { q1: q } } }),
202
+ (_q: Json) => ({ state: "S", questions: { questions: _q } }),
203
+ (_q: Json) => ({ state: "S", questions: { "text of the question": _q } }),
204
+ (q: Json) => ({ questions: { q1: { ...q, state: { nested: 1 } } } }),
205
+ (q: Json) => ({ state: "S", questions: { "a: null": q, other: "" } }),
206
+ (_q: Json) => ({
207
+ state: "S",
208
+ questions: {
209
+ item: ["a", "b"],
210
+ type: ["choice", "noul"],
211
+ instructions: ["q1?", "q2?"],
212
+ criteria: [{ x: null }, null],
213
+ },
214
+ }),
215
+ ];
216
+ for (const type of QUESTION_TYPES) {
217
+ for (const criteria of criteriaShapes) {
218
+ for (const extra of extras) {
219
+ const q: Json = { type };
220
+ if (criteria !== undefined) q.criteria = criteria;
221
+ if (Math.random() < 0.7) {
222
+ q.instructions = Math.random() < 0.5 ? "How?" : { text: "How?" };
223
+ }
224
+ Object.assign(q, extra);
225
+ for (const wrap of wrappers) out.push(wrap(q));
226
+ }
227
+ }
228
+ }
229
+ return out;
230
+ }
231
+
232
+ /**
233
+ * Randomised widening. The enumerated corpus above is a cross-product of
234
+ * shapes I thought of, and it found three real bugs - all of them in the
235
+ * COMBINATIONS rather than in the individual shapes. The obvious way to find
236
+ * the rest is to stop enumerating and generate: a seeded PRNG keeps this
237
+ * reproducible while exploring key names, nesting depth, alias assignment and
238
+ * value types far outside anything written by hand.
239
+ */
240
+ function rng(seed: number): () => number {
241
+ let s = seed >>> 0;
242
+ return () => {
243
+ s = (s * 1664525 + 1013904223) >>> 0;
244
+ return s / 0x100000000;
245
+ };
246
+ }
247
+
248
+ const DANGEROUS_KEYS = [
249
+ "__proto__",
250
+ "constructor",
251
+ "prototype",
252
+ "toString",
253
+ "valueOf",
254
+ "hasOwnProperty",
255
+ ];
256
+
257
+ const ALIAS_KEYS = [
258
+ "instructions",
259
+ "instruction",
260
+ "prompt",
261
+ "question",
262
+ "text",
263
+ "query",
264
+ "task",
265
+ "criteria",
266
+ "options",
267
+ "choices",
268
+ "levels",
269
+ "rubric",
270
+ "labels",
271
+ "description",
272
+ "title",
273
+ "detail",
274
+ "name",
275
+ "id",
276
+ "label",
277
+ "true",
278
+ "false",
279
+ "state",
280
+ "context",
281
+ "evidence",
282
+ "approximate",
283
+ "weight",
284
+ "confidence",
285
+ ];
286
+
287
+ function setKey(obj: Json, key: string, value: Json): void {
288
+ Object.defineProperty(obj, key, {
289
+ value,
290
+ enumerable: true,
291
+ writable: true,
292
+ configurable: true,
293
+ });
294
+ }
295
+
296
+ function randomValue(rand: () => number, depth: number): Json {
297
+ const roll = rand();
298
+ if (depth > 2) return "text";
299
+ if (roll < 0.3) return "some text";
300
+ if (roll < 0.4) return "";
301
+ if (roll < 0.5) return null;
302
+ if (roll < 0.6) return rand() < 0.5;
303
+ if (roll < 0.7) return Math.floor(rand() * 10);
304
+ if (roll < 0.8) {
305
+ const out: Json[] = [];
306
+ for (let i = 0; i < 1 + Math.floor(rand() * 3); i += 1)
307
+ out.push(randomValue(rand, depth + 1));
308
+ return out;
309
+ }
310
+ const out: Json = {};
311
+ for (let i = 0; i < 1 + Math.floor(rand() * 3); i += 1)
312
+ setKey(out, randomKey(rand), randomValue(rand, depth + 1));
313
+ return out;
314
+ }
315
+
316
+ function randomKey(rand: () => number): string {
317
+ if (rand() < 0.15)
318
+ return DANGEROUS_KEYS[Math.floor(rand() * DANGEROUS_KEYS.length)];
319
+ if (rand() < 0.5) return ALIAS_KEYS[Math.floor(rand() * ALIAS_KEYS.length)];
320
+ return `k${Math.floor(rand() * 6)}`;
321
+ }
322
+
323
+ function randomQuestion(rand: () => number): Json {
324
+ const q: Json = {
325
+ type: QUESTION_TYPES[Math.floor(rand() * QUESTION_TYPES.length)],
326
+ };
327
+ if (rand() < 0.8)
328
+ setKey(q, "instructions", rand() < 0.2 ? { text: "?" } : "how?");
329
+ if (rand() < 0.8) setKey(q, "criteria", randomValue(rand, 1));
330
+ for (let i = 0; i < Math.floor(rand() * 4); i += 1)
331
+ setKey(q, randomKey(rand), randomValue(rand, 1));
332
+ return q;
333
+ }
334
+
335
+ function randomRequest(rand: () => number): Json {
336
+ const q = randomQuestion(rand);
337
+ const shape = rand();
338
+ let questions: Json;
339
+ if (shape < 0.3) questions = { [randomKey(rand)]: q };
340
+ else if (shape < 0.5) questions = [q];
341
+ else if (shape < 0.6) questions = { [randomKey(rand)]: [q] };
342
+ else if (shape < 0.7) questions = { a: { [randomKey(rand)]: q } };
343
+ else if (shape < 0.8) questions = { [randomKey(rand)]: { a: q } };
344
+ else if (shape < 0.9) {
345
+ const out: Json = {};
346
+ setKey(out, randomKey(rand), q);
347
+ setKey(out, randomKey(rand), randomValue(rand, 1));
348
+ questions = out;
349
+ } else questions = randomValue(rand, 1);
350
+ const out: Json = { questions };
351
+ if (rand() < 0.6) setKey(out, "state", "S");
352
+ else if (rand() < 0.3) setKey(out, "context", "S");
353
+ if (rand() < 0.15) setKey(out, randomKey(rand), randomValue(rand, 1));
354
+ return out;
355
+ }
356
+
357
+ function deepFreeze(v: unknown): void {
358
+ if (typeof v === "object" && v !== null && !Object.isFrozen(v)) {
359
+ Object.freeze(v);
360
+ for (const k of Object.keys(v)) {
361
+ deepFreeze((v as Record<string, unknown>)[k]);
362
+ }
363
+ }
364
+ }
365
+
366
+ function shapeOk(args: Json): string | undefined {
367
+ const questions = args?.questions;
368
+ if (questions === undefined) return undefined;
369
+ if (
370
+ typeof questions !== "object" ||
371
+ questions === null ||
372
+ Array.isArray(questions)
373
+ ) {
374
+ return "questions is not a map";
375
+ }
376
+ for (const [name, q] of Object.entries<Json>(questions)) {
377
+ if (q === null || typeof q !== "object" || Array.isArray(q)) {
378
+ return `question ${name} is not an object`;
379
+ }
380
+ for (const key of ["state", "context", "evidence"]) {
381
+ if (Object.hasOwn(q, key)) return `question ${name} still carries ${key}`;
382
+ }
383
+ if (Object.hasOwn(q, "type") && q.type === "score") {
384
+ if (q.criteria !== undefined && !Array.isArray(q.criteria)) {
385
+ return `score question ${name} has non-array criteria`;
386
+ }
387
+ }
388
+ if (q.type === "choice" && q.criteria !== undefined) {
389
+ if (Array.isArray(q.criteria)) {
390
+ return `choice question ${name} has array criteria`;
391
+ }
392
+ }
393
+ if (Object.hasOwn(q, "type")) {
394
+ for (const key of Object.keys(q)) {
395
+ if (!["type", "instructions", "criteria"].includes(key)) {
396
+ return `question ${name} kept extra key ${key}`;
397
+ }
398
+ }
399
+ }
400
+ }
401
+ return undefined;
402
+ }
403
+
404
+ const inputs = [...corpus()];
405
+ for (const seed of [1, 7, 13, 99, 2024, 31337, 424242]) {
406
+ const rand = rng(seed);
407
+ for (let i = 0; i < 4000; i += 1) inputs.push(randomRequest(rand));
408
+ }
409
+ // One sweep feeds every property, so it runs once in `before` and each
410
+ // invariant reports its own failures rather than a merged count. A merged
411
+ // array would attribute a shape bug to "properties" and tell the next
412
+ // person nothing about where to look.
413
+ const rejected: string[] = [];
414
+ const shapeProblems: string[] = [];
415
+ const nondeterministic: string[] = [];
416
+ const notIdempotent: string[] = [];
417
+
418
+ async function sweep(): Promise<void> {
419
+ for (const input of inputs) {
420
+ deepFreeze(input);
421
+ let once: Json;
422
+ try {
423
+ once = prepareSystemOneArgs(input);
424
+ } catch {
425
+ rejected.push(JSON.stringify(input).slice(0, 160));
426
+ continue;
427
+ }
428
+ if (await reachesProvider(once)) {
429
+ const bad = shapeOk(captured);
430
+ if (bad) {
431
+ shapeProblems.push(
432
+ `REACHED PROVIDER: ${bad} :: ${JSON.stringify(captured).slice(0, 160)}`,
433
+ );
434
+ }
435
+ }
436
+ // 1. non-mutation + 3. determinism
437
+ try {
438
+ assert.deepEqual(prepareSystemOneArgs(input), once);
439
+ } catch {
440
+ nondeterministic.push(JSON.stringify(input).slice(0, 160));
441
+ continue;
442
+ }
443
+ // 2. idempotence
444
+ const twice = prepareSystemOneArgs(once);
445
+ try {
446
+ assert.deepEqual(twice, once);
447
+ } catch {
448
+ notIdempotent.push(
449
+ `in=${JSON.stringify(input).slice(0, 140)}\n once=${JSON.stringify(once).slice(0, 140)}\n twice=${JSON.stringify(twice).slice(0, 140)}`,
450
+ );
451
+ }
452
+ }
453
+ }
454
+
455
+ /** Assert a list of violations is empty, showing a bounded sample if not. */
456
+ function assertNoViolations(violations: string[], what: string): void {
457
+ if (violations.length === 0) return;
458
+ const shown = violations
459
+ .slice(0, 10)
460
+ .map((v) => `\n - ${v}`)
461
+ .join("");
462
+ const more =
463
+ violations.length > 10
464
+ ? `\n ... and ${violations.length - 10} more`
465
+ : "";
466
+ assert.fail(
467
+ `${violations.length} ${what} across ${inputs.length} generated shapes${shown}${more}`,
468
+ );
469
+ }
470
+
471
+ /*
472
+ * Property 6. A repair may reshape or discard a malformed entry, but it may
473
+ * not do so invisibly. The contract is one-directional: a batch cannot report
474
+ * on a question that was never sent, and a choice cannot pick an option that
475
+ * was omitted. So for every PRIMITIVE entry in a question MAP (a string
476
+ * sitting in a name slot is the observed shape) the name must either survive
477
+ * into the normalized output or be named in a drop note.
478
+ *
479
+ * Two exemptions. Records: a record-valued entry is spliced into the parent
480
+ * level or normalized in place, and a nested question map legitimately loses
481
+ * its outer name to the splice — only primitives can vanish. Arrays: the array
482
+ * branch names entries from their own name field or by position (q1, q2), so
483
+ * an index is not a stable identity to compare against.
484
+ */
485
+ function checkNoSilentDrop(): string[] {
486
+ const bad: string[] = [];
487
+ for (const input of inputs) {
488
+ if (!input || typeof input !== "object") continue;
489
+ const raw = (input as any).questions;
490
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
491
+ // The corpus is deep-frozen, and normalization legitimately refuses some
492
+ // frozen shapes outright; the main loop skips those the same way.
493
+ let out: any;
494
+ let notes: string;
495
+ try {
496
+ out = prepareSystemOneArgs(input);
497
+ notes = collectDiscardedQuestions(input).join(" | ");
498
+ } catch {
499
+ continue;
500
+ }
501
+ const normalized =
502
+ out && typeof out === "object" ? (out as any).questions : undefined;
503
+ for (const [name, value] of Object.entries(raw as object)) {
504
+ if (value !== null && typeof value === "object") continue; // records exempt
505
+ const survived =
506
+ normalized && typeof normalized === "object"
507
+ ? Object.hasOwn(normalized, name)
508
+ : false;
509
+ if (survived) continue;
510
+ if (notes.includes(name)) continue;
511
+ bad.push(
512
+ `SILENTLY DROPPED: ${JSON.stringify(name)} :: ${JSON.stringify(input).slice(0, 150)}`,
513
+ );
514
+ }
515
+ }
516
+ return bad;
517
+ }
518
+
519
+ describe("system_one normalization properties", () => {
520
+ before(async () => {
521
+ await sweep();
522
+ });
523
+
524
+ it("is idempotent: f(f(x)) deep-equals f(x)", () => {
525
+ assertNoViolations(notIdempotent, "non-idempotent shapes");
526
+ });
527
+
528
+ it("never mutates its input", () => {
529
+ // Same assertion as determinism below, stated separately because a
530
+ // mutation bug and an order-dependence bug have different causes.
531
+ assertNoViolations(nondeterministic, "non-deterministic shapes");
532
+ });
533
+
534
+ it("is deterministic: same input, same output, twice", () => {
535
+ assertNoViolations(nondeterministic, "non-deterministic shapes");
536
+ });
537
+
538
+ it("sends a provider-bound request with a clean question shape", () => {
539
+ assertNoViolations(shapeProblems, "malformed provider-bound requests");
540
+ });
541
+
542
+ it("names every score level in the rendered result", () => {
543
+ assertNoViolations(checkLegibility(), "rubrics with an unnamed level");
544
+ });
545
+
546
+ it("never discards a question without reporting it", () => {
547
+ assertNoViolations(
548
+ checkNoSilentDrop(),
549
+ "question-map entries dropped without a note",
550
+ );
551
+ });
552
+
553
+ it("exercises a corpus large enough to have found real defects", () => {
554
+ // A property that passes because it generated 12 shapes is not a
555
+ // property. This one found seven bugs at 34,600; keep the floor honest.
556
+ assert.ok(
557
+ inputs.length >= 30_000,
558
+ `corpus shrank to ${inputs.length}; the sweep has caught defects only at scale`,
559
+ );
560
+ assert.ok(
561
+ rejected.length > 0,
562
+ "no input was ever rejected, so the corpus is not reaching the real code",
563
+ );
564
+ });
565
+ });
@@ -28,6 +28,53 @@ describe("render", () => {
28
28
  assert.match(text, /probabilities:[\s\S]*coding: 0\.91/);
29
29
  assert.match(text, /complex:[\s\S]*noul: 0\.82/);
30
30
  assert.match(text, /difficulty:[\s\S]*score: 1\.7/);
31
+ // The legend is the whole point: on the index-slot convention a bare
32
+ // `0: 0.1` names no level, so it must render as `0 easy`.
33
+ assert.match(text, /0 easy: 0\.1/);
34
+ assert.match(text, /1 medium: 0\.2/);
35
+ assert.match(text, /2 hard: 0\.7/);
36
+ assert.match(text, /position on the 0\.\.2 rubric scale/);
37
+ });
38
+
39
+ it("does not duplicate a self-describing probability key", () => {
40
+ // The contract also allows keys to be the level values themselves, in
41
+ // which case the key already names the level and needs no legend.
42
+ const text = renderSystemOneResult({
43
+ answers: {
44
+ difficulty: {
45
+ type: "score",
46
+ score: 0.4,
47
+ probabilities: { easy: 0.6, hard: 0.4 },
48
+ legend: { easy: "easy", hard: "hard" },
49
+ confidence: 0.8,
50
+ },
51
+ },
52
+ } as any);
53
+ assert.match(text, /easy: 0\.6/);
54
+ assert.doesNotMatch(text, /easy easy/);
55
+ });
56
+
57
+ it("still renders a slot key when the legend has no entry for it", () => {
58
+ const text = renderSystemOneResult({
59
+ answers: {
60
+ difficulty: {
61
+ type: "score",
62
+ score: 1,
63
+ probabilities: { "0": 0.5, "1": 0.5 },
64
+ confidence: 0.5,
65
+ },
66
+ },
67
+ } as any);
68
+ assert.match(text, /0: 0\.5/);
69
+ });
70
+
71
+ it("states the reading rules once, for the whole result", () => {
72
+ const text = renderSystemOneResult({
73
+ answers: { a: { type: "noul", noul: 0.5 } },
74
+ } as any);
75
+ assert.match(text, /noul is P\(yes\)/);
76
+ assert.match(text, /choice always returns a winner/);
77
+ assert.match(text, /not permission to act/);
31
78
  });
32
79
  it("renders score probabilities and flags unknown types", () => {
33
80
  const text = renderSystemOneResult({