@aikdna/kdna-studio-core 1.3.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.
- package/LICENSE +202 -0
- package/README.md +168 -0
- package/package.json +27 -0
- package/schemas/studio.project.schema.json +194 -0
- package/src/cards/feynman.js +105 -0
- package/src/cards/index.js +114 -0
- package/src/cli-bridge/index.js +2 -0
- package/src/compile/index.js +511 -0
- package/src/evidence/index.js +81 -0
- package/src/governance/index.js +140 -0
- package/src/i18n/index.js +145 -0
- package/src/index.js +59 -0
- package/src/judgment-fields.js +28 -0
- package/src/packaging/index.js +88 -0
- package/src/pipeline.js +101 -0
- package/src/project/index.js +359 -0
- package/src/provenance/index.js +44 -0
- package/src/quality/contradiction.js +183 -0
- package/src/quality/index.js +161 -0
- package/src/quality/validate-cards.js +164 -0
- package/src/testlab/delta.js +193 -0
- package/src/testlab/index.js +116 -0
- package/src/versioning/index.js +155 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Studio Project lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Create, load, save, validate Studio Project manifests
|
|
6
|
+
* - Manage project-level state transitions
|
|
7
|
+
* - Schema validation against studio.project.schema.json
|
|
8
|
+
* - Human Lock gate enforcement on export
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
const projectSchema = require('../../schemas/studio.project.schema.json');
|
|
13
|
+
const { JUDGMENT_CARD_TYPES, cardJudgmentFingerprint } = require('../judgment-fields');
|
|
14
|
+
|
|
15
|
+
function createProject(name, type = 'domain', options = {}) {
|
|
16
|
+
const project = {
|
|
17
|
+
studio_version: '0.1.0',
|
|
18
|
+
project_id: `studio_${require('crypto').randomUUID()}`,
|
|
19
|
+
name,
|
|
20
|
+
type,
|
|
21
|
+
created: new Date().toISOString().slice(0, 10),
|
|
22
|
+
updated: new Date().toISOString().slice(0, 10),
|
|
23
|
+
author: options.author || { name: '', id: '' },
|
|
24
|
+
status: 'drafting',
|
|
25
|
+
cards: [],
|
|
26
|
+
evidence: [],
|
|
27
|
+
tests: [],
|
|
28
|
+
stages: {
|
|
29
|
+
evidence_room: { status: 'pending', evidence_count: 0 },
|
|
30
|
+
interview_room: { status: 'pending', questions_asked: 0 },
|
|
31
|
+
judgment_cards: { status: 'pending', locked: 0, total: 0 },
|
|
32
|
+
test_lab: { status: 'pending', evals_passed: 0, evals_total: 0 },
|
|
33
|
+
export: { status: 'pending' },
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
return project;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loadProject(json) {
|
|
40
|
+
let project;
|
|
41
|
+
try {
|
|
42
|
+
project = typeof json === 'string' ? JSON.parse(json) : json;
|
|
43
|
+
} catch (e) {
|
|
44
|
+
throw new Error('loadProject: invalid JSON input — ' + e.message);
|
|
45
|
+
}
|
|
46
|
+
if (!project || typeof project !== 'object' || Array.isArray(project)) {
|
|
47
|
+
throw new Error('loadProject: input is not a valid project object');
|
|
48
|
+
}
|
|
49
|
+
const result = validateProject(project);
|
|
50
|
+
if (!result.valid) {
|
|
51
|
+
throw new Error('loadProject: project validation failed:\n - ' + result.issues.join('\n - '));
|
|
52
|
+
}
|
|
53
|
+
return project;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function saveProject(project) {
|
|
57
|
+
project.updated = new Date().toISOString().slice(0, 10);
|
|
58
|
+
return JSON.stringify(project, null, 2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validateProject(project) {
|
|
62
|
+
const issues = [];
|
|
63
|
+
|
|
64
|
+
function check(cond, msg) { if (!cond) issues.push(msg); }
|
|
65
|
+
|
|
66
|
+
function checkType(val, expected, path) {
|
|
67
|
+
if (expected === 'string' && typeof val !== 'string') issues.push(path + ': expected string, got ' + typeof val);
|
|
68
|
+
else if (expected === 'number' && typeof val !== 'number') issues.push(path + ': expected number, got ' + typeof val);
|
|
69
|
+
else if (expected === 'integer' && (!Number.isInteger(val) || typeof val !== 'number')) issues.push(path + ': expected integer, got ' + typeof val);
|
|
70
|
+
else if (expected === 'boolean' && typeof val !== 'boolean') issues.push(path + ': expected boolean, got ' + typeof val);
|
|
71
|
+
else if (expected === 'object' && (typeof val !== 'object' || val === null || Array.isArray(val))) issues.push(path + ': expected object, got ' + (Array.isArray(val) ? 'array' : typeof val));
|
|
72
|
+
else if (expected === 'array' && !Array.isArray(val)) issues.push(path + ': expected array, got ' + typeof val);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Required top-level fields
|
|
76
|
+
if (!project || typeof project !== 'object' || Array.isArray(project)) {
|
|
77
|
+
return { valid: false, issues: ['project must be a non-null object'] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const required = projectSchema.required || [];
|
|
81
|
+
for (const field of required) {
|
|
82
|
+
if (!(field in project)) issues.push('Missing required field: ' + field);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// studio_version
|
|
86
|
+
if (project.studio_version !== undefined) {
|
|
87
|
+
checkType(project.studio_version, 'string', 'studio_version');
|
|
88
|
+
if (typeof project.studio_version === 'string') {
|
|
89
|
+
const verPattern = projectSchema.properties.studio_version.pattern;
|
|
90
|
+
if (verPattern && !new RegExp(verPattern).test(project.studio_version)) {
|
|
91
|
+
issues.push('studio_version: invalid version format, expected semver (e.g. 1.2.3)');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// name
|
|
97
|
+
if (project.name !== undefined) {
|
|
98
|
+
checkType(project.name, 'string', 'name');
|
|
99
|
+
if (typeof project.name === 'string' && project.name.length < 1) {
|
|
100
|
+
issues.push('name: must not be empty');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// type
|
|
105
|
+
if (project.type !== undefined) {
|
|
106
|
+
checkType(project.type, 'string', 'type');
|
|
107
|
+
if (typeof project.type === 'string' && !['domain', 'cluster'].includes(project.type)) {
|
|
108
|
+
issues.push('type: must be "domain" or "cluster", got "' + project.type + '"');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// status
|
|
113
|
+
if (project.status !== undefined) {
|
|
114
|
+
checkType(project.status, 'string', 'status');
|
|
115
|
+
if (typeof project.status === 'string') {
|
|
116
|
+
const validStatuses = ['drafting', 'cards_in_progress', 'ready_for_test', 'ready_for_release', 'released'];
|
|
117
|
+
if (!validStatuses.includes(project.status)) {
|
|
118
|
+
issues.push('status: must be one of ' + validStatuses.join(', ') + ', got "' + project.status + '"');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// project_id
|
|
124
|
+
if (project.project_id !== undefined) {
|
|
125
|
+
checkType(project.project_id, 'string', 'project_id');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// created / updated
|
|
129
|
+
if (project.created !== undefined) checkType(project.created, 'string', 'created');
|
|
130
|
+
if (project.updated !== undefined) checkType(project.updated, 'string', 'updated');
|
|
131
|
+
|
|
132
|
+
// author
|
|
133
|
+
if (project.author !== undefined) {
|
|
134
|
+
checkType(project.author, 'object', 'author');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// cards
|
|
138
|
+
if (project.cards !== undefined) {
|
|
139
|
+
checkType(project.cards, 'array', 'cards');
|
|
140
|
+
if (Array.isArray(project.cards)) {
|
|
141
|
+
for (let i = 0; i < project.cards.length; i++) {
|
|
142
|
+
const card = project.cards[i];
|
|
143
|
+
if (!card || typeof card !== 'object') {
|
|
144
|
+
issues.push('cards[' + i + ']: must be an object');
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
for (const req of ['id', 'type', 'status']) {
|
|
148
|
+
if (!(req in card)) issues.push('cards[' + i + ']: missing required field "' + req + '"');
|
|
149
|
+
}
|
|
150
|
+
if (card.type !== undefined) {
|
|
151
|
+
const validTypes = ['axiom', 'ontology', 'misunderstanding', 'boundary', 'self_check', 'risk', 'aesthetic', 'scenario', 'case'];
|
|
152
|
+
if (!validTypes.includes(card.type)) issues.push('cards[' + i + ']: invalid type "' + card.type + '"');
|
|
153
|
+
}
|
|
154
|
+
if (card.status !== undefined) {
|
|
155
|
+
const validStates = ['draft', 'revised', 'locked', 'tested', 'published', 'deprecated'];
|
|
156
|
+
if (!validStates.includes(card.status)) issues.push('cards[' + i + ']: invalid status "' + card.status + '"');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// evidence
|
|
163
|
+
if (project.evidence !== undefined) {
|
|
164
|
+
checkType(project.evidence, 'array', 'evidence');
|
|
165
|
+
if (Array.isArray(project.evidence)) {
|
|
166
|
+
for (let i = 0; i < project.evidence.length; i++) {
|
|
167
|
+
const ev = project.evidence[i];
|
|
168
|
+
if (!ev || typeof ev !== 'object') {
|
|
169
|
+
issues.push('evidence[' + i + ']: must be an object');
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
for (const req of ['id', 'type', 'title']) {
|
|
173
|
+
if (!(req in ev)) issues.push('evidence[' + i + ']: missing required field "' + req + '"');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// tests
|
|
180
|
+
if (project.tests !== undefined) {
|
|
181
|
+
checkType(project.tests, 'array', 'tests');
|
|
182
|
+
if (Array.isArray(project.tests)) {
|
|
183
|
+
for (let i = 0; i < project.tests.length; i++) {
|
|
184
|
+
const t = project.tests[i];
|
|
185
|
+
if (!t || typeof t !== 'object') {
|
|
186
|
+
issues.push('tests[' + i + ']: must be an object');
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
for (const req of ['id', 'input']) {
|
|
190
|
+
if (!(req in t)) issues.push('tests[' + i + ']: missing required field "' + req + '"');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// stages
|
|
197
|
+
if (project.stages !== undefined) {
|
|
198
|
+
checkType(project.stages, 'object', 'stages');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { valid: issues.length === 0, issues };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function upgradeProject(project, fromVersion, toVersion) {
|
|
205
|
+
const migrations = {
|
|
206
|
+
'0.1.0_to_0.2.0': function(p) {
|
|
207
|
+
if (!p.release) p.release = { version: toVersion };
|
|
208
|
+
return p;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const key = fromVersion + '_to_' + toVersion;
|
|
212
|
+
if (migrations[key]) {
|
|
213
|
+
project = migrations[key](project);
|
|
214
|
+
} else {
|
|
215
|
+
throw new Error('upgradeProject: no migration path from ' + fromVersion + ' to ' + toVersion);
|
|
216
|
+
}
|
|
217
|
+
project.studio_version = toVersion;
|
|
218
|
+
return project;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Human Lock Gate ────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Detect judgment-class cards that require Human Lock but don't have it,
|
|
225
|
+
* or have had their judgment fields changed since the last Human Lock.
|
|
226
|
+
*
|
|
227
|
+
* @returns {{ blocked: boolean, issues: Array<{cardId: string, type: string, reason: string}> }}
|
|
228
|
+
*/
|
|
229
|
+
function checkHumanLockGate(project) {
|
|
230
|
+
const issues = [];
|
|
231
|
+
const cards = project.cards || [];
|
|
232
|
+
|
|
233
|
+
for (const card of cards) {
|
|
234
|
+
if (!JUDGMENT_CARD_TYPES.has(card.type)) continue;
|
|
235
|
+
|
|
236
|
+
const cardId = card.id || 'unknown';
|
|
237
|
+
|
|
238
|
+
// Rule 1: Judgment-class cards must be locked before export
|
|
239
|
+
if (card.status !== 'locked' && card.status !== 'tested' && card.status !== 'published') {
|
|
240
|
+
issues.push({
|
|
241
|
+
cardId,
|
|
242
|
+
type: card.type,
|
|
243
|
+
reason: `judgment-class card "${cardId}" (${card.type}) is not locked. Human Lock required before export.`
|
|
244
|
+
});
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Rule 2: Locked cards must have a Human Lock record
|
|
249
|
+
if (!card.human_lock || !card.human_lock.by || !card.human_lock.statement) {
|
|
250
|
+
issues.push({
|
|
251
|
+
cardId,
|
|
252
|
+
type: card.type,
|
|
253
|
+
reason: `locked card "${cardId}" (${card.type}) has no valid Human Lock record (missing by/statement).`
|
|
254
|
+
});
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Rule 3: Lock must confirm judgment-class fields were reviewed
|
|
259
|
+
const checked = card.human_lock.checked || {};
|
|
260
|
+
if (!checked.applies_when) {
|
|
261
|
+
issues.push({
|
|
262
|
+
cardId,
|
|
263
|
+
type: card.type,
|
|
264
|
+
reason: `card "${cardId}" Human Lock does not confirm applies_when was reviewed.`
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
if (!checked.does_not_apply_when) {
|
|
268
|
+
issues.push({
|
|
269
|
+
cardId,
|
|
270
|
+
type: card.type,
|
|
271
|
+
reason: `card "${cardId}" Human Lock does not confirm does_not_apply_when was reviewed.`
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (!checked.failure_risk) {
|
|
275
|
+
issues.push({
|
|
276
|
+
cardId,
|
|
277
|
+
type: card.type,
|
|
278
|
+
reason: `card "${cardId}" Human Lock does not confirm failure_risk was reviewed.`
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Rule 4: Judgment fields must not have changed since lock
|
|
283
|
+
if (card.human_lock.judgment_fingerprint) {
|
|
284
|
+
const currentFingerprint = cardJudgmentFingerprint(card);
|
|
285
|
+
if (currentFingerprint !== card.human_lock.judgment_fingerprint) {
|
|
286
|
+
issues.push({
|
|
287
|
+
cardId,
|
|
288
|
+
type: card.type,
|
|
289
|
+
reason: `card "${cardId}" judgment fields changed after Human Lock — re-lock required.`
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Rule 4: At least one locked judgment-class card must exist for a domain project
|
|
296
|
+
const lockedJudgmentCards = cards.filter(c =>
|
|
297
|
+
JUDGMENT_CARD_TYPES.has(c.type) &&
|
|
298
|
+
['locked', 'tested', 'published'].includes(c.status)
|
|
299
|
+
);
|
|
300
|
+
if (lockedJudgmentCards.length === 0 && cards.some(c => JUDGMENT_CARD_TYPES.has(c.type))) {
|
|
301
|
+
issues.push({
|
|
302
|
+
cardId: '(project)',
|
|
303
|
+
type: 'project',
|
|
304
|
+
reason: 'No judgment-class cards are locked. At least one axiom, boundary, or risk card must be Human Locked before export.'
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
blocked: issues.length > 0,
|
|
310
|
+
issues,
|
|
311
|
+
lockedJudgmentCards: lockedJudgmentCards.length,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Export a project for release, with Human Lock gate enforcement.
|
|
317
|
+
* Blocks if any judgment-class cards are not properly locked.
|
|
318
|
+
*
|
|
319
|
+
* @throws {Error} if Human Lock gate blocks export
|
|
320
|
+
* @returns {string} JSON string of the project
|
|
321
|
+
*/
|
|
322
|
+
function exportProject(project, options = {}) {
|
|
323
|
+
const gate = checkHumanLockGate(project);
|
|
324
|
+
|
|
325
|
+
if (gate.blocked && !options.force) {
|
|
326
|
+
const lines = ['Human Lock Gate blocked export:', ''];
|
|
327
|
+
for (const issue of gate.issues) {
|
|
328
|
+
lines.push(` ✗ ${issue.cardId}: ${issue.reason}`);
|
|
329
|
+
}
|
|
330
|
+
lines.push('');
|
|
331
|
+
lines.push(` Locked judgment cards: ${gate.lockedJudgmentCards}`);
|
|
332
|
+
lines.push(' To override (emergency only): pass { force: true }');
|
|
333
|
+
throw new Error(lines.join('\n'));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (gate.blocked && options.force) {
|
|
337
|
+
// Emergency override: recorded but not blocked
|
|
338
|
+
project._human_lock_override = {
|
|
339
|
+
overridden_at: new Date().toISOString(),
|
|
340
|
+
reason: options.forceReason || 'Emergency override (no reason provided)',
|
|
341
|
+
blocked_issues: gate.issues.map(i => ({ cardId: i.cardId, reason: i.reason })),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
project.updated = new Date().toISOString().slice(0, 10);
|
|
346
|
+
|
|
347
|
+
// Update release metadata
|
|
348
|
+
if (!project.release) project.release = {};
|
|
349
|
+
project.release.exported_at = new Date().toISOString();
|
|
350
|
+
project.release.locked_judgment_cards = gate.lockedJudgmentCards;
|
|
351
|
+
project.release.human_lock_gate_passed = !gate.blocked || !!options.force;
|
|
352
|
+
|
|
353
|
+
return JSON.stringify(project, null, 2);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = {
|
|
357
|
+
createProject, loadProject, saveProject, validateProject, upgradeProject,
|
|
358
|
+
exportProject, checkHumanLockGate, JUDGMENT_CARD_TYPES
|
|
359
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provenance tracking — build metadata and content fingerprinting.
|
|
3
|
+
*
|
|
4
|
+
* Every compiled KDNA domain carries provenance proving:
|
|
5
|
+
* - Which Studio Core version created it
|
|
6
|
+
* - Which project it came from
|
|
7
|
+
* - Who authored the locked cards
|
|
8
|
+
* - Content tree fingerprint
|
|
9
|
+
*/
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
|
|
12
|
+
function buildProvenance(project, compiledFiles, identity = {}) {
|
|
13
|
+
const lockedCards = (project.cards || []).filter(c => c.locked);
|
|
14
|
+
const tests = project.tests || [];
|
|
15
|
+
|
|
16
|
+
// Content fingerprint: hash of all locked card content
|
|
17
|
+
const cardHashes = lockedCards
|
|
18
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
19
|
+
.map(c => `${c.id}:${c.fields?.one_sentence || c.fields?.concept || ''}`);
|
|
20
|
+
const contentFingerprint = crypto.createHash('sha256').update(cardHashes.join('\n')).digest('hex');
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
studio_core: 'aikdna/kdna-studio-core',
|
|
24
|
+
studio_core_version: project.studio_version || require('../../package.json').version,
|
|
25
|
+
created_by: 'kdna-studio-sdk',
|
|
26
|
+
compiler: '@aikdna/kdna-studio-core',
|
|
27
|
+
compiler_version: project.studio_version || require('../../package.json').version,
|
|
28
|
+
build_id: identity.build_id || `build_${crypto.randomUUID()}`,
|
|
29
|
+
project_id: project.project_id,
|
|
30
|
+
project_uid: identity.project_uid || project.project_uid || project.project_id || null,
|
|
31
|
+
asset_uid: identity.asset_uid || null,
|
|
32
|
+
domain_id: identity.domain_id || null,
|
|
33
|
+
registry_name: identity.registry_name || project.name || null,
|
|
34
|
+
author_id: project.author?.id || '',
|
|
35
|
+
locked_card_count: lockedCards.length,
|
|
36
|
+
test_case_count: tests.length,
|
|
37
|
+
built_at: identity.compiled_at || new Date().toISOString(),
|
|
38
|
+
compiled_at: identity.compiled_at || new Date().toISOString(),
|
|
39
|
+
content_fingerprint: identity.content_digest || `sha256:${contentFingerprint}`,
|
|
40
|
+
content_digest: identity.content_digest || `sha256:${contentFingerprint}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { buildProvenance };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contradiction Check — Surface conflicts, gaps, and weak judgment.
|
|
3
|
+
*
|
|
4
|
+
* Detects:
|
|
5
|
+
* - Axiom contradictions (two axioms that cannot both be true)
|
|
6
|
+
* - Missing boundaries (axiom lacks does_not_apply_when)
|
|
7
|
+
* - Weak self-checks (not a yes/no question, too vague)
|
|
8
|
+
* - Over-generalized axioms (too broad to be testable)
|
|
9
|
+
* - Straw-man misunderstandings (describes something no one believes)
|
|
10
|
+
* - Missing counterexamples (misunderstanding lacks a real example)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function tokenize(text) {
|
|
14
|
+
if (!text) return [];
|
|
15
|
+
try {
|
|
16
|
+
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
|
17
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
|
18
|
+
return [...segmenter.segment(text)].filter(s => s.isWordLike && s.segment.length > 3).map(s => s.segment);
|
|
19
|
+
}
|
|
20
|
+
} catch { /* fallback */ }
|
|
21
|
+
return text.split(/\s+/).filter(w => w.length > 3);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function detectContradictions(cards) {
|
|
25
|
+
const issues = [];
|
|
26
|
+
const axioms = cards.filter(c => c.type === 'axiom' && c.locked);
|
|
27
|
+
|
|
28
|
+
// Check for missing boundaries on locked axioms
|
|
29
|
+
for (const ax of axioms) {
|
|
30
|
+
if (!ax.fields?.does_not_apply_when || ax.fields.does_not_apply_when.length === 0) {
|
|
31
|
+
issues.push({
|
|
32
|
+
type: 'missing_boundary',
|
|
33
|
+
card_id: ax.id,
|
|
34
|
+
severity: 'blocking',
|
|
35
|
+
message: `${ax.id}: locked axiom lacks does_not_apply_when`,
|
|
36
|
+
fix: 'Define at least one situation where this axiom should NOT be applied.',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (!ax.fields?.applies_when || ax.fields.applies_when.length === 0) {
|
|
40
|
+
issues.push({
|
|
41
|
+
type: 'missing_applicability',
|
|
42
|
+
card_id: ax.id,
|
|
43
|
+
severity: 'blocking',
|
|
44
|
+
message: `${ax.id}: locked axiom lacks applies_when`,
|
|
45
|
+
fix: 'Define at least one situation where this axiom applies.',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (ax.fields?.full_statement && ax.fields.full_statement.length < 30) {
|
|
49
|
+
issues.push({
|
|
50
|
+
type: 'too_short',
|
|
51
|
+
card_id: ax.id,
|
|
52
|
+
severity: 'warning',
|
|
53
|
+
message: `${ax.id}: full_statement is very short — may be too vague`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check for over-generalization: axioms that use absolute language without boundaries
|
|
58
|
+
const oneLiner = (ax.fields?.one_sentence || '').toLowerCase();
|
|
59
|
+
if (/\b(always|never|every|all|none|must)\b/.test(oneLiner) && (!ax.fields.does_not_apply_when || ax.fields.does_not_apply_when.length === 0)) {
|
|
60
|
+
issues.push({
|
|
61
|
+
type: 'overgeneralized',
|
|
62
|
+
card_id: ax.id,
|
|
63
|
+
severity: 'warning',
|
|
64
|
+
message: `${ax.id}: uses absolute language ("${oneLiner.match(/always|never|every|all|none|must/)[0]}") but has no does_not_apply_when`,
|
|
65
|
+
fix: 'Add does_not_apply_when to prevent this axiom from being applied universally.',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check misunderstandings
|
|
71
|
+
const misunderstandings = cards.filter(c => c.type === 'misunderstanding' && c.locked);
|
|
72
|
+
for (const ms of misunderstandings) {
|
|
73
|
+
const wrong = ms.fields?.wrong || '';
|
|
74
|
+
const correct = ms.fields?.correct || '';
|
|
75
|
+
|
|
76
|
+
// Check for straw-man: the "wrong" belief describes something no real person would believe
|
|
77
|
+
if (wrong.length < 15) {
|
|
78
|
+
issues.push({
|
|
79
|
+
type: 'vague_misunderstanding',
|
|
80
|
+
card_id: ms.id,
|
|
81
|
+
severity: 'warning',
|
|
82
|
+
message: `${ms.id}: wrong belief description is very short — may not describe a real mistake`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check the wrong and correct are actually different (not just negation)
|
|
87
|
+
const wrongWords = new Set(tokenize(wrong.toLowerCase()));
|
|
88
|
+
const correctWords = tokenize(correct.toLowerCase());
|
|
89
|
+
const sharedWords = correctWords.filter(w => wrongWords.has(w)).length;
|
|
90
|
+
if (correctWords.length > 0 && sharedWords / correctWords.length > 0.7) {
|
|
91
|
+
issues.push({
|
|
92
|
+
type: 'weak_distinction',
|
|
93
|
+
card_id: ms.id,
|
|
94
|
+
severity: 'warning',
|
|
95
|
+
message: `${ms.id}: wrong and correct share ${Math.round(sharedWords / correctWords.length * 100)}% of words — distinction may be too weak`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Key distinction check
|
|
100
|
+
if (!ms.fields?.key_distinction || ms.fields.key_distinction.length < 20) {
|
|
101
|
+
issues.push({
|
|
102
|
+
type: 'missing_distinction',
|
|
103
|
+
card_id: ms.id,
|
|
104
|
+
severity: 'blocking',
|
|
105
|
+
message: `${ms.id}: key_distinction missing or too short`,
|
|
106
|
+
fix: 'Explain the conceptual boundary between the wrong belief and the correct one.',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check self-checks
|
|
112
|
+
const selfChecks = cards.filter(c => c.type === 'self_check' && c.locked);
|
|
113
|
+
for (const sc of selfChecks) {
|
|
114
|
+
const question = sc.fields?.question || '';
|
|
115
|
+
if (!question.trim().endsWith('?')) {
|
|
116
|
+
issues.push({
|
|
117
|
+
type: 'not_a_question',
|
|
118
|
+
card_id: sc.id,
|
|
119
|
+
severity: 'blocking',
|
|
120
|
+
message: `${sc.id}: self_check must be phrased as a question ending with ?`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (question.length < 15) {
|
|
124
|
+
issues.push({
|
|
125
|
+
type: 'vague_check',
|
|
126
|
+
card_id: sc.id,
|
|
127
|
+
severity: 'warning',
|
|
128
|
+
message: `${sc.id}: self_check question is very short — may be too vague to verify`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// Check for generic self-checks
|
|
132
|
+
const genericPatterns = ['is this good', 'is this correct', 'is this helpful', 'is this clear', 'is this response', 'is the response', 'good enough', 'is it good'];
|
|
133
|
+
if (genericPatterns.some(p => question.toLowerCase().includes(p))) {
|
|
134
|
+
issues.push({
|
|
135
|
+
type: 'generic_check',
|
|
136
|
+
card_id: sc.id,
|
|
137
|
+
severity: 'warning',
|
|
138
|
+
message: `${sc.id}: self_check is too generic — should be domain-specific`,
|
|
139
|
+
fix: 'Rephrase with domain-specific criteria, e.g. "Did the agent diagnose the type of uncertainty before suggesting action?"',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Check boundaries
|
|
145
|
+
const boundaries = cards.filter(c => c.type === 'boundary' && c.locked);
|
|
146
|
+
for (const bd of boundaries) {
|
|
147
|
+
if (bd.fields?.out_of_scope && bd.fields.out_of_scope.length < 10) {
|
|
148
|
+
issues.push({
|
|
149
|
+
type: 'vague_boundary',
|
|
150
|
+
card_id: bd.id,
|
|
151
|
+
severity: 'warning',
|
|
152
|
+
message: `${bd.id}: out_of_scope is very short — boundary may be unclear`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (!bd.fields?.acceptable_exceptions || bd.fields.acceptable_exceptions.length === 0) {
|
|
156
|
+
issues.push({
|
|
157
|
+
type: 'no_exceptions',
|
|
158
|
+
card_id: bd.id,
|
|
159
|
+
severity: 'warning',
|
|
160
|
+
message: `${bd.id}: no acceptable_exceptions declared — every boundary has justified exceptions`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return issues;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function summarizeContradictions(issues) {
|
|
169
|
+
const blocking = issues.filter(i => i.severity === 'blocking');
|
|
170
|
+
const warnings = issues.filter(i => i.severity === 'warning');
|
|
171
|
+
return {
|
|
172
|
+
total: issues.length,
|
|
173
|
+
blocking: blocking.length,
|
|
174
|
+
warnings: warnings.length,
|
|
175
|
+
clean: issues.length === 0,
|
|
176
|
+
by_type: issues.reduce((acc, i) => {
|
|
177
|
+
acc[i.type] = (acc[i.type] || 0) + 1;
|
|
178
|
+
return acc;
|
|
179
|
+
}, {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { detectContradictions, summarizeContradictions };
|