@forwardimpact/libsyntheticrender 0.1.2 → 0.1.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/index.js +1 -0
- package/package.json +1 -1
- package/render/enricher.js +6 -0
- package/render/html.js +55 -1
- package/render/link-assigner.js +80 -8
- package/render/raw.js +2 -1
- package/test/enricher.test.js +64 -0
- package/test/industry-data.test.js +85 -0
- package/test/link-assigner.test.js +163 -0
- package/test/validate-eval.test.js +95 -0
- package/test/validate.test.js +1 -1
- package/validate-eval.js +65 -0
- package/validate.js +183 -26
package/index.js
CHANGED
|
@@ -5,3 +5,4 @@ export { generateDrugs, generatePlatforms } from "./render/industry-data.js";
|
|
|
5
5
|
export { assignLinks } from "./render/link-assigner.js";
|
|
6
6
|
export { validateLinks, validateHTML } from "./render/validate-links.js";
|
|
7
7
|
export { renderDataset } from "./render/dataset-renderers.js";
|
|
8
|
+
export { validateEvalReferences } from "./validate-eval.js";
|
package/package.json
CHANGED
package/render/enricher.js
CHANGED
|
@@ -44,10 +44,16 @@ function buildEnrichContext(enrichKey, linked) {
|
|
|
44
44
|
.slice(0, 3)
|
|
45
45
|
.map((m) => ({ type: "Person", name: m.name, iri: m.iri })),
|
|
46
46
|
];
|
|
47
|
+
const narrative = {};
|
|
48
|
+
if (proj.milestones?.length) narrative.milestones = proj.milestones;
|
|
49
|
+
if (proj.risks?.length) narrative.risks = proj.risks;
|
|
50
|
+
if (proj.technical_choices?.length)
|
|
51
|
+
narrative.technical_choices = proj.technical_choices;
|
|
47
52
|
return {
|
|
48
53
|
entityType: "Project",
|
|
49
54
|
entityName: proj.name,
|
|
50
55
|
mentionTargets: mentions,
|
|
56
|
+
...(Object.keys(narrative).length > 0 && { narrative }),
|
|
51
57
|
};
|
|
52
58
|
}
|
|
53
59
|
case "platform": {
|
package/render/html.js
CHANGED
|
@@ -9,6 +9,44 @@
|
|
|
9
9
|
import { generateDrugs, generatePlatforms } from "./industry-data.js";
|
|
10
10
|
import { assignLinks } from "./link-assigner.js";
|
|
11
11
|
|
|
12
|
+
const FAQ_QUESTIONS = [
|
|
13
|
+
"What is pharmaceutical engineering and what does it involve?",
|
|
14
|
+
"How does computational chemistry accelerate drug discovery?",
|
|
15
|
+
"What are the key phases of clinical trial development?",
|
|
16
|
+
"How does GMP compliance affect manufacturing processes?",
|
|
17
|
+
"What role does data science play in pharmaceutical R&D?",
|
|
18
|
+
"How are biomarkers used in drug development?",
|
|
19
|
+
"What is the drug approval process and how long does it take?",
|
|
20
|
+
"How does continuous manufacturing differ from batch processing?",
|
|
21
|
+
"What are the main challenges in scaling up drug production?",
|
|
22
|
+
"How do platform engineering teams support drug discovery?",
|
|
23
|
+
"What regulatory frameworks govern pharmaceutical development?",
|
|
24
|
+
"How is AI being used in drug candidate screening?",
|
|
25
|
+
"What quality control measures ensure drug safety?",
|
|
26
|
+
"How does real-world evidence complement clinical trials?",
|
|
27
|
+
"What are the key considerations for biologics manufacturing?",
|
|
28
|
+
"How do cross-functional teams collaborate in drug development?",
|
|
29
|
+
"What is the role of process analytical technology in manufacturing?",
|
|
30
|
+
"How are digital twins used in pharmaceutical engineering?",
|
|
31
|
+
"What sustainability practices are used in drug manufacturing?",
|
|
32
|
+
"How does pharmacovigilance work after drug approval?",
|
|
33
|
+
"What are the differences between small molecule and biologic drugs?",
|
|
34
|
+
"How do adaptive trial designs improve clinical development?",
|
|
35
|
+
"What is the role of observability in manufacturing systems?",
|
|
36
|
+
"How are cloud platforms used in pharmaceutical data management?",
|
|
37
|
+
"What are the key challenges in supply chain management for pharma?",
|
|
38
|
+
"How does formulation science affect drug delivery?",
|
|
39
|
+
"What are the best practices for laboratory data management?",
|
|
40
|
+
"How do engineering teams handle regulatory submission preparation?",
|
|
41
|
+
"What is the role of DevOps in pharmaceutical software systems?",
|
|
42
|
+
"How are patient-reported outcomes used in clinical development?",
|
|
43
|
+
"What are the principles of quality by design in drug manufacturing?",
|
|
44
|
+
"How does risk management apply to pharmaceutical engineering?",
|
|
45
|
+
"What emerging technologies are transforming drug development?",
|
|
46
|
+
"How do companies manage intellectual property in pharma R&D?",
|
|
47
|
+
"What role does environmental monitoring play in GMP facilities?",
|
|
48
|
+
];
|
|
49
|
+
|
|
12
50
|
/** Wrap inner HTML in the page shell. */
|
|
13
51
|
function page(templates, title, body, domain) {
|
|
14
52
|
return templates.render("page.html", {
|
|
@@ -45,6 +83,18 @@ export function renderHTML(entities, prose, templates) {
|
|
|
45
83
|
const blogCount = gc?.blogs || 0;
|
|
46
84
|
const articleTopics = gc?.article_topics || [];
|
|
47
85
|
|
|
86
|
+
// Extract org name and date range from entities
|
|
87
|
+
const orgName = entities.orgs?.[0]?.name || domain;
|
|
88
|
+
const allDates = (entities.scenarios || [])
|
|
89
|
+
.flatMap((s) => (s.snapshots || []).map((snap) => snap.date))
|
|
90
|
+
.sort();
|
|
91
|
+
const startYear = allDates.length
|
|
92
|
+
? new Date(allDates[0]).getFullYear()
|
|
93
|
+
: null;
|
|
94
|
+
const endYear = allDates.length
|
|
95
|
+
? new Date(allDates.at(-1)).getFullYear()
|
|
96
|
+
: null;
|
|
97
|
+
|
|
48
98
|
// Assign cross-links deterministically
|
|
49
99
|
const linked = assignLinks({
|
|
50
100
|
drugs,
|
|
@@ -59,6 +109,10 @@ export function renderHTML(entities, prose, templates) {
|
|
|
59
109
|
blogCount,
|
|
60
110
|
articleTopics,
|
|
61
111
|
seed: entities.activity?.seed || 42,
|
|
112
|
+
orgName,
|
|
113
|
+
startYear,
|
|
114
|
+
endYear,
|
|
115
|
+
blogTopics: gc?.blog_topics || null,
|
|
62
116
|
});
|
|
63
117
|
|
|
64
118
|
// Enrich platform and drug entities with reverse links
|
|
@@ -227,7 +281,7 @@ export function renderHTML(entities, prose, templates) {
|
|
|
227
281
|
].filter(Boolean);
|
|
228
282
|
return {
|
|
229
283
|
iri: `https://${domain}/id/faq/faq-${i + 1}`,
|
|
230
|
-
question:
|
|
284
|
+
question: FAQ_QUESTIONS[i % FAQ_QUESTIONS.length],
|
|
231
285
|
answer: prose.get(`faq_${i}`) || `Answer to FAQ ${i + 1}.`,
|
|
232
286
|
aboutLinks,
|
|
233
287
|
};
|
package/render/link-assigner.js
CHANGED
|
@@ -50,9 +50,16 @@ export function assignLinks({
|
|
|
50
50
|
blogCount,
|
|
51
51
|
articleTopics = [],
|
|
52
52
|
seed = 42,
|
|
53
|
+
orgName = null,
|
|
54
|
+
startYear = null,
|
|
55
|
+
endYear = null,
|
|
56
|
+
blogTopics = null,
|
|
53
57
|
}) {
|
|
54
58
|
const rng = createSeededRNG(seed + 1000);
|
|
55
59
|
const base = `https://${domain}`;
|
|
60
|
+
const effectiveOrgName = orgName || domain;
|
|
61
|
+
const effectiveStartYear = startYear || new Date().getFullYear();
|
|
62
|
+
const yearSpan = endYear && startYear ? endYear - startYear + 1 : 1;
|
|
56
63
|
|
|
57
64
|
// --- Project linking ---
|
|
58
65
|
const linkedProjects = projects.map((proj) => {
|
|
@@ -189,9 +196,9 @@ export function assignLinks({
|
|
|
189
196
|
platformLink,
|
|
190
197
|
drugLink,
|
|
191
198
|
attendees,
|
|
192
|
-
date:
|
|
193
|
-
orgName:
|
|
194
|
-
orgIri: `${base}/org/headquarters`,
|
|
199
|
+
date: `${effectiveStartYear + (i % yearSpan)}-${String((i % 12) + 1).padStart(2, "0")}-01`,
|
|
200
|
+
orgName: effectiveOrgName,
|
|
201
|
+
orgIri: `${base}/id/org/headquarters`,
|
|
195
202
|
};
|
|
196
203
|
},
|
|
197
204
|
);
|
|
@@ -228,7 +235,7 @@ export function assignLinks({
|
|
|
228
235
|
aboutProjects,
|
|
229
236
|
aboutDrugs,
|
|
230
237
|
aboutPlatforms,
|
|
231
|
-
date:
|
|
238
|
+
date: `${effectiveStartYear + (i % yearSpan)}-${String((i % 12) + 1).padStart(2, "0")}-15`,
|
|
232
239
|
location: "Cambridge, MA",
|
|
233
240
|
eventStatus: "EventScheduled",
|
|
234
241
|
};
|
|
@@ -253,6 +260,71 @@ export function assignLinks({
|
|
|
253
260
|
"Open Source in Pharmaceutical R&D",
|
|
254
261
|
];
|
|
255
262
|
|
|
263
|
+
// Weighted topic selection from DSL blog_topics or fallback to BLOG_TOPICS
|
|
264
|
+
const weightedTopicEntries = blogTopics
|
|
265
|
+
? Object.entries(blogTopics).map(([name, weight]) => ({ name, weight }))
|
|
266
|
+
: null;
|
|
267
|
+
|
|
268
|
+
// Title templates per topic — cycle through for variety
|
|
269
|
+
const TOPIC_TITLES = {
|
|
270
|
+
drug_discovery: [
|
|
271
|
+
"Advances in AI-Driven Drug Discovery",
|
|
272
|
+
"Accelerating Lead Optimization with Computational Methods",
|
|
273
|
+
"From Target Identification to Clinical Candidate",
|
|
274
|
+
"Novel Approaches to Drug Screening and Design",
|
|
275
|
+
"Machine Learning in Molecular Discovery",
|
|
276
|
+
],
|
|
277
|
+
platform_engineering: [
|
|
278
|
+
"Building Scalable Platform Infrastructure",
|
|
279
|
+
"Platform Engineering at Scale",
|
|
280
|
+
"Cloud-Native Architecture for Life Sciences",
|
|
281
|
+
"Developer Experience and Platform Tooling",
|
|
282
|
+
"Infrastructure as Code in Practice",
|
|
283
|
+
],
|
|
284
|
+
clinical_development: [
|
|
285
|
+
"Navigating Complex Clinical Trial Design",
|
|
286
|
+
"Real-World Evidence in Clinical Development",
|
|
287
|
+
"Adaptive Trial Strategies for Modern Therapeutics",
|
|
288
|
+
"Biomarker-Driven Clinical Programs",
|
|
289
|
+
],
|
|
290
|
+
data_science: [
|
|
291
|
+
"Data Science in Pharmaceutical R&D",
|
|
292
|
+
"Building Robust Data Pipelines for Drug Development",
|
|
293
|
+
"Machine Learning Models for Bioprocess Optimization",
|
|
294
|
+
"Data Mesh Architecture for Pharma",
|
|
295
|
+
],
|
|
296
|
+
engineering_culture: [
|
|
297
|
+
"Building a Culture of Engineering Excellence",
|
|
298
|
+
"Cross-Functional Collaboration in Drug Development",
|
|
299
|
+
"Developer Experience: Lessons Learned",
|
|
300
|
+
"Mentoring and Growth in Engineering Teams",
|
|
301
|
+
],
|
|
302
|
+
};
|
|
303
|
+
const topicCounters = {};
|
|
304
|
+
|
|
305
|
+
function selectBlogTopic(index) {
|
|
306
|
+
if (!weightedTopicEntries) return BLOG_TOPICS[index % BLOG_TOPICS.length];
|
|
307
|
+
const total = weightedTopicEntries.reduce((s, t) => s + t.weight, 0);
|
|
308
|
+
let r = rng.random() * total;
|
|
309
|
+
let selectedKey = weightedTopicEntries.at(-1).name;
|
|
310
|
+
for (const t of weightedTopicEntries) {
|
|
311
|
+
r -= t.weight;
|
|
312
|
+
if (r <= 0) {
|
|
313
|
+
selectedKey = t.name;
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Map topic key to a human-readable title
|
|
318
|
+
const titles = TOPIC_TITLES[selectedKey];
|
|
319
|
+
if (!titles)
|
|
320
|
+
return selectedKey
|
|
321
|
+
.replace(/_/g, " ")
|
|
322
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
323
|
+
const count = topicCounters[selectedKey] || 0;
|
|
324
|
+
topicCounters[selectedKey] = count + 1;
|
|
325
|
+
return titles[count % titles.length];
|
|
326
|
+
}
|
|
327
|
+
|
|
256
328
|
const linkedBlogPosts = Array.from({ length: blogCount }, (_, i) => {
|
|
257
329
|
const author = rng.pick(people);
|
|
258
330
|
const aboutDrugs = rng.shuffle(drugs).slice(0, rng.randomInt(1, 3));
|
|
@@ -271,16 +343,16 @@ export function assignLinks({
|
|
|
271
343
|
|
|
272
344
|
return {
|
|
273
345
|
index: i + 1,
|
|
274
|
-
headline:
|
|
346
|
+
headline: selectBlogTopic(i),
|
|
275
347
|
iri: `${base}/id/blog/blog-${i + 1}`,
|
|
276
|
-
identifier: `BLOG
|
|
348
|
+
identifier: `BLOG-${effectiveStartYear}-${String(i + 1).padStart(3, "0")}`,
|
|
277
349
|
author,
|
|
278
350
|
aboutDrugs,
|
|
279
351
|
aboutPlatforms,
|
|
280
352
|
aboutProjects,
|
|
281
353
|
mentionsPeople,
|
|
282
354
|
keywords: keywords.join(", "),
|
|
283
|
-
date:
|
|
355
|
+
date: `${effectiveStartYear + (Math.floor(i / 24) % yearSpan)}-${String((Math.floor(i / 2) % 12) + 1).padStart(2, "0")}-${String(10 + (i % 20)).padStart(2, "0")}`,
|
|
284
356
|
};
|
|
285
357
|
});
|
|
286
358
|
|
|
@@ -334,7 +406,7 @@ export function assignLinks({
|
|
|
334
406
|
...drugLinks.map((d) => d.name),
|
|
335
407
|
...platformLinks.map((p) => p.name),
|
|
336
408
|
].join(", "),
|
|
337
|
-
date:
|
|
409
|
+
date: `${effectiveStartYear + (i % yearSpan)}-${String((i % 12) + 1).padStart(2, "0")}-01`,
|
|
338
410
|
};
|
|
339
411
|
});
|
|
340
412
|
|
package/render/raw.js
CHANGED
|
@@ -429,6 +429,7 @@ function renderRoster(entities) {
|
|
|
429
429
|
level: person.level,
|
|
430
430
|
hire_date: person.hire_date,
|
|
431
431
|
is_manager: person.is_manager || false,
|
|
432
|
+
archetype: person.archetype || "steady_contributor",
|
|
432
433
|
}));
|
|
433
434
|
|
|
434
435
|
return YAML.stringify({ roster }, { lineWidth: 120 });
|
|
@@ -452,7 +453,7 @@ function renderTeams(entities) {
|
|
|
452
453
|
department_name: dept?.name || "",
|
|
453
454
|
manager: manager?.name || "",
|
|
454
455
|
manager_email: manager?.email || "",
|
|
455
|
-
size:
|
|
456
|
+
size: members.length,
|
|
456
457
|
members: members.map((m) => ({
|
|
457
458
|
name: m.name,
|
|
458
459
|
email: m.email,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildEntities } from "@forwardimpact/libsyntheticgen/engine/entities";
|
|
4
|
+
import { createSeededRNG } from "@forwardimpact/libsyntheticgen/rng";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tests that entity IRIs from entities.js now consistently use /id/{type}/{id}
|
|
8
|
+
* format, which the enricher's stripOffDomainIris preserves.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
describe("enricher IRI compatibility", () => {
|
|
12
|
+
const minimalAst = {
|
|
13
|
+
domain: "test.example",
|
|
14
|
+
orgs: [{ id: "testorg", name: "TestOrg" }],
|
|
15
|
+
departments: [{ id: "eng", name: "Engineering", org: "testorg" }],
|
|
16
|
+
teams: [{ id: "team1", department: "eng", repos: [] }],
|
|
17
|
+
people: { count: 2, distribution: { L3: 1 }, disciplines: { se: 1 } },
|
|
18
|
+
projects: [
|
|
19
|
+
{
|
|
20
|
+
id: "proj1",
|
|
21
|
+
type: "drug",
|
|
22
|
+
teams: ["team1"],
|
|
23
|
+
prose_topic: "test",
|
|
24
|
+
prose_tone: "formal",
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
test("all entity IRIs contain /id/ prefix", () => {
|
|
30
|
+
const rng = createSeededRNG(42);
|
|
31
|
+
const entities = buildEntities(minimalAst, rng);
|
|
32
|
+
|
|
33
|
+
for (const org of entities.orgs) {
|
|
34
|
+
assert.ok(
|
|
35
|
+
org.iri.includes("/id/org/"),
|
|
36
|
+
`Org IRI missing /id/: ${org.iri}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
for (const dept of entities.departments) {
|
|
40
|
+
assert.ok(
|
|
41
|
+
dept.iri.includes("/id/department/"),
|
|
42
|
+
`Dept IRI missing /id/: ${dept.iri}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
for (const team of entities.teams) {
|
|
46
|
+
assert.ok(
|
|
47
|
+
team.iri.includes("/id/team/"),
|
|
48
|
+
`Team IRI missing /id/: ${team.iri}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
for (const person of entities.people) {
|
|
52
|
+
assert.ok(
|
|
53
|
+
person.iri.includes("/id/person/"),
|
|
54
|
+
`Person IRI missing /id/: ${person.iri}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
for (const proj of entities.projects) {
|
|
58
|
+
assert.ok(
|
|
59
|
+
proj.iri.includes("/id/project/"),
|
|
60
|
+
`Project IRI missing /id/: ${proj.iri}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { generateDrugs, generatePlatforms } from "../render/industry-data.js";
|
|
4
|
+
|
|
5
|
+
const DOMAIN = "test.example";
|
|
6
|
+
|
|
7
|
+
describe("generateDrugs", () => {
|
|
8
|
+
test("returns an array of drug objects", () => {
|
|
9
|
+
const drugs = generateDrugs(DOMAIN);
|
|
10
|
+
assert.ok(Array.isArray(drugs));
|
|
11
|
+
assert.ok(drugs.length > 0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("each drug has required fields", () => {
|
|
15
|
+
const drugs = generateDrugs(DOMAIN);
|
|
16
|
+
for (const drug of drugs) {
|
|
17
|
+
assert.ok(drug.id, "drug missing id");
|
|
18
|
+
assert.ok(drug.name, "drug missing name");
|
|
19
|
+
assert.ok(drug.iri, "drug missing iri");
|
|
20
|
+
assert.ok(drug.drugClass, "drug missing drugClass");
|
|
21
|
+
assert.ok(drug.phase, "drug missing phase");
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("drug IRIs use /id/drug/ prefix", () => {
|
|
26
|
+
const drugs = generateDrugs(DOMAIN);
|
|
27
|
+
for (const drug of drugs) {
|
|
28
|
+
assert.ok(
|
|
29
|
+
drug.iri.includes("/id/drug/"),
|
|
30
|
+
`Drug IRI missing /id/drug/: ${drug.iri}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("drug IDs are unique", () => {
|
|
36
|
+
const drugs = generateDrugs(DOMAIN);
|
|
37
|
+
const ids = drugs.map((d) => d.id);
|
|
38
|
+
assert.strictEqual(ids.length, new Set(ids).size);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("generatePlatforms", () => {
|
|
43
|
+
test("returns an array of platform objects", () => {
|
|
44
|
+
const platforms = generatePlatforms(DOMAIN);
|
|
45
|
+
assert.ok(Array.isArray(platforms));
|
|
46
|
+
assert.ok(platforms.length > 0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("each platform has required fields", () => {
|
|
50
|
+
const platforms = generatePlatforms(DOMAIN);
|
|
51
|
+
for (const p of platforms) {
|
|
52
|
+
assert.ok(p.id, "platform missing id");
|
|
53
|
+
assert.ok(p.name, "platform missing name");
|
|
54
|
+
assert.ok(p.iri, "platform missing iri");
|
|
55
|
+
assert.ok(p.category, "platform missing category");
|
|
56
|
+
assert.ok(Array.isArray(p.dependencies), "platform missing dependencies");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("platform IRIs use /id/platform/ prefix", () => {
|
|
61
|
+
const platforms = generatePlatforms(DOMAIN);
|
|
62
|
+
for (const p of platforms) {
|
|
63
|
+
assert.ok(
|
|
64
|
+
p.iri.includes("/id/platform/"),
|
|
65
|
+
`Platform IRI missing /id/platform/: ${p.iri}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("platform IDs are unique", () => {
|
|
71
|
+
const platforms = generatePlatforms(DOMAIN);
|
|
72
|
+
const ids = platforms.map((p) => p.id);
|
|
73
|
+
assert.strictEqual(ids.length, new Set(ids).size);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("platform dependencies reference valid IDs", () => {
|
|
77
|
+
const platforms = generatePlatforms(DOMAIN);
|
|
78
|
+
const ids = new Set(platforms.map((p) => p.id));
|
|
79
|
+
for (const p of platforms) {
|
|
80
|
+
for (const dep of p.dependencies) {
|
|
81
|
+
assert.ok(ids.has(dep), `Platform ${p.id} depends on unknown ${dep}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { assignLinks } from "../render/link-assigner.js";
|
|
4
|
+
import { generateDrugs, generatePlatforms } from "../render/industry-data.js";
|
|
5
|
+
|
|
6
|
+
const DOMAIN = "test.example";
|
|
7
|
+
|
|
8
|
+
function makeTestEntities() {
|
|
9
|
+
const drugs = generateDrugs(DOMAIN);
|
|
10
|
+
const platforms = generatePlatforms(DOMAIN);
|
|
11
|
+
const teams = [
|
|
12
|
+
{ id: "alpha", department: "eng", manager: "mgr-a" },
|
|
13
|
+
{ id: "beta", department: "eng", manager: "mgr-b" },
|
|
14
|
+
];
|
|
15
|
+
const departments = [{ id: "eng", name: "Engineering" }];
|
|
16
|
+
const people = [
|
|
17
|
+
{
|
|
18
|
+
id: "alice",
|
|
19
|
+
name: "Alice",
|
|
20
|
+
team_id: "alpha",
|
|
21
|
+
is_manager: true,
|
|
22
|
+
email: "alice@test.example",
|
|
23
|
+
github_username: "alice",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "bob",
|
|
27
|
+
name: "Bob",
|
|
28
|
+
team_id: "beta",
|
|
29
|
+
is_manager: true,
|
|
30
|
+
email: "bob@test.example",
|
|
31
|
+
github_username: "bob",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "carol",
|
|
35
|
+
name: "Carol",
|
|
36
|
+
team_id: "alpha",
|
|
37
|
+
is_manager: false,
|
|
38
|
+
email: "carol@test.example",
|
|
39
|
+
github_username: "carol",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "dave",
|
|
43
|
+
name: "Dave",
|
|
44
|
+
team_id: "beta",
|
|
45
|
+
is_manager: false,
|
|
46
|
+
email: "dave@test.example",
|
|
47
|
+
github_username: "dave",
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
const projects = [
|
|
51
|
+
{
|
|
52
|
+
id: "proj1",
|
|
53
|
+
type: "drug",
|
|
54
|
+
teams: ["alpha"],
|
|
55
|
+
prose_topic: "testing",
|
|
56
|
+
prose_tone: "formal",
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
return { drugs, platforms, teams, departments, people, projects };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("assignLinks", () => {
|
|
64
|
+
test("returns all expected entity types", () => {
|
|
65
|
+
const ent = makeTestEntities();
|
|
66
|
+
const result = assignLinks({
|
|
67
|
+
...ent,
|
|
68
|
+
domain: DOMAIN,
|
|
69
|
+
courseCount: 3,
|
|
70
|
+
eventCount: 2,
|
|
71
|
+
blogCount: 5,
|
|
72
|
+
articleTopics: ["clinical"],
|
|
73
|
+
seed: 42,
|
|
74
|
+
});
|
|
75
|
+
assert.ok(Array.isArray(result.drugs));
|
|
76
|
+
assert.ok(Array.isArray(result.platforms));
|
|
77
|
+
assert.ok(Array.isArray(result.projects));
|
|
78
|
+
assert.ok(Array.isArray(result.courses));
|
|
79
|
+
assert.ok(Array.isArray(result.events));
|
|
80
|
+
assert.ok(Array.isArray(result.blogPosts));
|
|
81
|
+
assert.ok(Array.isArray(result.articles));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("blog dates are valid for counts exceeding 24", () => {
|
|
85
|
+
const ent = makeTestEntities();
|
|
86
|
+
const result = assignLinks({
|
|
87
|
+
...ent,
|
|
88
|
+
domain: DOMAIN,
|
|
89
|
+
courseCount: 0,
|
|
90
|
+
eventCount: 0,
|
|
91
|
+
blogCount: 45,
|
|
92
|
+
seed: 42,
|
|
93
|
+
});
|
|
94
|
+
for (const blog of result.blogPosts) {
|
|
95
|
+
const parsed = new Date(blog.date);
|
|
96
|
+
assert.ok(!isNaN(parsed.getTime()), `Invalid date: ${blog.date}`);
|
|
97
|
+
const month = parseInt(blog.date.split("-")[1], 10);
|
|
98
|
+
assert.ok(month >= 1 && month <= 12, `Month out of range: ${month}`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("project IRIs use /id/ prefix", () => {
|
|
103
|
+
const ent = makeTestEntities();
|
|
104
|
+
const result = assignLinks({
|
|
105
|
+
...ent,
|
|
106
|
+
domain: DOMAIN,
|
|
107
|
+
courseCount: 2,
|
|
108
|
+
eventCount: 1,
|
|
109
|
+
blogCount: 1,
|
|
110
|
+
seed: 42,
|
|
111
|
+
});
|
|
112
|
+
for (const proj of result.projects) {
|
|
113
|
+
assert.ok(
|
|
114
|
+
proj.iri.includes("/id/project/"),
|
|
115
|
+
`Project IRI missing /id/: ${proj.iri}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("course orgName uses provided value instead of hardcoded", () => {
|
|
121
|
+
const ent = makeTestEntities();
|
|
122
|
+
const result = assignLinks({
|
|
123
|
+
...ent,
|
|
124
|
+
domain: DOMAIN,
|
|
125
|
+
courseCount: 3,
|
|
126
|
+
eventCount: 0,
|
|
127
|
+
blogCount: 0,
|
|
128
|
+
seed: 42,
|
|
129
|
+
orgName: "TestCorp",
|
|
130
|
+
});
|
|
131
|
+
for (const course of result.courses) {
|
|
132
|
+
assert.strictEqual(course.orgName, "TestCorp");
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("dates use provided startYear/endYear", () => {
|
|
137
|
+
const ent = makeTestEntities();
|
|
138
|
+
const result = assignLinks({
|
|
139
|
+
...ent,
|
|
140
|
+
domain: DOMAIN,
|
|
141
|
+
courseCount: 2,
|
|
142
|
+
eventCount: 2,
|
|
143
|
+
blogCount: 3,
|
|
144
|
+
seed: 42,
|
|
145
|
+
startYear: 2027,
|
|
146
|
+
endYear: 2028,
|
|
147
|
+
});
|
|
148
|
+
for (const course of result.courses) {
|
|
149
|
+
const year = parseInt(course.date.split("-")[0], 10);
|
|
150
|
+
assert.ok(
|
|
151
|
+
year >= 2027 && year <= 2028,
|
|
152
|
+
`Course year out of range: ${year}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
for (const event of result.events) {
|
|
156
|
+
const year = parseInt(event.date.split("-")[0], 10);
|
|
157
|
+
assert.ok(
|
|
158
|
+
year >= 2027 && year <= 2028,
|
|
159
|
+
`Event year out of range: ${year}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { validateEvalReferences } from "../validate-eval.js";
|
|
4
|
+
|
|
5
|
+
describe("validateEvalReferences", () => {
|
|
6
|
+
const generatedData = {
|
|
7
|
+
orgs: [
|
|
8
|
+
{
|
|
9
|
+
iri: "https://test.example/id/org/testorg",
|
|
10
|
+
name: "TestOrg",
|
|
11
|
+
id: "testorg",
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
departments: [
|
|
15
|
+
{
|
|
16
|
+
iri: "https://test.example/id/department/eng",
|
|
17
|
+
name: "Engineering",
|
|
18
|
+
id: "eng",
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
teams: [
|
|
22
|
+
{
|
|
23
|
+
iri: "https://test.example/id/team/alpha",
|
|
24
|
+
name: "Alpha Team",
|
|
25
|
+
id: "alpha",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
people: [
|
|
29
|
+
{
|
|
30
|
+
iri: "https://test.example/id/person/alice",
|
|
31
|
+
name: "Alice",
|
|
32
|
+
id: "alice",
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
projects: [
|
|
36
|
+
{
|
|
37
|
+
iri: "https://test.example/id/project/proj1",
|
|
38
|
+
name: "Project One",
|
|
39
|
+
id: "proj1",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
test("passes when all IRI references exist", () => {
|
|
45
|
+
const scenarios = [
|
|
46
|
+
{
|
|
47
|
+
name: "valid_scenario",
|
|
48
|
+
evaluations: [
|
|
49
|
+
{ data: "https://test.example/id/org/testorg" },
|
|
50
|
+
{ data: "https://test.example/id/project/proj1" },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
const result = validateEvalReferences(scenarios, generatedData);
|
|
55
|
+
assert.ok(result.passed);
|
|
56
|
+
assert.strictEqual(result.errors.length, 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reports missing IRI references", () => {
|
|
60
|
+
const scenarios = [
|
|
61
|
+
{
|
|
62
|
+
name: "broken_scenario",
|
|
63
|
+
evaluations: [{ data: "https://test.example/id/project/nonexistent" }],
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
const result = validateEvalReferences(scenarios, generatedData);
|
|
67
|
+
assert.ok(!result.passed);
|
|
68
|
+
assert.strictEqual(result.errors.length, 1);
|
|
69
|
+
assert.ok(result.errors[0].includes("nonexistent"));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("handles scenarios without evaluations", () => {
|
|
73
|
+
const scenarios = [{ name: "no_evals" }];
|
|
74
|
+
const result = validateEvalReferences(scenarios, generatedData);
|
|
75
|
+
assert.ok(result.passed);
|
|
76
|
+
assert.strictEqual(result.errors.length, 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("handles evaluations without data", () => {
|
|
80
|
+
const scenarios = [
|
|
81
|
+
{
|
|
82
|
+
name: "no_data",
|
|
83
|
+
evaluations: [{ label: "something" }],
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
const result = validateEvalReferences(scenarios, generatedData);
|
|
87
|
+
assert.ok(result.passed);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("handles empty scenarios array", () => {
|
|
91
|
+
const result = validateEvalReferences([], generatedData);
|
|
92
|
+
assert.ok(result.passed);
|
|
93
|
+
assert.strictEqual(result.errors.length, 0);
|
|
94
|
+
});
|
|
95
|
+
});
|
package/test/validate.test.js
CHANGED
package/validate-eval.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eval Scenario Reference Validator.
|
|
3
|
+
*
|
|
4
|
+
* Validates that entity names and IRIs referenced in eval scenarios
|
|
5
|
+
* exist in the generated data. Reports missing references.
|
|
6
|
+
*
|
|
7
|
+
* @module libuniverse/validate-eval
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate eval scenario references against generated data.
|
|
12
|
+
* @param {object[]} evalScenarios - Parsed eval scenario definitions
|
|
13
|
+
* @param {object} generatedData - Full entity graph
|
|
14
|
+
* @returns {{ passed: boolean, errors: string[] }}
|
|
15
|
+
*/
|
|
16
|
+
export function validateEvalReferences(evalScenarios, generatedData) {
|
|
17
|
+
const errors = [];
|
|
18
|
+
|
|
19
|
+
// Build lookup sets from generated data
|
|
20
|
+
const allIris = new Set();
|
|
21
|
+
const allNames = new Set();
|
|
22
|
+
|
|
23
|
+
const entitySources = [
|
|
24
|
+
generatedData.orgs,
|
|
25
|
+
generatedData.departments,
|
|
26
|
+
generatedData.teams,
|
|
27
|
+
generatedData.people,
|
|
28
|
+
generatedData.projects,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
for (const source of entitySources) {
|
|
32
|
+
if (!Array.isArray(source)) continue;
|
|
33
|
+
for (const entity of source) {
|
|
34
|
+
if (entity.iri) allIris.add(entity.iri);
|
|
35
|
+
if (entity.name) allNames.add(entity.name);
|
|
36
|
+
if (entity.id) allNames.add(entity.id);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check each eval scenario
|
|
41
|
+
for (const scenario of evalScenarios) {
|
|
42
|
+
if (!scenario.evaluations) continue;
|
|
43
|
+
for (const evaluation of scenario.evaluations) {
|
|
44
|
+
const data = evaluation.data;
|
|
45
|
+
if (!data) continue;
|
|
46
|
+
|
|
47
|
+
// Check for IRI references
|
|
48
|
+
const iriMatches = data.match(/https?:\/\/[^\s"']+\/id\/[^\s"',)]+/g);
|
|
49
|
+
if (iriMatches) {
|
|
50
|
+
for (const iri of iriMatches) {
|
|
51
|
+
if (!allIris.has(iri)) {
|
|
52
|
+
errors.push(
|
|
53
|
+
`Scenario "${scenario.name}": IRI "${iri}" not found in generated data`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
passed: errors.length === 0,
|
|
63
|
+
errors,
|
|
64
|
+
};
|
|
65
|
+
}
|
package/validate.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* @module libuniverse/validate
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { PROFICIENCY_LEVELS } from "@forwardimpact/libsyntheticgen/vocabulary.js";
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Validate cross-content integrity of generated entities.
|
|
9
11
|
* @param {object} entities
|
|
@@ -36,6 +38,9 @@ export function validateCrossContent(entities) {
|
|
|
36
38
|
checkScorecardCheckIds(entities),
|
|
37
39
|
checkRosterSnapshotQuarters(entities),
|
|
38
40
|
checkProjectTeamEmails(entities),
|
|
41
|
+
checkProseLength(entities),
|
|
42
|
+
checkProficiencyMonotonicity(entities),
|
|
43
|
+
checkSelfAssessmentPlausibility(entities),
|
|
39
44
|
];
|
|
40
45
|
|
|
41
46
|
const failures = checks.filter((c) => !c.passed);
|
|
@@ -287,25 +292,10 @@ function checkGetDXSnapshotsInfoResponses(entities) {
|
|
|
287
292
|
|
|
288
293
|
function checkSnapshotScoreDriverIds(entities) {
|
|
289
294
|
const scores = entities.activity?.scores || [];
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
"ease_of_release",
|
|
295
|
-
"test_efficiency",
|
|
296
|
-
"managing_tech_debt",
|
|
297
|
-
"code_review",
|
|
298
|
-
"documentation",
|
|
299
|
-
"codebase_experience",
|
|
300
|
-
"incident_response",
|
|
301
|
-
"learning_culture",
|
|
302
|
-
"experimentation",
|
|
303
|
-
"connectedness",
|
|
304
|
-
"efficient_processes",
|
|
305
|
-
"deep_work",
|
|
306
|
-
"leveraging_user_feedback",
|
|
307
|
-
]);
|
|
308
|
-
const invalid = scores.filter((s) => !VALID_DRIVERS.has(s.item_id));
|
|
295
|
+
const validDrivers = new Set(
|
|
296
|
+
(entities.framework?.drivers || []).map((d) => d.id),
|
|
297
|
+
);
|
|
298
|
+
const invalid = scores.filter((s) => !validDrivers.has(s.item_id));
|
|
309
299
|
return {
|
|
310
300
|
name: "snapshot_score_driver_ids",
|
|
311
301
|
passed: invalid.length === 0,
|
|
@@ -331,13 +321,7 @@ function checkScoreTrajectories(entities) {
|
|
|
331
321
|
|
|
332
322
|
function checkEvidenceProficiency(entities) {
|
|
333
323
|
const evidence = entities.activity?.evidence || [];
|
|
334
|
-
const VALID_PROFICIENCIES = new Set(
|
|
335
|
-
"awareness",
|
|
336
|
-
"foundational",
|
|
337
|
-
"working",
|
|
338
|
-
"practitioner",
|
|
339
|
-
"expert",
|
|
340
|
-
]);
|
|
324
|
+
const VALID_PROFICIENCIES = new Set(PROFICIENCY_LEVELS);
|
|
341
325
|
const invalid = evidence.filter(
|
|
342
326
|
(e) => e.proficiency && !VALID_PROFICIENCIES.has(e.proficiency),
|
|
343
327
|
);
|
|
@@ -512,6 +496,179 @@ function checkProjectTeamEmails(entities) {
|
|
|
512
496
|
};
|
|
513
497
|
}
|
|
514
498
|
|
|
499
|
+
// ─── E1: Prose length validation ────────────────
|
|
500
|
+
|
|
501
|
+
const PROSE_RANGES = {
|
|
502
|
+
description: { min: 50, max: 2000 },
|
|
503
|
+
proficiencyDescription: { min: 20, max: 500 },
|
|
504
|
+
maturityDescription: { min: 20, max: 500 },
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
function checkProseLength(entities) {
|
|
508
|
+
const fw = entities.framework;
|
|
509
|
+
if (!fw)
|
|
510
|
+
return { name: "prose_length", passed: true, message: "No framework data" };
|
|
511
|
+
|
|
512
|
+
const errors = [];
|
|
513
|
+
|
|
514
|
+
// Check capabilities for description and proficiency descriptions
|
|
515
|
+
for (const cap of fw.capabilities || []) {
|
|
516
|
+
if (typeof cap !== "object") continue;
|
|
517
|
+
if (cap.description) {
|
|
518
|
+
const len = cap.description.length;
|
|
519
|
+
if (
|
|
520
|
+
len < PROSE_RANGES.description.min ||
|
|
521
|
+
len > PROSE_RANGES.description.max
|
|
522
|
+
) {
|
|
523
|
+
errors.push(
|
|
524
|
+
`Capability '${cap.id || cap.name}' description: ${len} chars`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Check behaviours for description
|
|
531
|
+
for (const beh of fw.behaviours || []) {
|
|
532
|
+
if (typeof beh !== "object") continue;
|
|
533
|
+
if (beh.description) {
|
|
534
|
+
const len = beh.description.length;
|
|
535
|
+
if (
|
|
536
|
+
len < PROSE_RANGES.description.min ||
|
|
537
|
+
len > PROSE_RANGES.description.max
|
|
538
|
+
) {
|
|
539
|
+
errors.push(
|
|
540
|
+
`Behaviour '${beh.id || beh.name}' description: ${len} chars`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
name: "prose_length",
|
|
548
|
+
passed: errors.length === 0,
|
|
549
|
+
message:
|
|
550
|
+
errors.length === 0
|
|
551
|
+
? "All prose fields within expected length range"
|
|
552
|
+
: `${errors.length} prose fields outside range: ${errors.slice(0, 3).join("; ")}`,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ─── E2: Proficiency monotonicity ──────────────
|
|
557
|
+
|
|
558
|
+
const PROF_INDEX = Object.fromEntries(PROFICIENCY_LEVELS.map((p, i) => [p, i]));
|
|
559
|
+
|
|
560
|
+
function checkProficiencyMonotonicity(entities) {
|
|
561
|
+
const pathway = entities.pathway;
|
|
562
|
+
if (!pathway?.levels) {
|
|
563
|
+
return {
|
|
564
|
+
name: "proficiency_monotonicity",
|
|
565
|
+
passed: true,
|
|
566
|
+
message: "No pathway level data",
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const levels = pathway.levels;
|
|
571
|
+
const levelIds = Object.keys(levels);
|
|
572
|
+
if (levelIds.length < 2) {
|
|
573
|
+
return {
|
|
574
|
+
name: "proficiency_monotonicity",
|
|
575
|
+
passed: true,
|
|
576
|
+
message: "Fewer than 2 levels",
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const violations = [];
|
|
581
|
+
// For each skill, check that proficiency is non-decreasing across levels
|
|
582
|
+
const skillIds = new Set();
|
|
583
|
+
for (const levelId of levelIds) {
|
|
584
|
+
const baselines =
|
|
585
|
+
levels[levelId]?.baselines || levels[levelId]?.skillBaselines || {};
|
|
586
|
+
for (const skillId of Object.keys(baselines)) {
|
|
587
|
+
skillIds.add(skillId);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
for (const skillId of skillIds) {
|
|
592
|
+
let prevIdx = -1;
|
|
593
|
+
for (const levelId of levelIds) {
|
|
594
|
+
const baselines =
|
|
595
|
+
levels[levelId]?.baselines || levels[levelId]?.skillBaselines || {};
|
|
596
|
+
const prof = baselines[skillId];
|
|
597
|
+
if (!prof || PROF_INDEX[prof] === undefined) continue;
|
|
598
|
+
const idx = PROF_INDEX[prof];
|
|
599
|
+
if (idx < prevIdx) {
|
|
600
|
+
violations.push(`Skill '${skillId}' decreases at level '${levelId}'`);
|
|
601
|
+
}
|
|
602
|
+
prevIdx = idx;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
name: "proficiency_monotonicity",
|
|
608
|
+
passed: violations.length === 0,
|
|
609
|
+
message:
|
|
610
|
+
violations.length === 0
|
|
611
|
+
? "All skill proficiencies are non-decreasing across levels"
|
|
612
|
+
: `${violations.length} monotonicity violations: ${violations.slice(0, 3).join("; ")}`,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ─── E3: Self-assessment plausibility ──────────
|
|
617
|
+
|
|
618
|
+
function checkSelfAssessmentPlausibility(entities) {
|
|
619
|
+
const pathway = entities.pathway;
|
|
620
|
+
if (!pathway?.selfAssessments) {
|
|
621
|
+
return {
|
|
622
|
+
name: "self_assessment_plausibility",
|
|
623
|
+
passed: true,
|
|
624
|
+
message: "No self-assessment data",
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const assessments = pathway.selfAssessments;
|
|
629
|
+
if (!Array.isArray(assessments) || assessments.length === 0) {
|
|
630
|
+
return {
|
|
631
|
+
name: "self_assessment_plausibility",
|
|
632
|
+
passed: true,
|
|
633
|
+
message: "No self-assessments",
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const violations = [];
|
|
638
|
+
const threshold = 2; // Allow ±2 deviation from expected
|
|
639
|
+
|
|
640
|
+
for (let i = 0; i < assessments.length; i++) {
|
|
641
|
+
const assessment = assessments[i];
|
|
642
|
+
const profs = Object.values(assessment.skillProficiencies || {});
|
|
643
|
+
if (profs.length === 0) continue;
|
|
644
|
+
|
|
645
|
+
const indices = profs
|
|
646
|
+
.map((p) => PROF_INDEX[p])
|
|
647
|
+
.filter((idx) => idx !== undefined);
|
|
648
|
+
if (indices.length === 0) continue;
|
|
649
|
+
|
|
650
|
+
const median = indices.sort((a, b) => a - b)[
|
|
651
|
+
Math.floor(indices.length / 2)
|
|
652
|
+
];
|
|
653
|
+
const expectedBase = Math.min(i, PROFICIENCY_LEVELS.length - 1);
|
|
654
|
+
|
|
655
|
+
if (Math.abs(median - expectedBase) > threshold) {
|
|
656
|
+
violations.push(
|
|
657
|
+
`Assessment '${assessment.id}': median proficiency ${median} vs expected ~${expectedBase}`,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
name: "self_assessment_plausibility",
|
|
664
|
+
passed: violations.length === 0,
|
|
665
|
+
message:
|
|
666
|
+
violations.length === 0
|
|
667
|
+
? "Self-assessment distributions are plausible"
|
|
668
|
+
: `${violations.length} implausible assessments: ${violations.slice(0, 3).join("; ")}`,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
515
672
|
/**
|
|
516
673
|
* Content validator class with DI.
|
|
517
674
|
*/
|