@you-agent-factory/factory-emulator 0.0.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.
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Pure rule analysis and resolution for scenarios that have passed the public
3
+ * parser. These helpers never create Factory events or emulator activity.
4
+ */
5
+ export function scenarioSemanticDiagnostics(scenario, factory) {
6
+ const diagnostics = [];
7
+ const initialSubmissions = scenario.initialSubmissions ?? [];
8
+ const factoryWorkTypes = factoryWorkTypeNames(factory);
9
+ const initialSubmissionIndexes = indexesById(initialSubmissions);
10
+ const ruleIndexes = indexesById(scenario.rules);
11
+ const cyclicLineageOutcomeKeys = cyclicScriptedOutcomeKeys(
12
+ scenario.rules,
13
+ ruleIndexes,
14
+ );
15
+
16
+ duplicateIdDiagnostics(
17
+ initialSubmissionIndexes,
18
+ "/initialSubmissions",
19
+ "initial submission",
20
+ diagnostics,
21
+ );
22
+ duplicateIdDiagnostics(ruleIndexes, "/rules", "rule", diagnostics);
23
+
24
+ for (const [index, submission] of initialSubmissions.entries()) {
25
+ if (!factoryWorkTypes.has(submission.workType)) {
26
+ diagnostics.push(
27
+ diagnostic(
28
+ "UNKNOWN_FACTORY_WORK_TYPE",
29
+ `/initialSubmissions/${index}/workType`,
30
+ `Initial submission work type ${JSON.stringify(submission.workType)} is not available in the Factory definition.`,
31
+ "a work type declared by the Factory definition",
32
+ ),
33
+ );
34
+ }
35
+ }
36
+
37
+ for (const [index, rule] of scenario.rules.entries()) {
38
+ validateRuleMatcher(
39
+ rule,
40
+ index,
41
+ factoryWorkTypes,
42
+ initialSubmissionIndexes,
43
+ diagnostics,
44
+ );
45
+ validateLineageCursors(
46
+ rule,
47
+ index,
48
+ scenario.rules,
49
+ ruleIndexes,
50
+ initialSubmissionIndexes,
51
+ cyclicLineageOutcomeKeys,
52
+ diagnostics,
53
+ );
54
+
55
+ for (let earlierIndex = 0; earlierIndex < index; earlierIndex += 1) {
56
+ if (shadows(scenario.rules[earlierIndex], rule, initialSubmissions)) {
57
+ diagnostics.push(
58
+ diagnostic(
59
+ "SHADOWED_RULE",
60
+ `/rules/${index}`,
61
+ `Rule ${JSON.stringify(rule.id)} at /rules/${index} is unreachable because earlier rule ${JSON.stringify(scenario.rules[earlierIndex].id)} at /rules/${earlierIndex} covers its supported match domain.`,
62
+ `a match domain not covered by /rules/${earlierIndex}`,
63
+ ),
64
+ );
65
+ break;
66
+ }
67
+ }
68
+ }
69
+
70
+ return diagnostics.sort(compareDiagnostics);
71
+ }
72
+
73
+ /** Returns the first authored rule that applies to a submission. */
74
+ export function selectEmulatorRule(scenario, submission) {
75
+ return scenario.rules.find((rule) => ruleMatches(rule.match, submission));
76
+ }
77
+
78
+ /**
79
+ * Resolves one zero-based matching invocation without mutating scenario state.
80
+ * The invocation index counts prior matches for the selected rule only.
81
+ */
82
+ export function resolveEmulatorScenarioResult(scenario, submission, invocationIndex) {
83
+ if (!Number.isSafeInteger(invocationIndex) || invocationIndex < 0) {
84
+ throw new RangeError("invocationIndex must be a zero-based safe integer.");
85
+ }
86
+ const rule = selectEmulatorRule(scenario, submission);
87
+ if (rule === undefined) {
88
+ return { kind: "unmatched", behavior: scenario.unmatchedBehavior };
89
+ }
90
+ if (invocationIndex < rule.outcomes.length) {
91
+ return { kind: "outcome", rule, outcome: rule.outcomes[invocationIndex] };
92
+ }
93
+ if (rule.exhaustionBehavior.kind === "repeatLast") {
94
+ return {
95
+ kind: "outcome",
96
+ rule,
97
+ outcome: rule.outcomes[rule.outcomes.length - 1],
98
+ };
99
+ }
100
+ if (rule.exhaustionBehavior.kind === "useUnmatchedBehavior") {
101
+ return { kind: "unmatched", behavior: scenario.unmatchedBehavior };
102
+ }
103
+ return { kind: "exhausted", rule, behavior: rule.exhaustionBehavior };
104
+ }
105
+
106
+ function factoryWorkTypeNames(factory) {
107
+ if (!isRecord(factory) || !Array.isArray(factory.workTypes)) {
108
+ return new Set();
109
+ }
110
+ return new Set(
111
+ factory.workTypes.flatMap((workType) =>
112
+ isRecord(workType) && typeof workType.name === "string"
113
+ ? [workType.name]
114
+ : [],
115
+ ),
116
+ );
117
+ }
118
+
119
+ function indexesById(items) {
120
+ const indexes = new Map();
121
+ for (const [index, item] of items.entries()) {
122
+ const values = indexes.get(item.id) ?? [];
123
+ values.push(index);
124
+ indexes.set(item.id, values);
125
+ }
126
+ return indexes;
127
+ }
128
+
129
+ function duplicateIdDiagnostics(indexes, rootPath, itemName, diagnostics) {
130
+ for (const [id, occurrences] of indexes) {
131
+ if (occurrences.length > 1) {
132
+ for (const index of occurrences.slice(1)) {
133
+ diagnostics.push(
134
+ diagnostic(
135
+ "DUPLICATE_SCENARIO_IDENTIFIER",
136
+ `${rootPath}/${index}/id`,
137
+ `${itemName[0].toUpperCase()}${itemName.slice(1)} id ${JSON.stringify(id)} duplicates ${rootPath}/${occurrences[0]}/id.`,
138
+ `a unique ${itemName} id`,
139
+ ),
140
+ );
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ function validateRuleMatcher(
147
+ rule,
148
+ ruleIndex,
149
+ factoryWorkTypes,
150
+ initialSubmissionIndexes,
151
+ diagnostics,
152
+ ) {
153
+ if (rule.match.kind === "workType" && !factoryWorkTypes.has(rule.match.workType)) {
154
+ diagnostics.push(
155
+ diagnostic(
156
+ "UNKNOWN_FACTORY_WORK_TYPE",
157
+ `/rules/${ruleIndex}/match/workType`,
158
+ `Rule work type ${JSON.stringify(rule.match.workType)} is not available in the Factory definition.`,
159
+ "a work type declared by the Factory definition",
160
+ ),
161
+ );
162
+ }
163
+ if (
164
+ rule.match.kind === "submissionId" &&
165
+ initialSubmissionIndexes.get(rule.match.submissionId)?.length !== 1
166
+ ) {
167
+ diagnostics.push(
168
+ diagnostic(
169
+ "UNKNOWN_INITIAL_SUBMISSION",
170
+ `/rules/${ruleIndex}/match/submissionId`,
171
+ `Rule submission id ${JSON.stringify(rule.match.submissionId)} does not identify one initial submission.`,
172
+ "exactly one initial submission id",
173
+ ),
174
+ );
175
+ }
176
+ }
177
+
178
+ function validateLineageCursors(
179
+ rule,
180
+ ruleIndex,
181
+ rules,
182
+ ruleIndexes,
183
+ initialSubmissionIndexes,
184
+ cyclicLineageOutcomeKeys,
185
+ diagnostics,
186
+ ) {
187
+ for (const [outcomeIndex, outcome] of rule.outcomes.entries()) {
188
+ if (outcome.kind !== "complete" || outcome.lineageCursor === undefined) {
189
+ continue;
190
+ }
191
+ const cursor = outcome.lineageCursor;
192
+ const path = `/rules/${ruleIndex}/outcomes/${outcomeIndex}/lineageCursor`;
193
+ if (cursor.kind === "initialSubmission") {
194
+ if (initialSubmissionIndexes.get(cursor.submissionId)?.length !== 1) {
195
+ diagnostics.push(
196
+ diagnostic(
197
+ "MISSING_LINEAGE_CURSOR_TARGET",
198
+ path,
199
+ `Lineage cursor submission ${JSON.stringify(cursor.submissionId)} does not identify one initial submission.`,
200
+ "exactly one initial submission id",
201
+ ),
202
+ );
203
+ }
204
+ continue;
205
+ }
206
+
207
+ if (cyclicLineageOutcomeKeys.has(outcomeKey(ruleIndex, outcomeIndex))) {
208
+ diagnostics.push(
209
+ diagnostic(
210
+ "CYCLIC_LINEAGE_CURSOR",
211
+ path,
212
+ "Lineage cursor participates in a cycle and cannot resolve to a prior result.",
213
+ "an acyclic reference to a previous complete scripted outcome",
214
+ ),
215
+ );
216
+ continue;
217
+ }
218
+
219
+ const targetIndexes = ruleIndexes.get(cursor.ruleId);
220
+ if (targetIndexes?.length !== 1) {
221
+ diagnostics.push(
222
+ diagnostic(
223
+ "MISSING_LINEAGE_CURSOR_TARGET",
224
+ path,
225
+ `Lineage cursor rule ${JSON.stringify(cursor.ruleId)} does not identify one scripted rule.`,
226
+ "exactly one earlier scripted rule id",
227
+ ),
228
+ );
229
+ continue;
230
+ }
231
+
232
+ const targetRuleIndex = targetIndexes[0];
233
+ if (
234
+ targetRuleIndex > ruleIndex ||
235
+ (targetRuleIndex === ruleIndex && cursor.outcomeIndex >= outcomeIndex)
236
+ ) {
237
+ diagnostics.push(
238
+ diagnostic(
239
+ "FORWARD_LINEAGE_CURSOR",
240
+ path,
241
+ `Lineage cursor targets /rules/${targetRuleIndex}/outcomes/${cursor.outcomeIndex}, which is not a previously scripted result.`,
242
+ "a previous complete scripted outcome",
243
+ ),
244
+ );
245
+ continue;
246
+ }
247
+
248
+ const targetOutcome = rules[targetRuleIndex].outcomes[cursor.outcomeIndex];
249
+ if (targetOutcome?.kind !== "complete") {
250
+ diagnostics.push(
251
+ diagnostic(
252
+ "INCOMPATIBLE_LINEAGE_CURSOR",
253
+ path,
254
+ `Lineage cursor target /rules/${targetRuleIndex}/outcomes/${cursor.outcomeIndex} is not a complete scripted outcome.`,
255
+ "a previous complete scripted outcome",
256
+ ),
257
+ );
258
+ }
259
+ }
260
+ }
261
+
262
+ function cyclicScriptedOutcomeKeys(rules, ruleIndexes) {
263
+ const links = new Map();
264
+ for (const [ruleIndex, rule] of rules.entries()) {
265
+ for (const [outcomeIndex, outcome] of rule.outcomes.entries()) {
266
+ const cursor = outcome.kind === "complete" ? outcome.lineageCursor : undefined;
267
+ if (cursor?.kind !== "scriptedOutcome") {
268
+ continue;
269
+ }
270
+ const targetIndexes = ruleIndexes.get(cursor.ruleId);
271
+ if (targetIndexes?.length !== 1) {
272
+ continue;
273
+ }
274
+ const targetRuleIndex = targetIndexes[0];
275
+ const targetOutcome = rules[targetRuleIndex].outcomes[cursor.outcomeIndex];
276
+ if (targetOutcome?.kind === "complete") {
277
+ links.set(
278
+ outcomeKey(ruleIndex, outcomeIndex),
279
+ outcomeKey(targetRuleIndex, cursor.outcomeIndex),
280
+ );
281
+ }
282
+ }
283
+ }
284
+
285
+ const cyclic = new Set();
286
+ for (const start of links.keys()) {
287
+ const positions = new Map();
288
+ const path = [];
289
+ for (let current = start; current !== undefined; current = links.get(current)) {
290
+ const cycleStart = positions.get(current);
291
+ if (cycleStart !== undefined) {
292
+ for (const cycleKey of path.slice(cycleStart)) {
293
+ cyclic.add(cycleKey);
294
+ }
295
+ break;
296
+ }
297
+ positions.set(current, path.length);
298
+ path.push(current);
299
+ }
300
+ }
301
+ return cyclic;
302
+ }
303
+
304
+ function outcomeKey(ruleIndex, outcomeIndex) {
305
+ return `${ruleIndex}:${outcomeIndex}`;
306
+ }
307
+
308
+ function shadows(earlierRule, laterRule, initialSubmissions) {
309
+ const earlier = earlierRule.match;
310
+ const later = laterRule.match;
311
+ if (earlier.kind === "all") {
312
+ return true;
313
+ }
314
+ if (earlier.kind === later.kind) {
315
+ return (
316
+ (earlier.kind === "workType" && earlier.workType === later.workType) ||
317
+ (earlier.kind === "submissionId" &&
318
+ earlier.submissionId === later.submissionId)
319
+ );
320
+ }
321
+ return (
322
+ earlier.kind === "workType" &&
323
+ later.kind === "submissionId" &&
324
+ initialSubmissions.some(
325
+ (submission) =>
326
+ submission.id === later.submissionId &&
327
+ submission.workType === earlier.workType,
328
+ )
329
+ );
330
+ }
331
+
332
+ function ruleMatches(match, submission) {
333
+ return (
334
+ match.kind === "all" ||
335
+ (match.kind === "workType" && match.workType === submission.workType) ||
336
+ (match.kind === "submissionId" && match.submissionId === submission.id)
337
+ );
338
+ }
339
+
340
+ function diagnostic(code, path, message, expectation) {
341
+ return { code, path, message, expectation };
342
+ }
343
+
344
+ function compareDiagnostics(left, right) {
345
+ return (
346
+ left.path.localeCompare(right.path) ||
347
+ left.code.localeCompare(right.code) ||
348
+ left.expectation.localeCompare(right.expectation)
349
+ );
350
+ }
351
+
352
+ function isRecord(value) {
353
+ return typeof value === "object" && value !== null && !Array.isArray(value);
354
+ }
@@ -0,0 +1,38 @@
1
+ import type {
2
+ EmulatorExhaustionBehavior,
3
+ EmulatorOutcome,
4
+ EmulatorRule,
5
+ EmulatorScenario,
6
+ EmulatorUnmatchedBehavior,
7
+ } from "./generated/scenario.js";
8
+ import type { EmulatorScenarioDiagnostic } from "./parser.js";
9
+
10
+ export interface EmulatorSubmission {
11
+ id: string;
12
+ workType: string;
13
+ }
14
+
15
+ export type EmulatorScenarioResolution =
16
+ | { kind: "outcome"; rule: EmulatorRule; outcome: EmulatorOutcome }
17
+ | { kind: "unmatched"; behavior: EmulatorUnmatchedBehavior }
18
+ | {
19
+ kind: "exhausted";
20
+ rule: EmulatorRule;
21
+ behavior: Extract<EmulatorExhaustionBehavior, { kind: "reject" }>;
22
+ };
23
+
24
+ export declare function selectEmulatorRule(
25
+ scenario: EmulatorScenario,
26
+ submission: EmulatorSubmission,
27
+ ): EmulatorRule | undefined;
28
+
29
+ export declare function resolveEmulatorScenarioResult(
30
+ scenario: EmulatorScenario,
31
+ submission: EmulatorSubmission,
32
+ invocationIndex: number,
33
+ ): EmulatorScenarioResolution;
34
+
35
+ export declare function scenarioSemanticDiagnostics(
36
+ scenario: EmulatorScenario,
37
+ factory: unknown,
38
+ ): EmulatorScenarioDiagnostic[];