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.
- package/README.md +4 -1
- package/demo.gif +0 -0
- package/package.json +1 -1
- package/skills/system-one/SKILL.md +147 -9
- package/src/render.ts +70 -5
- package/src/tool.ts +620 -60
- package/tests/properties.test.ts +565 -0
- package/tests/render.test.ts +47 -0
- package/tests/tool.test.ts +1015 -53
|
@@ -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
|
+
});
|
package/tests/render.test.ts
CHANGED
|
@@ -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({
|