@exellix/graph-engine 8.7.0 → 9.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.0.0 — Response contract & finalOutput (FR-GE / CR-GE pack)
4
+
5
+ ### Breaking
6
+
7
+ - **Single canonical response:** Executable mapping reads **`graph.response` only** (authoring: `graph.response`; flat model: root `response`). `metadata.graphResponse` is rejected as executable truth (`GRAPH_RESPONSE_LEGACY_SOURCE`).
8
+ - **`validateGraphResponseWiring`** runs on compile by default (`strictResponseValidation !== false`): path writers, flat map targets, legacy paths/shapes, empty shape + finalizer.
9
+ - **`GRAPH_RESPONSE_INCOMPLETE`** when non-empty `graph.response.shape` resolves empty on a completed run.
10
+ - **Legacy removed:** `inference.conceptSketch.*` execution paths, `subnetAnalysis` / top-level `taskSections` shape keys, `metadata.responsePreset.id === 'subnetAnalysis'`, task `outputMapping`.
11
+ - **`resolveGraphRunContract`** reads **`graph.response.persistency` only** (no `metadata.graphResponse.persistency` fallback).
12
+ - **`ExecuteGraphResult.execution`** is always present on graph runs.
13
+
14
+ ### Added
15
+
16
+ - **`coerce: 'stringArray'`** on response selectors; **`coerceStringArray`**, **`resolveGraphResponse`**, **`assessGraphResponseCompleteness`** exports.
17
+ - **`migrateLegacyGraphResponseToAuthoring`** (import-only).
18
+ - **`inspectGraph`**: `response.source === 'graph.response'`.
19
+
20
+ ### Downstream
21
+
22
+ - **graphs-playground:** remove `normalizeQaAnswersArrayFinalOutput`, `normalizeTaskSectionsFinalOutput`, `coerceResponsePresetTaggedGraph`, `buildTaskSectionsFinalOutputFromAnswers`; add `coerce: 'stringArray'` in studio preset builders at save time.
23
+
3
24
  ## 8.6.0 — Graphenix 2.7.3 (no-legacy web + narrix pipeline)
4
25
 
5
26
  ### Breaking
@@ -1,6 +1,9 @@
1
1
  import type { AuthoringGraphDocument, CompileExecutablePlanV2Options, ExecutableGraphPlanV2 } from '@x12i/graphenix-executable-contracts';
2
2
  import type { GraphRuntimeObject } from '../runtime/ExellixGraphRuntime.js';
3
- export type CompileExellixExecutablePlanOptions = CompileExecutablePlanV2Options;
3
+ export type CompileExellixExecutablePlanOptions = CompileExecutablePlanV2Options & {
4
+ /** When true (default in 9.x), reject unsatisfiable graph.response wiring before returning a plan. */
5
+ strictResponseValidation?: boolean;
6
+ };
4
7
  /**
5
8
  * Host/test helper: canonical {@link AuthoringGraphDocument} + runtime → validated v2 executable plan.
6
9
  * No legacy migration — graphs must already be Graphenix 2.x authoring shape.
@@ -3,6 +3,12 @@ import { compileExecutablePlanV2 } from '@x12i/graphenix-plan-compiler';
3
3
  import { validateExecutablePlanV2 } from '@x12i/graphenix-plan-format';
4
4
  import { resolveGraphEntryFromAuthoringDocument } from './authoringDocumentHelpers.js';
5
5
  import { EXELLIX_STRUCTURED_DATA_FILTERS_V1 } from '../types/refs.js';
6
+ import { assertGraphResponseWiringOk, validateGraphResponseWiring, } from '../validation/validateGraphResponseWiring.js';
7
+ import { resolveAuthoringGraphResponse } from '../validation/authoringGraphResponse.js';
8
+ import { assertGraphResponseDefinition } from '../runtime/graphResponseMapping.js';
9
+ function isPlainRecord(v) {
10
+ return v != null && typeof v === 'object' && !Array.isArray(v);
11
+ }
6
12
  function patchEntryGatesFromGraphEntryDataFilters(plan, doc) {
7
13
  const graphEntry = resolveGraphEntryFromAuthoringDocument(doc);
8
14
  const dataFilters = graphEntry?.dataFilters;
@@ -30,6 +36,14 @@ function patchEntryGatesFromGraphEntryDataFilters(plan, doc) {
30
36
  * No legacy migration — graphs must already be Graphenix 2.x authoring shape.
31
37
  */
32
38
  export function compileExellixExecutablePlan(doc, runtime, options) {
39
+ const strictResponseValidation = options?.strictResponseValidation !== false;
40
+ // Ensure canonical response is present on authoring graph before plan compile (FR-GE-001).
41
+ const resolvedResponse = resolveAuthoringGraphResponse(doc);
42
+ assertGraphResponseDefinition(resolvedResponse);
43
+ const graphRecord = doc.graph;
44
+ if (!isPlainRecord(graphRecord.response)) {
45
+ graphRecord.response = resolvedResponse;
46
+ }
33
47
  const authoringValidation = validateAuthoringGraph(doc);
34
48
  if (!authoringValidation.valid) {
35
49
  const summary = authoringValidation.errors.map((e) => `${e.path}: ${e.message}`).join('; ');
@@ -41,5 +55,9 @@ export function compileExellixExecutablePlan(doc, runtime, options) {
41
55
  const summary = planValidation.errors.map((e) => `${e.path}: ${e.message}`).join('; ');
42
56
  throw new Error(`compileExellixExecutablePlan: invalid plan: ${summary}`);
43
57
  }
58
+ if (strictResponseValidation) {
59
+ const planWiring = validateGraphResponseWiring(plan);
60
+ assertGraphResponseWiringOk(planWiring, { graphId: plan.source.graphId });
61
+ }
44
62
  return plan;
45
63
  }
@@ -65,6 +65,9 @@ export function mergeGraphRunPersistency(base, override) {
65
65
  };
66
66
  }
67
67
  function mergeRawPersistency(base, rawOverride) {
68
+ // Explicit null on a run/work/job disables result writeback (simulation).
69
+ if (rawOverride === null)
70
+ return null;
68
71
  const overrideRec = asRecord(rawOverride);
69
72
  if (!overrideRec)
70
73
  return base;
@@ -98,12 +101,6 @@ function readPersistency(graph) {
98
101
  if (fromResponse) {
99
102
  return normalizePersistency(fromResponse);
100
103
  }
101
- const metadata = asRecord(graph.metadata);
102
- const graphResponse = asRecord(metadata?.graphResponse);
103
- const legacy = asRecord(graphResponse?.persistency);
104
- if (legacy) {
105
- return normalizePersistency(legacy);
106
- }
107
104
  return null;
108
105
  }
109
106
  /** Read graph document version from published graph JSON (`version` or `metadata.version`). */
@@ -16,7 +16,7 @@ export type { HostExecuteGraphRunOptions, MainReadinessPolicy, ExecutionStepOpti
16
16
  export type { AiTaskProfileMetadata, AiTaskProfileInputSynthesis, } from './types/aiTaskProfile.js';
17
17
  export { hasWebScopeAuthoring } from './types/aiTaskProfile.js';
18
18
  export type { ExecutionStrategyInvocation, ExecutionStrategyPhase, ExecutionStrategyWrapperKey, SmartInputConfig, TaskStrategyItemData, XynthesizedDestinationScope, XynthesizedMemory, XynthesizedOutputConfig, } from './types/aiTasksDerivedTypes.js';
19
- export type { Graph, GraphModelObject, GraphAiModelConfig, PartialGraphAiModelConfig, GraphModelAliasConfig, GraphRuntimeNodeConfig, TaskNodeRuntimeObject, GraphDocumentMetadata, GraphEntryContract, GraphResponseDefinition, GraphResponseMissingBehavior, GraphResponseSelector, GraphResponseShape, GraphResponseContract, GraphResponseMapping, GraphResponseMappingMissingBehavior, GraphResponseMappingSelector, GraphResponseMappingTarget, GraphExecutionDefaults, GraphExecutionMode, GraphOutputMode, GraphFlowOutline, GraphCoreObjective, GraphNodesResponses, GraphNode, TaskNode, TaskNodeConditions, TaskNodeConditionWhen, TaskNodeJsonCondition, TaskNodeJsConditionFunction, TaskNodeAiCondition, TaskNodeConditionParameters, ModelConfigSelection, ModelConfigCase, TaskNodePureMetadata, TaskNodeExecutionPipelineStep, TaskOutputValidation, FinalizerNode, FinalizerInputBinding, OutputSchema, AggregateFinalizerConfig, BundleFinalizerConfig, SelectFinalizerConfig, SynthesizeFinalizerConfig, UtilityExecutionPolicy, Job, CatalogPlanningKind, CatalogRequestStatus, CatalogBinding, ScopedQuestionCatalogRequestEntry, DiscoveryDefinitionCatalogRequestEntry, DiscoveryDefinitionCatalogRequest, DiscoveryDefinitionPlanningFields, CatalogRequestEntry, StructuredDataFiltersV1, ConditionsDataFilters, } from './types/refs.js';
19
+ export type { Graph, GraphModelObject, GraphAiModelConfig, PartialGraphAiModelConfig, GraphModelAliasConfig, GraphRuntimeNodeConfig, TaskNodeRuntimeObject, GraphDocumentMetadata, GraphEntryContract, GraphResponseDefinition, GraphResponseMissingBehavior, GraphResponseSelector, GraphResponseCoercion, GraphResponseShape, GraphResponseContract, GraphResponseMapping, GraphResponseMappingMissingBehavior, GraphResponseMappingSelector, GraphResponseMappingTarget, GraphExecutionDefaults, GraphExecutionMode, GraphOutputMode, GraphFlowOutline, GraphCoreObjective, GraphNodesResponses, GraphNode, TaskNode, TaskNodeConditions, TaskNodeConditionWhen, TaskNodeJsonCondition, TaskNodeJsConditionFunction, TaskNodeAiCondition, TaskNodeConditionParameters, ModelConfigSelection, ModelConfigCase, TaskNodePureMetadata, TaskNodeExecutionPipelineStep, TaskOutputValidation, FinalizerNode, FinalizerInputBinding, OutputSchema, AggregateFinalizerConfig, BundleFinalizerConfig, SelectFinalizerConfig, SynthesizeFinalizerConfig, UtilityExecutionPolicy, Job, CatalogPlanningKind, CatalogRequestStatus, CatalogBinding, ScopedQuestionCatalogRequestEntry, DiscoveryDefinitionCatalogRequestEntry, DiscoveryDefinitionCatalogRequest, DiscoveryDefinitionPlanningFields, CatalogRequestEntry, StructuredDataFiltersV1, ConditionsDataFilters, } from './types/refs.js';
20
20
  export { mergeGraphDocumentModel, EXELLIX_GRAPH_MODEL_VARIABLE_KEY, EXELLIX_STRUCTURED_DATA_FILTERS_V1, } from './types/refs.js';
21
21
  export type { TaskNodeTaskConfiguration, TaskNodeScopedDataReaderPackSlot, } from './types/taskNodeConfiguration.js';
22
22
  export { getTaskConfiguration } from './types/taskNodeConfiguration.js';
@@ -95,6 +95,13 @@ export type { ExecutableGraphPlanV2, NodeExecutionPlan, ExecutionUnitPlanV2, } f
95
95
  export type { AuthoringGraphDocument } from '@x12i/graphenix-executable-contracts';
96
96
  export { compileExellixExecutablePlan } from './compile/compileExellixExecutablePlan.js';
97
97
  export type { CompileExellixExecutablePlanOptions } from './compile/compileExellixExecutablePlan.js';
98
+ export { applyGraphResponseDefinition, resolveGraphResponse, assessGraphResponseCompleteness, GRAPH_RESPONSE_INCOMPLETE, } from './runtime/graphResponseMapping.js';
99
+ export type { GraphResponseMappingContext, GraphResponseMappingError } from './runtime/graphResponseMapping.js';
100
+ export { coerceStringArray } from './runtime/coerceStringArray.js';
101
+ export { validateGraphResponseWiring, assertGraphResponseWiringOk, RESPONSE_PATH_NO_WRITER, TASK_MAP_TARGET_INVALID, TASK_MAP_SOURCE_INVALID, RESPONSE_LEGACY_SHAPE_KEY, EXECUTION_MAPPING_LEGACY_PATH, RESPONSE_EMPTY_SHAPE, RESPONSE_LEGACY_PRESET_ID, RESPONSE_COERCE_STRING_ARRAY_ON_SCALAR, } from './validation/validateGraphResponseWiring.js';
102
+ export type { GraphResponseWiringIssue, GraphResponseWiringValidation, } from './validation/validateGraphResponseWiring.js';
103
+ export { detectAuthoringResponseSourceIssues, resolveAuthoringGraphResponse, migrateLegacyGraphResponseToAuthoring, GRAPH_RESPONSE_LEGACY_SOURCE, GRAPH_RESPONSE_DUAL_SOURCE, } from './validation/authoringGraphResponse.js';
104
+ export type { AuthoringResponseSourceIssue } from './validation/authoringGraphResponse.js';
98
105
  export { buildGraphExecutionRequestFromStudioExecute as buildAuthoringStudioGraphExecutionRequest } from '@x12i/graphenix-execute-envelope';
99
106
  export type { RunTaskRequest, RunTaskResponse, TasksClientLike, GraphLoader as RuntimeGraphLoader, ExecuteGraphInput, GraphExecutionRequest, GraphRuntimeObject, GraphKnowledgeResolver, GraphKnowledgeResolverContext, ExecuteGraphResult, ExellixGraphRuntimeOptions, } from './runtime/ExellixGraphRuntime.js';
100
107
  export type { PlanStatus, GraphPlan, GraphEngine, GraphEngineFactory, } from './runtime/GraphEngine.js';
package/dist/src/index.js CHANGED
@@ -69,6 +69,10 @@ export { computeGraphDocumentContentSha256, stableStringifyGraphDocument, } from
69
69
  // New runtime with injection seam
70
70
  export { createExellixGraphRuntime } from './runtime/ExellixGraphRuntime.js';
71
71
  export { compileExellixExecutablePlan } from './compile/compileExellixExecutablePlan.js';
72
+ export { applyGraphResponseDefinition, resolveGraphResponse, assessGraphResponseCompleteness, GRAPH_RESPONSE_INCOMPLETE, } from './runtime/graphResponseMapping.js';
73
+ export { coerceStringArray } from './runtime/coerceStringArray.js';
74
+ export { validateGraphResponseWiring, assertGraphResponseWiringOk, RESPONSE_PATH_NO_WRITER, TASK_MAP_TARGET_INVALID, TASK_MAP_SOURCE_INVALID, RESPONSE_LEGACY_SHAPE_KEY, EXECUTION_MAPPING_LEGACY_PATH, RESPONSE_EMPTY_SHAPE, RESPONSE_LEGACY_PRESET_ID, RESPONSE_COERCE_STRING_ARRAY_ON_SCALAR, } from './validation/validateGraphResponseWiring.js';
75
+ export { detectAuthoringResponseSourceIssues, resolveAuthoringGraphResponse, migrateLegacyGraphResponseToAuthoring, GRAPH_RESPONSE_LEGACY_SOURCE, GRAPH_RESPONSE_DUAL_SOURCE, } from './validation/authoringGraphResponse.js';
72
76
  export { buildGraphExecutionRequestFromStudioExecute as buildAuthoringStudioGraphExecutionRequest } from '@x12i/graphenix-execute-envelope';
73
77
  // Testkit (in-memory loader, dep engine, recording client — sample NARRIX tasks: `@exellix/graph-engine/testkit`)
74
78
  export { InMemoryGraphLoader, DepGraphEngineFactory } from '../testkit/index.js';
@@ -462,6 +462,10 @@ export function inspectGraph(graph) {
462
462
  ioEdges,
463
463
  catalogs,
464
464
  graphDocumentModel: mergeGraphDocumentModel(graph),
465
+ response: {
466
+ source: 'graph.response',
467
+ definition: graph.response,
468
+ },
465
469
  virtualIO,
466
470
  };
467
471
  }
@@ -264,6 +264,11 @@ export type GraphInspection = {
264
264
  * Graph `metadata` (same shape as `variables.__graphModel` during execution).
265
265
  */
266
266
  graphDocumentModel: GraphDocumentMetadata;
267
+ /** Executable response definition source — always `graph.response` in 9.x. */
268
+ response: {
269
+ source: 'graph.response';
270
+ definition: import('../types/refs.js').GraphResponseDefinition;
271
+ };
267
272
  /**
268
273
  * Virtual Layer 01 / 08 nodes and edges for visualization; not part of executable `nodes` / `edges`.
269
274
  */
@@ -1,4 +1,5 @@
1
1
  import { getFinalizerNodeConfig, getTaskNodeConfig, isExecutableFinalizerNode, isExecutableTaskNode, } from '@x12i/graphenix-executable-contracts';
2
+ import { GRAPH_RESPONSE_LEGACY_SOURCE } from '../validation/authoringGraphResponse.js';
2
3
  function isPlainRecord(v) {
3
4
  return v != null && typeof v === 'object' && !Array.isArray(v);
4
5
  }
@@ -82,24 +83,40 @@ function edgesFromPlan(plan) {
82
83
  });
83
84
  }
84
85
  function responseFromEmbedded(doc, plan) {
85
- const metaResponse = doc.graph.metadata?.graphResponse;
86
- if (isPlainRecord(metaResponse)) {
86
+ const graphRecord = doc.graph;
87
+ const graphLevel = graphRecord.response;
88
+ if (isPlainRecord(graphLevel) && 'shape' in graphLevel) {
87
89
  const out = {
88
- shape: isPlainRecord(metaResponse.shape) ? structuredClone(metaResponse.shape) : {},
90
+ shape: isPlainRecord(graphLevel.shape) || Array.isArray(graphLevel.shape)
91
+ ? structuredClone(graphLevel.shape)
92
+ : graphLevel.shape ?? {},
89
93
  };
90
- if (metaResponse.missing !== undefined) {
91
- out.missing = metaResponse.missing;
92
- }
93
- if (metaResponse.version !== undefined) {
94
- out.version = metaResponse.version;
94
+ if (graphLevel.missing !== undefined) {
95
+ out.missing = graphLevel.missing;
95
96
  }
96
97
  return out;
97
98
  }
99
+ const metaResponse = doc.graph.metadata?.graphResponse;
100
+ if (isPlainRecord(metaResponse) && hasExecutableMetadataResponse(metaResponse)) {
101
+ const err = new Error('Executable response mapping exists only under metadata.graphResponse. Move shape to graph.response — studio metadata is UI-only and is not read at compile or execute.');
102
+ err.code = GRAPH_RESPONSE_LEGACY_SOURCE;
103
+ throw err;
104
+ }
98
105
  if (isPlainRecord(plan.contracts?.response?.finalOutputSchema)) {
99
106
  return { shape: { type: 'literal', value: {} } };
100
107
  }
101
108
  return { shape: {} };
102
109
  }
110
+ function hasExecutableMetadataResponse(metaResponse) {
111
+ const shape = metaResponse.shape;
112
+ if (shape === undefined)
113
+ return false;
114
+ if (isPlainRecord(shape))
115
+ return Object.keys(shape).length > 0;
116
+ if (Array.isArray(shape))
117
+ return shape.length > 0;
118
+ return true;
119
+ }
103
120
  /** Materialize exellix {@link Graph} from plan embedded normalized authoring graph. */
104
121
  export function embeddedGraphToExellixGraph(plan) {
105
122
  if (plan.normalizedGraph.mode !== 'embedded') {
@@ -100,8 +100,8 @@ export interface ExecuteGraphResult {
100
100
  nodeId?: string;
101
101
  error: any;
102
102
  }>;
103
- /** Final execution object (includes `_trace.nodes` for per-node timing). */
104
- execution?: unknown;
103
+ /** Final execution object (includes `_trace.nodes` for per-node timing). Always present on graph runs (FR-GE-005). */
104
+ execution: Record<string, unknown>;
105
105
  /** Final graph-engine-owned output memory accumulated from finalizer `outputMapping` writes. */
106
106
  outputsMemory?: unknown;
107
107
  runLog?: RunLogEntry[];
@@ -19,7 +19,7 @@ import { assertFinalizerRequiredReadsResolvable, validateGraphFinalizer, } from
19
19
  import { executeDeterministicFinalizer, executeSynthesizeFinalizer } from "./finalizers/executeFinalizer.js";
20
20
  import { createFinalizerError } from "./finalizers/errors.js";
21
21
  import { assertCanonicalGraphRuntimeObject } from "./validateCanonicalGraphRuntime.js";
22
- import { applyGraphResponseDefinition } from "./graphResponseMapping.js";
22
+ import { applyGraphResponseDefinition, assessGraphResponseCompleteness, GRAPH_RESPONSE_INCOMPLETE } from "./graphResponseMapping.js";
23
23
  import { buildAiTasksObservabilityRecord } from "./aiTasksObservability.js";
24
24
  import { buildRunTaskIdentityEnvelope, shouldForwardRunTaskTraceMode, } from "./runTaskAugments.js";
25
25
  import { buildRunLog, extractLogxerCorrelationFromMetadata, extractTaskRunLogFromMetadata, resolveRunLogLimits, } from "./buildRunLog.js";
@@ -1172,7 +1172,7 @@ export function createExellixGraphRuntime(opts) {
1172
1172
  logxerCorrelationId: logxerCorrelationIdLast,
1173
1173
  });
1174
1174
  return {
1175
- execution: currentExecution,
1175
+ execution: (isPlainRecord(currentExecution) ? currentExecution : {}),
1176
1176
  outputsMemory: currentOutputsMemory,
1177
1177
  ...built,
1178
1178
  };
@@ -1256,7 +1256,7 @@ export function createExellixGraphRuntime(opts) {
1256
1256
  const executionMemoryForResponse = isPlainRecord(currentExecution)
1257
1257
  ? structuredClone(currentExecution)
1258
1258
  : currentExecution;
1259
- return applyGraphResponseDefinition({
1259
+ const finalOutput = applyGraphResponseDefinition({
1260
1260
  response: graph.response,
1261
1261
  context: {
1262
1262
  graph,
@@ -1268,6 +1268,38 @@ export function createExellixGraphRuntime(opts) {
1268
1268
  stepsResponses,
1269
1269
  },
1270
1270
  });
1271
+ const completeness = assessGraphResponseCompleteness(graph.response, finalOutput);
1272
+ if (completeness.incomplete) {
1273
+ return {
1274
+ responseErrors: [
1275
+ {
1276
+ code: completeness.code ?? GRAPH_RESPONSE_INCOMPLETE,
1277
+ message: completeness.message ?? 'graph.response resolved empty.',
1278
+ },
1279
+ ],
1280
+ };
1281
+ }
1282
+ return finalOutput !== undefined ? { finalOutput } : {};
1283
+ }
1284
+ function resolveGraphRunFinalOutput(baseErrors = errors) {
1285
+ const resolved = buildFinalOutputFromGraphResponse();
1286
+ if (resolved.responseErrors?.length) {
1287
+ return {
1288
+ errors: [
1289
+ ...baseErrors,
1290
+ ...resolved.responseErrors.map((re) => {
1291
+ const e = new Error(re.message);
1292
+ e.code = re.code;
1293
+ return { error: e };
1294
+ }),
1295
+ ],
1296
+ responseFailed: true,
1297
+ };
1298
+ }
1299
+ return {
1300
+ finalOutput: resolved.finalOutput,
1301
+ errors: baseErrors,
1302
+ };
1271
1303
  }
1272
1304
  const playgroundReporter = runtime.playgroundReporter ?? opts.playgroundReporter;
1273
1305
  if (playgroundReporter) {
@@ -1314,7 +1346,7 @@ export function createExellixGraphRuntime(opts) {
1314
1346
  taskId: graphTaskId,
1315
1347
  error: err,
1316
1348
  });
1317
- const finalOutput = buildFinalOutputFromGraphResponse();
1349
+ const responseResolution = resolveGraphRunFinalOutput([...errors, { error: err }]);
1318
1350
  appendExecutionEvent(executionTrace, {
1319
1351
  id: `evt:${graphTaskId}:graph.failed`,
1320
1352
  ts: new Date().toISOString(),
@@ -1330,8 +1362,8 @@ export function createExellixGraphRuntime(opts) {
1330
1362
  outputsByNodeId,
1331
1363
  stepsResponses,
1332
1364
  engineSnapshot: engine.snapshot(),
1333
- ...(finalOutput !== undefined ? { finalOutput } : {}),
1334
- errors: [...errors, { error: err }],
1365
+ ...(responseResolution.finalOutput !== undefined ? { finalOutput: responseResolution.finalOutput } : {}),
1366
+ errors: responseResolution.errors,
1335
1367
  planAudit,
1336
1368
  trace: executionTrace,
1337
1369
  ...finalizeGraphPayload("failed"),
@@ -1350,8 +1382,8 @@ export function createExellixGraphRuntime(opts) {
1350
1382
  while (true) {
1351
1383
  const wavePlan = engine.plan();
1352
1384
  if (wavePlan.status === "completed") {
1353
- const graphStatus = errors.length ? "failed" : "completed";
1354
- const finalOutput = buildFinalOutputFromGraphResponse();
1385
+ const responseResolution = resolveGraphRunFinalOutput();
1386
+ const graphStatus = errors.length || responseResolution.responseFailed ? "failed" : "completed";
1355
1387
  appendExecutionEvent(executionTrace, {
1356
1388
  id: `evt:${graphTaskId}:graph.${graphStatus}`,
1357
1389
  ts: new Date().toISOString(),
@@ -1360,6 +1392,7 @@ export function createExellixGraphRuntime(opts) {
1360
1392
  message: `Graph execution ${graphStatus}.`,
1361
1393
  });
1362
1394
  validateExecutionTrace(executionTrace, plan);
1395
+ const resultErrors = responseResolution.errors.length ? responseResolution.errors : undefined;
1363
1396
  const result = {
1364
1397
  jobId,
1365
1398
  taskId: graphTaskId,
@@ -1368,9 +1401,9 @@ export function createExellixGraphRuntime(opts) {
1368
1401
  outputsByNodeId,
1369
1402
  stepsResponses,
1370
1403
  engineSnapshot: engine.snapshot(),
1371
- ...(finalOutput !== undefined ? { finalOutput } : {}),
1372
- ...(errors.length === 0 ? { finalizerNodeId, finalizerType: finalizer.finalizerType } : {}),
1373
- errors: errors.length ? errors : undefined,
1404
+ ...(responseResolution.finalOutput !== undefined ? { finalOutput: responseResolution.finalOutput } : {}),
1405
+ ...(graphStatus === "completed" ? { finalizerNodeId, finalizerType: finalizer.finalizerType } : {}),
1406
+ errors: resultErrors,
1374
1407
  planAudit,
1375
1408
  trace: executionTrace,
1376
1409
  ...finalizeGraphPayload(graphStatus),
@@ -1381,14 +1414,14 @@ export function createExellixGraphRuntime(opts) {
1381
1414
  if (eventEmitter) {
1382
1415
  if (graphStatus === "completed") {
1383
1416
  eventEmitter.emit(createGraphCompleteEvent(jobId, resolvedGraphId, graphTaskId, {
1384
- output: finalOutput,
1417
+ output: responseResolution.finalOutput,
1385
1418
  nodesExecuted: Object.keys(outputsByNodeId).length,
1386
1419
  finalMemory: { jobMemory: currentJobMemory, taskMemory: currentTaskMemory, execution: currentExecution, outputsMemory: currentOutputsMemory },
1387
1420
  ...(jobCorrelation ?? {}),
1388
1421
  }));
1389
1422
  }
1390
1423
  else {
1391
- eventEmitter.emit(createGraphFailEvent(jobId, resolvedGraphId, graphTaskId, errors[0]?.error, {
1424
+ eventEmitter.emit(createGraphFailEvent(jobId, resolvedGraphId, graphTaskId, responseResolution.errors[0]?.error ?? errors[0]?.error, {
1392
1425
  finalMemory: { jobMemory: currentJobMemory, taskMemory: currentTaskMemory, execution: currentExecution, outputsMemory: currentOutputsMemory },
1393
1426
  ...(jobCorrelation ?? {}),
1394
1427
  }));
@@ -1399,7 +1432,7 @@ export function createExellixGraphRuntime(opts) {
1399
1432
  if (wavePlan.status !== "continue") {
1400
1433
  const err = new Error(`GRAPH_BLOCKED: status=${wavePlan.status}`);
1401
1434
  err.code = "GRAPH_BLOCKED";
1402
- const finalOutput = buildFinalOutputFromGraphResponse();
1435
+ const responseResolution = resolveGraphRunFinalOutput([...errors, { error: err }]);
1403
1436
  const result = {
1404
1437
  jobId,
1405
1438
  taskId: graphTaskId,
@@ -1408,8 +1441,8 @@ export function createExellixGraphRuntime(opts) {
1408
1441
  outputsByNodeId,
1409
1442
  stepsResponses,
1410
1443
  engineSnapshot: engine.snapshot(),
1411
- ...(finalOutput !== undefined ? { finalOutput } : {}),
1412
- errors: [...errors, { error: err }],
1444
+ ...(responseResolution.finalOutput !== undefined ? { finalOutput: responseResolution.finalOutput } : {}),
1445
+ errors: responseResolution.errors,
1413
1446
  planAudit,
1414
1447
  trace: executionTrace,
1415
1448
  ...finalizeGraphPayload("failed"),
@@ -1651,7 +1684,7 @@ export function createExellixGraphRuntime(opts) {
1651
1684
  throw e;
1652
1685
  });
1653
1686
  if (failFast && errors.length) {
1654
- const finalOutput = buildFinalOutputFromGraphResponse();
1687
+ const responseResolution = resolveGraphRunFinalOutput();
1655
1688
  const result = {
1656
1689
  jobId,
1657
1690
  taskId: graphTaskId,
@@ -1660,8 +1693,8 @@ export function createExellixGraphRuntime(opts) {
1660
1693
  outputsByNodeId,
1661
1694
  stepsResponses,
1662
1695
  engineSnapshot: engine.snapshot(),
1663
- ...(finalOutput !== undefined ? { finalOutput } : {}),
1664
- errors,
1696
+ ...(responseResolution.finalOutput !== undefined ? { finalOutput: responseResolution.finalOutput } : {}),
1697
+ errors: responseResolution.errors,
1665
1698
  planAudit,
1666
1699
  trace: executionTrace,
1667
1700
  ...finalizeGraphPayload("failed"),
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Output-only coercion for graph.response selectors with `coerce: 'stringArray'`.
3
+ * Same semantics as graphs-playground normalizeQaAnswersArrayFinalOutput field rules.
4
+ */
5
+ export declare function coerceStringArray(value: unknown): unknown;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Output-only coercion for graph.response selectors with `coerce: 'stringArray'`.
3
+ * Same semantics as graphs-playground normalizeQaAnswersArrayFinalOutput field rules.
4
+ */
5
+ export function coerceStringArray(value) {
6
+ if (value === null || value === undefined)
7
+ return value;
8
+ if (Array.isArray(value)) {
9
+ return value
10
+ .filter((item) => item != null)
11
+ .map((item) => String(item).trim())
12
+ .filter((item) => item.length > 0);
13
+ }
14
+ if (typeof value !== 'string')
15
+ return value;
16
+ const trimmed = value.trim();
17
+ if (trimmed.length === 0)
18
+ return [];
19
+ if (trimmed.startsWith('[')) {
20
+ try {
21
+ const parsed = JSON.parse(trimmed);
22
+ if (Array.isArray(parsed)) {
23
+ return parsed
24
+ .filter((item) => item != null)
25
+ .map((item) => String(item).trim())
26
+ .filter((item) => item.length > 0);
27
+ }
28
+ }
29
+ catch {
30
+ // fall through to single-element wrap
31
+ }
32
+ }
33
+ return [trimmed];
34
+ }
@@ -18,6 +18,17 @@ export declare function applyGraphResponseDefinition(args: {
18
18
  response: GraphResponseDefinition;
19
19
  context: GraphResponseMappingContext;
20
20
  }): unknown;
21
+ /** Resolves root `graph.response` against accumulated execution memory (FR-GE-002). */
22
+ export declare const resolveGraphResponse: typeof applyGraphResponseDefinition;
21
23
  /** @deprecated Use applyGraphResponseDefinition with root-level graph.response. */
22
24
  export declare const applyGraphResponseMapping: typeof applyGraphResponseDefinition;
25
+ export declare const GRAPH_RESPONSE_INCOMPLETE: "GRAPH_RESPONSE_INCOMPLETE";
26
+ /**
27
+ * When graph.response.shape is non-empty but resolution yields an empty object, surface FR-GE-006.
28
+ */
29
+ export declare function assessGraphResponseCompleteness(response: GraphResponseDefinition, finalOutput: unknown): {
30
+ incomplete: boolean;
31
+ code?: typeof GRAPH_RESPONSE_INCOMPLETE;
32
+ message?: string;
33
+ };
23
34
  export {};
@@ -1,3 +1,4 @@
1
+ import { coerceStringArray } from './coerceStringArray.js';
1
2
  import { selectByPath } from './pathExpr.js';
2
3
  import { readTaskNodeModelInputSurface, } from './readTaskNodeInputsConfig.js';
3
4
  const GRAPH_RESPONSE_MAPPING_ERROR_CODE = 'GRAPH_RESPONSE_MAPPING_INVALID';
@@ -69,20 +70,31 @@ function materializeMissing(missing) {
69
70
  function presentOrMissing(value, missing) {
70
71
  return value === undefined || value === null ? materializeMissing(missing) : value;
71
72
  }
73
+ function applySelectorCoercion(value, selector, path) {
74
+ const coerce = selector.coerce;
75
+ if (coerce === undefined)
76
+ return value;
77
+ if (coerce !== 'stringArray') {
78
+ throw createGraphResponseMappingError(`Unsupported graph.response selector coercion "${String(coerce)}".`, path, { coerce });
79
+ }
80
+ return coerceStringArray(value);
81
+ }
72
82
  function resolveSelector(selector, context, path, missing) {
73
83
  if (selector.type === 'literal') {
74
84
  if (!Object.prototype.hasOwnProperty.call(selector, 'value')) {
75
85
  throw createGraphResponseMappingError('literal selector requires a value field.', `${path}.value`);
76
86
  }
77
- return selector.value;
87
+ return applySelectorCoercion(selector.value, selector, path);
78
88
  }
79
89
  if (selector.type === 'outputsMemoryPath') {
80
90
  const sourcePath = assertNonEmptyString(selector.path, `${path}.path`, `${selector.type}.path`);
81
- return presentOrMissing(selectByPath(context.outputsMemory, sourcePath), missing);
91
+ const resolved = presentOrMissing(selectByPath(context.outputsMemory, sourcePath), missing);
92
+ return resolved === OMIT ? resolved : applySelectorCoercion(resolved, selector, path);
82
93
  }
83
94
  if (selector.type === 'executionMemoryPath' || selector.type === 'executionPath') {
84
95
  const sourcePath = assertNonEmptyString(selector.path, `${path}.path`, `${selector.type}.path`);
85
- return presentOrMissing(selectByPath(context.executionMemory, sourcePath), missing);
96
+ const resolved = presentOrMissing(selectByPath(context.executionMemory, sourcePath), missing);
97
+ return resolved === OMIT ? resolved : applySelectorCoercion(resolved, selector, path);
86
98
  }
87
99
  if (selector.type === 'nodeMetadata') {
88
100
  const nodeId = assertNonEmptyString(selector.nodeId, `${path}.nodeId`, `${selector.type}.nodeId`);
@@ -91,7 +103,8 @@ function resolveSelector(selector, context, path, missing) {
91
103
  if (!node) {
92
104
  throw createGraphResponseMappingError(`${selector.type} selector references missing nodeId "${nodeId}".`, `${path}.nodeId`, { nodeId });
93
105
  }
94
- return presentOrMissing(selectByPath(node.metadata, sourcePath), missing);
106
+ const resolved = presentOrMissing(selectByPath(node.metadata, sourcePath), missing);
107
+ return resolved === OMIT ? resolved : applySelectorCoercion(resolved, selector, path);
95
108
  }
96
109
  if (selector.type === 'nodeInputsConfig') {
97
110
  const inputSelector = selector;
@@ -102,7 +115,8 @@ function resolveSelector(selector, context, path, missing) {
102
115
  throw createGraphResponseMappingError(`${selector.type} selector references missing nodeId "${nodeId}".`, `${path}.nodeId`, { nodeId });
103
116
  }
104
117
  const root = readTaskNodeModelInputSurface(node);
105
- return presentOrMissing(selectByPath(root, sourcePath), missing);
118
+ const resolved = presentOrMissing(selectByPath(root, sourcePath), missing);
119
+ return resolved === OMIT ? resolved : applySelectorCoercion(resolved, selector, path);
106
120
  }
107
121
  if (selector.type === 'firstPresent') {
108
122
  if (!Array.isArray(selector.sources) || selector.sources.length === 0) {
@@ -114,10 +128,12 @@ function resolveSelector(selector, context, path, missing) {
114
128
  throw createGraphResponseMappingError('firstPresent.sources entries must be graph response mapping selectors.', `${path}.sources.${i}`);
115
129
  }
116
130
  const value = resolveSelector(source, context, `${path}.sources.${i}`, 'omit');
117
- if (value !== OMIT && value !== null)
118
- return value;
131
+ if (value !== OMIT && value !== null) {
132
+ return applySelectorCoercion(value, selector, path);
133
+ }
119
134
  }
120
- return materializeMissing(missing);
135
+ const missingValue = materializeMissing(missing);
136
+ return missingValue === OMIT ? missingValue : applySelectorCoercion(missingValue, selector, path);
121
137
  }
122
138
  throw createGraphResponseMappingError(`Unsupported graph response mapping selector type "${String(selector.type)}".`, `${path}.type`);
123
139
  }
@@ -151,5 +167,42 @@ export function applyGraphResponseDefinition(args) {
151
167
  const mapped = resolveShape(args.response.shape, args.context, 'graph.response.shape', missing);
152
168
  return mapped === OMIT ? undefined : mapped;
153
169
  }
170
+ /** Resolves root `graph.response` against accumulated execution memory (FR-GE-002). */
171
+ export const resolveGraphResponse = applyGraphResponseDefinition;
154
172
  /** @deprecated Use applyGraphResponseDefinition with root-level graph.response. */
155
173
  export const applyGraphResponseMapping = applyGraphResponseDefinition;
174
+ export const GRAPH_RESPONSE_INCOMPLETE = 'GRAPH_RESPONSE_INCOMPLETE';
175
+ function isNonEmptyResponseShape(shape) {
176
+ if (shape == null)
177
+ return false;
178
+ if (Array.isArray(shape))
179
+ return shape.length > 0;
180
+ if (isPlainObject(shape))
181
+ return Object.keys(shape).length > 0;
182
+ return isSupportedSelector(shape);
183
+ }
184
+ function isEmptyResolvedOutput(value) {
185
+ if (value === undefined || value === null)
186
+ return true;
187
+ if (Array.isArray(value))
188
+ return value.length === 0;
189
+ if (isPlainObject(value))
190
+ return Object.keys(value).length === 0;
191
+ return false;
192
+ }
193
+ /**
194
+ * When graph.response.shape is non-empty but resolution yields an empty object, surface FR-GE-006.
195
+ */
196
+ export function assessGraphResponseCompleteness(response, finalOutput) {
197
+ if (!isNonEmptyResponseShape(response.shape)) {
198
+ return { incomplete: false };
199
+ }
200
+ if (!isEmptyResolvedOutput(finalOutput)) {
201
+ return { incomplete: false };
202
+ }
203
+ return {
204
+ incomplete: true,
205
+ code: GRAPH_RESPONSE_INCOMPLETE,
206
+ message: 'graph.response.shape is non-empty but finalOutput resolved empty — check executionMapping paths and selectors.',
207
+ };
208
+ }
@@ -40,6 +40,8 @@ const FORBIDDEN_GRAPH_RESPONSE_KEYS = [
40
40
  'notableExecutionPaths',
41
41
  'mappingPreset',
42
42
  ];
43
+ const LEGACY_RESPONSE_SHAPE_KEYS = new Set(['subnetAnalysis', 'taskSections']);
44
+ const LEGACY_EXECUTION_MAPPING_PATH_RE = /^inference\.conceptSketch(\.|$)/;
43
45
  /**
44
46
  * Returns top-level keys on `graph` that are not part of the canonical executable document contract.
45
47
  */
@@ -431,6 +433,11 @@ function assertGraphResponseShapeNoLegacy(shape, path, context) {
431
433
  }
432
434
  if (!isPlainObject(shape))
433
435
  return;
436
+ for (const key of Object.keys(shape)) {
437
+ if (LEGACY_RESPONSE_SHAPE_KEYS.has(key)) {
438
+ throw new ExellixGraphError(ExellixGraphErrorCode.NON_CANONICAL_GRAPH_DOCUMENT, `${path}.${key} is a removed legacy response shape key.`, { ...context, responseShapeKey: key });
439
+ }
440
+ }
434
441
  if (shape.type === 'nodeInputs') {
435
442
  throw new ExellixGraphError(ExellixGraphErrorCode.NON_CANONICAL_GRAPH_DOCUMENT, `${path}.type "nodeInputs" was removed; use nodeInputsConfig.`, context);
436
443
  }
@@ -478,7 +485,21 @@ export function assertCanonicalTaskNode(node, context, options) {
478
485
  if (!isTaskShape(node))
479
486
  return;
480
487
  if ('outputMapping' in node) {
481
- throw new ExellixGraphError(ExellixGraphErrorCode.NON_CANONICAL_TASK_NODE, `Task node "${String(node.id)}": outputMapping belongs on the finalizer. Use executionMapping to write task results into executionMemory.`, { jobId: context?.jobId, graphId: context?.graphId, nodeId: String(node.id) });
488
+ throw new ExellixGraphError(ExellixGraphErrorCode.NON_CANONICAL_TASK_NODE, `Task node "${String(node.id)}": TASK_OUTPUT_MAPPING_DEPRECATED — outputMapping belongs on the finalizer. Use executionMapping to write task results into executionMemory.`, { jobId: context?.jobId, graphId: context?.graphId, nodeId: String(node.id) });
489
+ }
490
+ const executionMapping = node.executionMapping;
491
+ if (executionMapping?.path &&
492
+ typeof executionMapping.path === 'string' &&
493
+ LEGACY_EXECUTION_MAPPING_PATH_RE.test(executionMapping.path)) {
494
+ throw new ExellixGraphError(ExellixGraphErrorCode.NON_CANONICAL_TASK_NODE, `Task node "${String(node.id)}": EXECUTION_MAPPING_LEGACY_PATH — use answers.qN instead of inference.conceptSketch.stepN.`, { jobId: context?.jobId, graphId: context?.graphId, nodeId: String(node.id) });
495
+ }
496
+ const map = executionMapping?.map;
497
+ if (isPlainObject(map)) {
498
+ for (const targetKey of Object.keys(map)) {
499
+ if (targetKey.includes('.')) {
500
+ throw new ExellixGraphError(ExellixGraphErrorCode.NON_CANONICAL_TASK_NODE, `Task node "${String(node.id)}": TASK_MAP_TARGET_INVALID — executionMapping.map target "${targetKey}" must be flat.`, { jobId: context?.jobId, graphId: context?.graphId, nodeId: String(node.id) });
501
+ }
502
+ }
482
503
  }
483
504
  assertOptionalTaskNodeConditions(node.conditions, String(node.id), {
484
505
  jobId: context?.jobId,
@@ -670,6 +691,10 @@ export function assertCanonicalGraphDocument(graph, context, options) {
670
691
  jobId: context?.jobId,
671
692
  graphId: resolvedGraphId,
672
693
  });
694
+ const preset = metadata.responsePreset;
695
+ if (isPlainObject(preset) && preset.id === 'subnetAnalysis') {
696
+ throw new ExellixGraphError(ExellixGraphErrorCode.NON_CANONICAL_GRAPH_DOCUMENT, 'metadata.responsePreset.id "subnetAnalysis" was removed — migrate to graph.response.shape.', { jobId: context?.jobId, graphId: resolvedGraphId });
697
+ }
673
698
  const graphEntry = metadata.graphEntry;
674
699
  if (graphEntry != null && typeof graphEntry === 'object' && !Array.isArray(graphEntry)) {
675
700
  const ge = graphEntry;
@@ -133,30 +133,35 @@ export type FinalizerInputBinding = {
133
133
  value: unknown;
134
134
  };
135
135
  export type GraphResponseMissingBehavior = 'omit' | 'null';
136
- export type GraphResponseSelector = {
136
+ /** Post-resolution output coercion (9.x). Only `stringArray` is supported in 9.0. */
137
+ export type GraphResponseCoercion = 'stringArray';
138
+ type GraphResponseSelectorBase = {
139
+ coerce?: GraphResponseCoercion;
140
+ };
141
+ export type GraphResponseSelector = (GraphResponseSelectorBase & {
137
142
  type: 'outputsMemoryPath';
138
143
  path: string;
139
- } | {
144
+ }) | (GraphResponseSelectorBase & {
140
145
  type: 'executionMemoryPath';
141
146
  path: string;
142
- } | {
147
+ }) | (GraphResponseSelectorBase & {
143
148
  type: 'executionPath';
144
149
  path: string;
145
- } | {
150
+ }) | (GraphResponseSelectorBase & {
146
151
  type: 'nodeMetadata';
147
152
  nodeId: string;
148
153
  path: string;
149
- } | {
154
+ }) | (GraphResponseSelectorBase & {
150
155
  type: 'nodeInputsConfig';
151
156
  nodeId: string;
152
157
  path: string;
153
- } | {
158
+ }) | (GraphResponseSelectorBase & {
154
159
  type: 'literal';
155
160
  value: unknown;
156
- } | {
161
+ }) | (GraphResponseSelectorBase & {
157
162
  type: 'firstPresent';
158
163
  sources: GraphResponseSelector[];
159
- };
164
+ });
160
165
  export type GraphResponseShape = unknown;
161
166
  export type GraphResponseDefinition = {
162
167
  /** Default: "omit". When "null", missing selector values are materialized as null. */
@@ -0,0 +1,21 @@
1
+ import type { AuthoringGraphDocument } from '@x12i/graphenix-executable-contracts';
2
+ import type { GraphResponseDefinition } from '../types/refs.js';
3
+ export declare const GRAPH_RESPONSE_LEGACY_SOURCE: "GRAPH_RESPONSE_LEGACY_SOURCE";
4
+ export declare const GRAPH_RESPONSE_DUAL_SOURCE: "GRAPH_RESPONSE_DUAL_SOURCE";
5
+ export type AuthoringResponseSourceIssue = {
6
+ code: typeof GRAPH_RESPONSE_LEGACY_SOURCE | typeof GRAPH_RESPONSE_DUAL_SOURCE;
7
+ message: string;
8
+ path?: string;
9
+ };
10
+ /**
11
+ * Import-only helper: copy metadata.graphResponse → graph.response when graph.response is absent.
12
+ * Does not run on the execute hot path.
13
+ */
14
+ export declare function migrateLegacyGraphResponseToAuthoring(doc: AuthoringGraphDocument): AuthoringGraphDocument;
15
+ /** Detect legacy-only or dual authoring response sources (FR-GE-001 / CR-GE-001). */
16
+ export declare function detectAuthoringResponseSourceIssues(doc: AuthoringGraphDocument): AuthoringResponseSourceIssue[];
17
+ /**
18
+ * Resolves the single canonical authoring response definition.
19
+ * @throws Error with code GRAPH_RESPONSE_LEGACY_SOURCE when only legacy metadata carries the shape.
20
+ */
21
+ export declare function resolveAuthoringGraphResponse(doc: AuthoringGraphDocument): GraphResponseDefinition;
@@ -0,0 +1,88 @@
1
+ export const GRAPH_RESPONSE_LEGACY_SOURCE = 'GRAPH_RESPONSE_LEGACY_SOURCE';
2
+ export const GRAPH_RESPONSE_DUAL_SOURCE = 'GRAPH_RESPONSE_DUAL_SOURCE';
3
+ /**
4
+ * Import-only helper: copy metadata.graphResponse → graph.response when graph.response is absent.
5
+ * Does not run on the execute hot path.
6
+ */
7
+ export function migrateLegacyGraphResponseToAuthoring(doc) {
8
+ const next = structuredClone(doc);
9
+ const graph = next.graph;
10
+ const hasGraphResponse = isPlainRecord(graph.response) && 'shape' in graph.response;
11
+ const metaResponse = next.graph.metadata?.graphResponse;
12
+ if (!hasGraphResponse && isPlainRecord(metaResponse) && 'shape' in metaResponse) {
13
+ graph.response = {
14
+ ...(metaResponse.missing !== undefined ? { missing: metaResponse.missing } : {}),
15
+ shape: structuredClone(metaResponse.shape),
16
+ };
17
+ }
18
+ return next;
19
+ }
20
+ function isPlainRecord(v) {
21
+ return v != null && typeof v === 'object' && !Array.isArray(v);
22
+ }
23
+ function hasExecutableResponseShape(response) {
24
+ if (!isPlainRecord(response))
25
+ return false;
26
+ const shape = response.shape;
27
+ if (shape === undefined)
28
+ return false;
29
+ if (isPlainRecord(shape))
30
+ return Object.keys(shape).length > 0;
31
+ if (Array.isArray(shape))
32
+ return shape.length > 0;
33
+ return true;
34
+ }
35
+ function readGraphLevelResponse(doc) {
36
+ const graph = doc.graph;
37
+ const response = graph.response;
38
+ if (!isPlainRecord(response) || !('shape' in response))
39
+ return undefined;
40
+ return response;
41
+ }
42
+ function readMetadataGraphResponse(doc) {
43
+ const metaResponse = doc.graph.metadata?.graphResponse;
44
+ if (!isPlainRecord(metaResponse) || !('shape' in metaResponse))
45
+ return undefined;
46
+ return metaResponse;
47
+ }
48
+ /** Detect legacy-only or dual authoring response sources (FR-GE-001 / CR-GE-001). */
49
+ export function detectAuthoringResponseSourceIssues(doc) {
50
+ const issues = [];
51
+ const graphResponse = readGraphLevelResponse(doc);
52
+ const metaResponse = readMetadataGraphResponse(doc);
53
+ const graphHasShape = graphResponse != null && hasExecutableResponseShape(graphResponse);
54
+ const metaHasShape = metaResponse != null && hasExecutableResponseShape(metaResponse);
55
+ if (!graphHasShape && metaHasShape) {
56
+ issues.push({
57
+ code: GRAPH_RESPONSE_LEGACY_SOURCE,
58
+ message: 'Executable response mapping exists only under metadata.graphResponse. Move shape to graph.response — studio metadata is UI-only and is not read at compile or execute.',
59
+ path: 'graph.metadata.graphResponse',
60
+ });
61
+ }
62
+ if (graphHasShape && metaHasShape) {
63
+ issues.push({
64
+ code: GRAPH_RESPONSE_DUAL_SOURCE,
65
+ message: 'Response mapping is declared on both graph.response and metadata.graphResponse. graph.response is authoritative; remove executable shape from metadata.graphResponse.',
66
+ path: 'graph.metadata.graphResponse',
67
+ });
68
+ }
69
+ return issues;
70
+ }
71
+ /**
72
+ * Resolves the single canonical authoring response definition.
73
+ * @throws Error with code GRAPH_RESPONSE_LEGACY_SOURCE when only legacy metadata carries the shape.
74
+ */
75
+ export function resolveAuthoringGraphResponse(doc) {
76
+ const issues = detectAuthoringResponseSourceIssues(doc);
77
+ const blocking = issues.find((i) => i.code === GRAPH_RESPONSE_LEGACY_SOURCE);
78
+ if (blocking) {
79
+ const err = new Error(blocking.message);
80
+ err.code = GRAPH_RESPONSE_LEGACY_SOURCE;
81
+ err.path = blocking.path;
82
+ throw err;
83
+ }
84
+ const graphResponse = readGraphLevelResponse(doc);
85
+ if (graphResponse)
86
+ return graphResponse;
87
+ return { shape: {} };
88
+ }
@@ -0,0 +1,26 @@
1
+ import type { AuthoringGraphDocument, ExecutableGraphPlanV2 } from '@x12i/graphenix-executable-contracts';
2
+ import type { Graph } from '../types/refs.js';
3
+ export declare const RESPONSE_PATH_NO_WRITER: "RESPONSE_PATH_NO_WRITER";
4
+ export declare const TASK_MAP_TARGET_INVALID: "TASK_MAP_TARGET_INVALID";
5
+ export declare const TASK_MAP_SOURCE_INVALID: "TASK_MAP_SOURCE_INVALID";
6
+ export declare const RESPONSE_LEGACY_SHAPE_KEY: "RESPONSE_LEGACY_SHAPE_KEY";
7
+ export declare const EXECUTION_MAPPING_LEGACY_PATH: "EXECUTION_MAPPING_LEGACY_PATH";
8
+ export declare const RESPONSE_EMPTY_SHAPE: "RESPONSE_EMPTY_SHAPE";
9
+ export declare const RESPONSE_LEGACY_PRESET_ID: "RESPONSE_LEGACY_PRESET_ID";
10
+ export declare const RESPONSE_COERCE_STRING_ARRAY_ON_SCALAR: "RESPONSE_COERCE_STRING_ARRAY_ON_SCALAR";
11
+ export type GraphResponseWiringIssue = {
12
+ code: string;
13
+ message: string;
14
+ nodeId?: string;
15
+ path?: string;
16
+ };
17
+ export type GraphResponseWiringValidation = {
18
+ ok: boolean;
19
+ errors: GraphResponseWiringIssue[];
20
+ warnings: GraphResponseWiringIssue[];
21
+ };
22
+ /** Validate response wiring for an authoring document, executable plan, or materialized graph. */
23
+ export declare function validateGraphResponseWiring(input: AuthoringGraphDocument | ExecutableGraphPlanV2 | Graph): GraphResponseWiringValidation;
24
+ export declare function assertGraphResponseWiringOk(validation: GraphResponseWiringValidation, context?: {
25
+ graphId?: string;
26
+ }): void;
@@ -0,0 +1,233 @@
1
+ import { embeddedGraphToExellixGraph } from '../plan/embeddedGraphToExellixGraph.js';
2
+ import { detectAuthoringResponseSourceIssues, GRAPH_RESPONSE_DUAL_SOURCE, GRAPH_RESPONSE_LEGACY_SOURCE, } from './authoringGraphResponse.js';
3
+ export const RESPONSE_PATH_NO_WRITER = 'RESPONSE_PATH_NO_WRITER';
4
+ export const TASK_MAP_TARGET_INVALID = 'TASK_MAP_TARGET_INVALID';
5
+ export const TASK_MAP_SOURCE_INVALID = 'TASK_MAP_SOURCE_INVALID';
6
+ export const RESPONSE_LEGACY_SHAPE_KEY = 'RESPONSE_LEGACY_SHAPE_KEY';
7
+ export const EXECUTION_MAPPING_LEGACY_PATH = 'EXECUTION_MAPPING_LEGACY_PATH';
8
+ export const RESPONSE_EMPTY_SHAPE = 'RESPONSE_EMPTY_SHAPE';
9
+ export const RESPONSE_LEGACY_PRESET_ID = 'RESPONSE_LEGACY_PRESET_ID';
10
+ export const RESPONSE_COERCE_STRING_ARRAY_ON_SCALAR = 'RESPONSE_COERCE_STRING_ARRAY_ON_SCALAR';
11
+ const LEGACY_SHAPE_KEYS = new Set(['subnetAnalysis', 'taskSections']);
12
+ const LEGACY_EXECUTION_PATH_RE = /^inference\.conceptSketch(\.|$)/;
13
+ const KNOWN_MAP_SOURCE_PREFIXES = ['output.', 'parsed.', 'node.', 'variables.', 'jobMemory.'];
14
+ function isPlainRecord(v) {
15
+ return v != null && typeof v === 'object' && !Array.isArray(v);
16
+ }
17
+ function isSupportedSelector(v) {
18
+ return isPlainRecord(v) && typeof v.type === 'string';
19
+ }
20
+ function getGraphNodes(graph) {
21
+ return Array.isArray(graph.nodes) ? graph.nodes : [];
22
+ }
23
+ function isTaskNode(node) {
24
+ return node.type !== 'finalizer';
25
+ }
26
+ function isFinalizerNode(node) {
27
+ return node.type === 'finalizer';
28
+ }
29
+ function isShapeEmpty(shape) {
30
+ if (shape == null)
31
+ return true;
32
+ if (Array.isArray(shape))
33
+ return shape.length === 0;
34
+ if (isPlainRecord(shape))
35
+ return Object.keys(shape).length === 0;
36
+ return false;
37
+ }
38
+ function hasTerminalFinalizer(graph) {
39
+ const nodes = getGraphNodes(graph);
40
+ const finalizers = nodes.filter(isFinalizerNode);
41
+ if (finalizers.length === 0)
42
+ return false;
43
+ const edges = graph.edges ?? [];
44
+ const targeted = new Set(edges.map((e) => e.to));
45
+ return finalizers.some((f) => targeted.has(f.id) || edges.some((e) => e.to === f.id));
46
+ }
47
+ function collectTaskWritePaths(graph) {
48
+ const out = [];
49
+ for (const node of getGraphNodes(graph)) {
50
+ if (!isTaskNode(node))
51
+ continue;
52
+ const em = node.executionMapping;
53
+ if (em?.path && typeof em.path === 'string' && em.path.length > 0) {
54
+ out.push({ nodeId: String(node.id), path: em.path });
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+ function pathHasWriter(selectorPath, writePaths) {
60
+ return writePaths.some(({ path }) => selectorPath === path || selectorPath.startsWith(`${path}.`));
61
+ }
62
+ function collectSelectors(shape, path, out, legacyKeys) {
63
+ if (isSupportedSelector(shape)) {
64
+ out.push({ selector: shape, path });
65
+ if (shape.type === 'firstPresent' && Array.isArray(shape.sources)) {
66
+ shape.sources.forEach((source, index) => collectSelectors(source, `${path}.sources[${index}]`, out, legacyKeys));
67
+ }
68
+ return;
69
+ }
70
+ if (Array.isArray(shape)) {
71
+ shape.forEach((item, index) => collectSelectors(item, `${path}[${index}]`, out, legacyKeys));
72
+ return;
73
+ }
74
+ if (isPlainRecord(shape)) {
75
+ for (const [key, val] of Object.entries(shape)) {
76
+ if (LEGACY_SHAPE_KEYS.has(key)) {
77
+ legacyKeys.push({
78
+ code: RESPONSE_LEGACY_SHAPE_KEY,
79
+ message: `graph.response.shape must not use legacy key "${key}".`,
80
+ path: `${path}.${key}`,
81
+ });
82
+ }
83
+ collectSelectors(val, `${path}.${key}`, out, legacyKeys);
84
+ }
85
+ }
86
+ }
87
+ function isValidMapSource(source) {
88
+ if (source.startsWith('"') || source.startsWith('\\"'))
89
+ return true;
90
+ if (KNOWN_MAP_SOURCE_PREFIXES.some((prefix) => source.startsWith(prefix)))
91
+ return true;
92
+ // Shorthand leaf keys resolve against task output at runtime.
93
+ if (!source.includes('.'))
94
+ return true;
95
+ return false;
96
+ }
97
+ function validateTaskExecutionMapping(node, errors, warnings) {
98
+ const em = node.executionMapping;
99
+ if (!em?.path)
100
+ return;
101
+ const nodeId = String(node.id);
102
+ if (LEGACY_EXECUTION_PATH_RE.test(em.path)) {
103
+ errors.push({
104
+ code: EXECUTION_MAPPING_LEGACY_PATH,
105
+ message: `Task node "${nodeId}" uses legacy executionMapping.path "${em.path}". Use answers.qN (or another flat namespace) instead of inference.conceptSketch.stepN.`,
106
+ nodeId,
107
+ path: `nodes.${nodeId}.executionMapping.path`,
108
+ });
109
+ }
110
+ const map = em.map;
111
+ if (!isPlainRecord(map))
112
+ return;
113
+ for (const [targetKey, sourcePath] of Object.entries(map)) {
114
+ if (targetKey.includes('.')) {
115
+ errors.push({
116
+ code: TASK_MAP_TARGET_INVALID,
117
+ message: `Task node "${nodeId}" executionMapping.map target "${targetKey}" must be flat — use graph.response.shape for nesting.`,
118
+ nodeId,
119
+ path: `nodes.${nodeId}.executionMapping.map.${targetKey}`,
120
+ });
121
+ }
122
+ if (typeof sourcePath === 'string' && !isValidMapSource(sourcePath)) {
123
+ errors.push({
124
+ code: TASK_MAP_SOURCE_INVALID,
125
+ message: `Task node "${nodeId}" executionMapping.map source "${sourcePath}" is not under a known task output root (output., parsed., node., variables., jobMemory., or shorthand leaf).`,
126
+ nodeId,
127
+ path: `nodes.${nodeId}.executionMapping.map.${targetKey}`,
128
+ });
129
+ }
130
+ }
131
+ }
132
+ function validateResponseShape(graph, errors, warnings) {
133
+ const response = graph.response;
134
+ if (!isPlainRecord(response))
135
+ return;
136
+ const shape = response.shape;
137
+ if (isShapeEmpty(shape) && hasTerminalFinalizer(graph)) {
138
+ errors.push({
139
+ code: RESPONSE_EMPTY_SHAPE,
140
+ message: 'graph.response.shape is empty but the graph has a terminal finalizer — declare response selectors or remove the finalizer.',
141
+ path: 'graph.response.shape',
142
+ });
143
+ }
144
+ const writePaths = collectTaskWritePaths(graph);
145
+ const selectors = [];
146
+ const legacyShapeIssues = [];
147
+ collectSelectors(shape, 'graph.response.shape', selectors, legacyShapeIssues);
148
+ errors.push(...legacyShapeIssues);
149
+ for (const { selector, path } of selectors) {
150
+ if (selector.coerce === 'stringArray' && /shortAnswer/i.test(path)) {
151
+ warnings.push({
152
+ code: RESPONSE_COERCE_STRING_ARRAY_ON_SCALAR,
153
+ message: `coerce: "stringArray" on scalar field at ${path} is unusual — shortAnswer is string-typed by contract.`,
154
+ path,
155
+ });
156
+ }
157
+ const memoryPath = selector.type === 'executionMemoryPath' || selector.type === 'executionPath'
158
+ ? selector.path
159
+ : undefined;
160
+ if (memoryPath && !pathHasWriter(memoryPath, writePaths)) {
161
+ errors.push({
162
+ code: RESPONSE_PATH_NO_WRITER,
163
+ message: `graph.response selector path "${memoryPath}" has no upstream task executionMapping.path prefix.`,
164
+ path,
165
+ });
166
+ }
167
+ }
168
+ }
169
+ function validateMetadataLegacyPreset(graph, errors) {
170
+ const metadata = graph.metadata;
171
+ if (!isPlainRecord(metadata))
172
+ return;
173
+ const preset = isPlainRecord(metadata.responsePreset) ? metadata.responsePreset : undefined;
174
+ if (preset?.id === 'subnetAnalysis') {
175
+ errors.push({
176
+ code: RESPONSE_LEGACY_PRESET_ID,
177
+ message: 'metadata.responsePreset.id "subnetAnalysis" was removed — migrate to graph.response.shape.',
178
+ path: 'metadata.responsePreset.id',
179
+ });
180
+ }
181
+ }
182
+ function validateGraphModel(graph) {
183
+ const errors = [];
184
+ const warnings = [];
185
+ validateMetadataLegacyPreset(graph, errors);
186
+ for (const node of getGraphNodes(graph)) {
187
+ if (isTaskNode(node))
188
+ validateTaskExecutionMapping(node, errors, warnings);
189
+ }
190
+ validateResponseShape(graph, errors, warnings);
191
+ return { ok: errors.length === 0, errors, warnings };
192
+ }
193
+ function graphFromPlan(plan) {
194
+ return embeddedGraphToExellixGraph(plan);
195
+ }
196
+ /** Validate response wiring for an authoring document, executable plan, or materialized graph. */
197
+ export function validateGraphResponseWiring(input) {
198
+ const errors = [];
199
+ const warnings = [];
200
+ if ('formatVersion' in input && 'graph' in input) {
201
+ const doc = input;
202
+ for (const issue of detectAuthoringResponseSourceIssues(doc)) {
203
+ if (issue.code === GRAPH_RESPONSE_LEGACY_SOURCE) {
204
+ errors.push({ code: issue.code, message: issue.message, path: issue.path });
205
+ }
206
+ else if (issue.code === GRAPH_RESPONSE_DUAL_SOURCE) {
207
+ warnings.push({ code: issue.code, message: issue.message, path: issue.path });
208
+ }
209
+ }
210
+ const stubGraph = {
211
+ id: doc.id,
212
+ nodes: [],
213
+ edges: [],
214
+ response: doc.graph.response ?? { shape: {} },
215
+ metadata: doc.graph.metadata,
216
+ };
217
+ validateMetadataLegacyPreset(stubGraph, errors);
218
+ return { ok: errors.length === 0, errors, warnings };
219
+ }
220
+ if ('normalizedGraph' in input && 'source' in input) {
221
+ return validateGraphModel(graphFromPlan(input));
222
+ }
223
+ return validateGraphModel(input);
224
+ }
225
+ export function assertGraphResponseWiringOk(validation, context) {
226
+ if (validation.ok)
227
+ return;
228
+ const summary = validation.errors.map((e) => `${e.code}: ${e.message}`).join('; ');
229
+ const err = new Error(`Graph response wiring validation failed${context?.graphId ? ` for "${context.graphId}"` : ''}: ${summary}`);
230
+ err.code = validation.errors[0]?.code ?? 'GRAPH_RESPONSE_WIRING_INVALID';
231
+ err.details = validation;
232
+ throw err;
233
+ }
@@ -12,6 +12,7 @@ function authoringShell(graphId, nodes, edges, graphResponseShape, extra) {
12
12
  graph: {
13
13
  nodes,
14
14
  edges,
15
+ response: authoringResponseBlock(graphResponseShape),
15
16
  inputs: taskNodes.map((node) => ({
16
17
  id: `graph-input:${node.id}`,
17
18
  name: 'Input Record',
@@ -31,7 +32,6 @@ function authoringShell(graphId, nodes, edges, graphResponseShape, extra) {
31
32
  ]
32
33
  : [],
33
34
  metadata: {
34
- graphResponse: { shape: graphResponseShape },
35
35
  modelConfig: defaultExecutableModelConfig(),
36
36
  ...extra,
37
37
  },
@@ -39,6 +39,9 @@ function authoringShell(graphId, nodes, edges, graphResponseShape, extra) {
39
39
  types: [],
40
40
  };
41
41
  }
42
+ function authoringResponseBlock(graphResponseShape) {
43
+ return { shape: graphResponseShape };
44
+ }
42
45
  /** Single ai-task + select finalizer — mirrors legacy flat `buildAiTaskGraph` tests. */
43
46
  export function buildAiTaskAuthoringGraph(graphId) {
44
47
  const taskId = 'ask-1';
@@ -6,7 +6,9 @@ function normalizeAuthoringDocument(input) {
6
6
  /** Builds `{ plan, runtime }` for tests and hosts with a Graphenix 2.x {@link AuthoringGraphDocument}. */
7
7
  export function buildExecuteGraphInput(doc, runtime) {
8
8
  return {
9
- plan: compileExellixExecutablePlan(normalizeAuthoringDocument(doc), runtime),
9
+ plan: compileExellixExecutablePlan(normalizeAuthoringDocument(doc), runtime, {
10
+ strictResponseValidation: false,
11
+ }),
10
12
  runtime,
11
13
  };
12
14
  }
@@ -228,11 +228,6 @@ export function flatTestGraphToAuthoringDocument(flat) {
228
228
  metadata.jobKnowledge = normalizeKnowledgeRefs(flat.jobKnowledge);
229
229
  if (Array.isArray(flat.taskKnowledge))
230
230
  metadata.taskKnowledge = normalizeKnowledgeRefs(flat.taskKnowledge);
231
- metadata.graphResponse = {
232
- ...(flatResponse.missing != null ? { missing: flatResponse.missing } : {}),
233
- ...(flatResponse.version != null ? { version: flatResponse.version } : {}),
234
- shape: responseShape,
235
- };
236
231
  metadata.modelConfig = flatModelConfigToAuthoring(flat.modelConfig) ?? defaultExecutableModelConfig();
237
232
  const taskNodes = nodes.filter((n) => n.kind === TASK_NODE_KIND);
238
233
  const finalizer = nodes.find((n) => n.kind === FINALIZER_NODE_KIND);
@@ -244,6 +239,11 @@ export function flatTestGraphToAuthoringDocument(flat) {
244
239
  graph: {
245
240
  nodes: nodes,
246
241
  edges,
242
+ response: {
243
+ ...(flatResponse.missing != null ? { missing: flatResponse.missing } : {}),
244
+ ...(flatResponse.version != null ? { version: flatResponse.version } : {}),
245
+ shape: responseShape,
246
+ },
247
247
  inputs: taskNodes.map((node) => ({
248
248
  id: `graph-input:${node.id}`,
249
249
  name: 'Input Record',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exellix/graph-engine",
3
- "version": "8.7.0",
3
+ "version": "9.0.0",
4
4
  "type": "module",
5
5
  "description": "Graph executor SDK",
6
6
  "main": "dist/src/index.js",
@@ -27,7 +27,7 @@
27
27
  "prebuild": "node scripts/clean-dist.mjs",
28
28
  "build": "tsc",
29
29
  "test": "npm run build && npm run check:no-legacy && tsx --test --test-force-exit --test-timeout=180000 --test-concurrency=2 tests/model-alias-canonical.test.ts tests/model-alias-execute.test.ts tests/model-alias-strategy.test.ts tests/model-alias-subnets.test.ts tests/ai-tasks-error-propagation.test.ts tests/reports-fixtures-pre-synthesis.test.ts tests/passthrough-parity.test.ts tests/step-retry-llm-call.test.ts tests/run-log-diagnostics.test.ts",
30
- "test:full": "npm run build && npm run check:no-legacy && tsx --test --test-force-exit --test-timeout=180000 --test-concurrency=2 tests/graph-engine.test.ts tests/graph-run-contract.test.ts tests/passthrough-parity.test.ts tests/task-node-run-task-preflight.test.ts tests/reports-fixtures-pre-synthesis.test.ts tests/model-alias-canonical.test.ts tests/model-alias-execute.test.ts tests/model-alias-strategy.test.ts tests/model-alias-subnets.test.ts tests/compile-host-patches.test.ts tests/reference-fixtures-compile.test.ts tests/step-retry-llm-call.test.ts",
30
+ "test:full": "npm run build && npm run check:no-legacy && tsx --test --test-force-exit --test-timeout=180000 --test-concurrency=2 tests/graph-engine.test.ts tests/graph-run-contract.test.ts tests/graph-response-contract.test.ts tests/passthrough-parity.test.ts tests/task-node-run-task-preflight.test.ts tests/reports-fixtures-pre-synthesis.test.ts tests/model-alias-canonical.test.ts tests/model-alias-execute.test.ts tests/model-alias-strategy.test.ts tests/model-alias-subnets.test.ts tests/compile-host-patches.test.ts tests/reference-fixtures-compile.test.ts tests/step-retry-llm-call.test.ts",
31
31
  "test:live": "npm run run:pre-synthesis",
32
32
  "test:subnets-graph-fixture": "npm run build && node --test tests/subnets-graph.fixture.test.mjs",
33
33
  "test:subnets-graph-live": "npm run build && node --env-file=.env --test tests/subnets-graph.live.test.mjs",