@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libsyntheticrender",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Multi-format rendering, validation, and formatting for synthetic data",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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: `FAQ Question ${i + 1}`,
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
  };
@@ -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: `2025-${String((i % 12) + 1).padStart(2, "0")}-01`,
193
- orgName: "BioNova",
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: `2025-${String((i % 12) + 1).padStart(2, "0")}-15`,
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: BLOG_TOPICS[i % BLOG_TOPICS.length],
346
+ headline: selectBlogTopic(i),
275
347
  iri: `${base}/id/blog/blog-${i + 1}`,
276
- identifier: `BLOG-2025-${String(i + 1).padStart(3, "0")}`,
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: `2025-${String(Math.floor(i / 2) + 1).padStart(2, "0")}-${String(10 + (i % 20)).padStart(2, "0")}`,
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: `2025-${String((i % 12) + 1).padStart(2, "0")}-01`,
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: team.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
+ });
@@ -32,7 +32,7 @@ function buildEntities(overrides = {}) {
32
32
  capabilities: [],
33
33
  behaviours: [],
34
34
  disciplines: [],
35
- drivers: [],
35
+ drivers: [{ id: "code_review" }],
36
36
  },
37
37
  activity: {
38
38
  roster: [{ email: "zeus@acme.com" }, { email: "athena@acme.com" }],
@@ -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 VALID_DRIVERS = new Set([
291
- "clear_direction",
292
- "say_on_priorities",
293
- "requirements_quality",
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
  */