hoi4-agent-tools 4.5.0 → 4.6.1

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/hoi4_agent_tools/map/border-quality.js +25 -7
  3. package/dist/hoi4_agent_tools/map/derived.js +16 -3
  4. package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
  5. package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
  6. package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
  7. package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
  8. package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
  9. package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
  10. package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
  11. package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
  12. package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
  13. package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
  14. package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
  15. package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
  16. package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
  17. package/dist/hoi4_agent_tools/map/operations.js +109 -32
  18. package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
  19. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +107 -0
  20. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +52 -0
  21. package/dist/hoi4_agent_tools/map/service.js +78 -16
  22. package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
  23. package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
  24. package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
  25. package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
  26. package/dist/hoi4_agent_tools/map/territory.js +1108 -0
  27. package/dist/hoi4_agent_tools/map/tool-operations.js +78 -0
  28. package/dist/hoi4_agent_tools/map/validation.js +4 -0
  29. package/dist/hoi4_agent_tools/map/world.js +301 -107
  30. package/dist/hoi4_agent_tools/reference/known-mistakes.js +3 -0
  31. package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
  32. package/dist/hoi4_agent_tools/schemas/map.js +206 -21
  33. package/dist/hoi4_agent_tools/version.js +1 -1
  34. package/docs/map.md +100 -4
  35. package/npm-shrinkwrap.json +2 -2
  36. package/package.json +1 -1
  37. package/schemas/configuration.schema.json +1 -1
  38. package/schemas/continuous-focus-palette.schema.json +1 -1
  39. package/schemas/custom-weighted-pool.schema.json +1 -1
  40. package/schemas/decision-inspect-input.schema.json +1 -1
  41. package/schemas/error-log-input.schema.json +1 -1
  42. package/schemas/event-feature-manifest.schema.json +1 -1
  43. package/schemas/focus-plan.schema.json +1 -1
  44. package/schemas/focus-planning-sidecar.schema.json +1 -1
  45. package/schemas/geography-model.schema.json +13 -1
  46. package/schemas/gui-animation-source.schema.json +1 -1
  47. package/schemas/gui-helper.schema.json +1 -1
  48. package/schemas/gui-scenario.schema.json +1 -1
  49. package/schemas/helper-expansion-request.schema.json +1 -1
  50. package/schemas/helper-expansion-summary.schema.json +1 -1
  51. package/schemas/impact-inspect-input.schema.json +1 -1
  52. package/schemas/map-operation.schema.json +1355 -736
  53. package/schemas/mechanic-test-input.schema.json +1 -1
  54. package/schemas/mod-index-input.schema.json +1 -1
  55. package/schemas/operation-result.schema.json +1 -1
  56. package/schemas/package-check-input.schema.json +1 -1
  57. package/schemas/package-manifest.schema.json +1 -1
  58. package/schemas/probability-analysis-result.schema.json +1 -1
  59. package/schemas/probability-compare-input.schema.json +1 -1
  60. package/schemas/probability-evaluate-input.schema.json +1 -1
  61. package/schemas/probability-inspect-input.schema.json +1 -1
  62. package/schemas/probability-render-input.schema.json +1 -1
  63. package/schemas/probability-scenario-set.schema.json +1 -1
  64. package/schemas/probability-sequence-input.schema.json +1 -1
  65. package/schemas/probability-simulate-input.schema.json +1 -1
  66. package/schemas/probability-sweep-input.schema.json +1 -1
  67. package/schemas/reference-context-input.schema.json +1 -1
  68. package/schemas/reference-read-input.schema.json +1 -1
  69. package/schemas/reference-search-input.schema.json +1 -1
  70. package/schemas/reference-sheets.schema.json +1 -1
  71. package/schemas/scenario-suite.schema.json +1 -1
  72. package/schemas/scenario-test-input.schema.json +1 -1
  73. package/schemas/script-validate-input.schema.json +1 -1
  74. package/schemas/shared-scenario.schema.json +1 -1
  75. package/schemas/source-lookup-input.schema.json +1 -1
  76. package/server.json +2 -2
@@ -478,6 +478,112 @@ function applyVolcano(volcano, base, kind, mw, mh, scale, seed, targets) {
478
478
  }
479
479
  }
480
480
  // ---------------------------------------------------------------------------------------------
481
+ /**
482
+ * A declared pass is authoritative over the other relief (ADR 0072). The saddle of a range's
483
+ * envelope is lowered by the range alone; a volcano cone, a plateau or a neighbouring range that
484
+ * overlaps the pass adds height back and the gap disappears. After everything is composed, the
485
+ * heights along the pass corridor are capped at the saddle level (base plus crest times the
486
+ * envelope), with a soft edge across the corridor, so the pass is lower than the crest on both
487
+ * sides wherever it lies.
488
+ */
489
+ function carvePassSaddles(geography, tables, heights, kind, mw, mh) {
490
+ const aspect = mh / mw;
491
+ // The line of every range in cell units, one point per table entry, with its footprint.
492
+ const lines = new Map();
493
+ for (const range of geography.ranges) {
494
+ const table = tables.find((candidate) => candidate.id === range.id);
495
+ if (table === undefined)
496
+ continue;
497
+ const x = new Float64Array(table.count);
498
+ const y = new Float64Array(table.count);
499
+ let widest = 0;
500
+ for (let j = 0; j < table.count; j += 1) {
501
+ const point = sampleAlong(range.path, geography.frame, j / (table.count - 1));
502
+ x[j] = point.x * mw;
503
+ y[j] = point.y * mh;
504
+ widest = Math.max(widest, table.halfWidth[j]);
505
+ }
506
+ lines.set(range.id, { x, y, reach: Math.max(2, widest * mw) });
507
+ }
508
+ for (const range of geography.ranges) {
509
+ const table = tables.find((candidate) => candidate.id === range.id);
510
+ const own = lines.get(range.id);
511
+ if (table === undefined || own === undefined)
512
+ continue;
513
+ const lineX = own.x;
514
+ const lineY = own.y;
515
+ for (const entry of table.passes) {
516
+ if (entry.depth <= 0)
517
+ continue;
518
+ const k = Math.min(table.count - 1, Math.max(0, Math.round(entry.at * (table.count - 1))));
519
+ // Half-width of the corridor in cells: the range's footprint, at least three cells, plus a
520
+ // soft edge of three more.
521
+ const corridor = Math.max(2, table.halfWidth[k] * mw);
522
+ const edge = 2;
523
+ const entries = Math.ceil((entry.halfLength / table.length) * (table.count - 1));
524
+ const first = Math.max(0, k - entries);
525
+ const last = Math.min(table.count - 1, k + entries);
526
+ let minX = Infinity;
527
+ let maxX = -Infinity;
528
+ let minY = Infinity;
529
+ let maxY = -Infinity;
530
+ for (let j = first; j <= last; j += 1) {
531
+ minX = Math.min(minX, lineX[j]);
532
+ maxX = Math.max(maxX, lineX[j]);
533
+ minY = Math.min(minY, lineY[j]);
534
+ maxY = Math.max(maxY, lineY[j]);
535
+ }
536
+ const reach = Math.ceil(corridor + edge) + 1;
537
+ const x0 = Math.max(0, Math.floor(minX) - reach);
538
+ const x1 = Math.min(mw - 1, Math.ceil(maxX) + reach);
539
+ const y0 = Math.max(0, Math.floor(minY) - reach);
540
+ const y1 = Math.min(mh - 1, Math.ceil(maxY) + reach);
541
+ const baseLevel = table.base[k];
542
+ const crestLevel = table.crest[k];
543
+ for (let y = y0; y <= y1; y += 1)
544
+ for (let x = x0; x <= x1; x += 1) {
545
+ const cell = y * mw + x;
546
+ if (kind[cell] !== LAND)
547
+ continue;
548
+ // The nearest point of the range line, which must lie inside the pass window.
549
+ let nearest = -1;
550
+ let nearestDistance = Infinity;
551
+ for (let j = 0; j < table.count; j += 1) {
552
+ const distance = Math.hypot(lineX[j] - (x + 0.5), lineY[j] - (y + 0.5));
553
+ if (distance < nearestDistance) {
554
+ nearestDistance = distance;
555
+ nearest = j;
556
+ }
557
+ }
558
+ if (nearest < first || nearest > last || nearestDistance > corridor + edge)
559
+ continue;
560
+ // A cell on the body of another range belongs to that range, not to this pass.
561
+ let onOther = false;
562
+ for (const [id, other] of lines) {
563
+ if (id === range.id)
564
+ continue;
565
+ for (let j = 0; j < other.x.length && !onOther; j += 1)
566
+ if (Math.hypot(other.x[j] - (x + 0.5), other.y[j] - (y + 0.5)) <= other.reach)
567
+ onOther = true;
568
+ if (onOther)
569
+ break;
570
+ }
571
+ if (onOther)
572
+ continue;
573
+ const along = ((nearest - k) / (table.count - 1)) * table.length;
574
+ const s = (along * along) / (entry.halfLength * entry.halfLength);
575
+ if (s >= 1)
576
+ continue;
577
+ const bump = (1 - s) * (1 - s);
578
+ const cap = baseLevel + (crestLevel - baseLevel) * (1 - entry.depth * bump);
579
+ const weight = nearestDistance <= corridor ? 1 : 1 - smoothstep((nearestDistance - corridor) / edge);
580
+ if (cap < heights[cell])
581
+ heights[cell] = heights[cell] + (cap - heights[cell]) * weight;
582
+ }
583
+ }
584
+ }
585
+ void aspect;
586
+ }
481
587
  /** Build the macro relief of the model. */
482
588
  export function synthesizeMacroRelief(input) {
483
589
  const { geography, width, height, seed } = input;
@@ -519,6 +625,7 @@ export function synthesizeMacroRelief(input) {
519
625
  heights[index] = Math.min(HEIGHT_CEILING, heights[index] + targets.delta[index]);
520
626
  footprint[index] = Math.max(footprint[index], targets.footprint[index]);
521
627
  }
628
+ carvePassSaddles(geography, ranges, heights, kind, mw, mh);
522
629
  return {
523
630
  width: mw,
524
631
  height: mh,
@@ -85,6 +85,58 @@ function barrierField(width, height, kind, heights, riverBitmap, macro, signal)
85
85
  }
86
86
  return { width, height, strength, sources };
87
87
  }
88
+ /**
89
+ * Run the macro relief and the hydrology only, which is where a model's rivers fail to reach
90
+ * their mouths, and return their notes. The full-resolution heights, river pixels and fields are
91
+ * not built, so a model can be checked in a fraction of a write. A river that cannot reach its
92
+ * mouth throws as in `synthesizeTerrain`, with every failing river in its details.
93
+ */
94
+ export function checkTerrainRivers(input) {
95
+ const { geography, width, height, seed, kind } = input;
96
+ const relief = { ...DEFAULT_RELIEF, ...input.relief };
97
+ let landPixels = 0;
98
+ for (let index = 0; index < width * height; index += 1)
99
+ if (kind[index] === LAND)
100
+ landPixels += 1;
101
+ const macro = synthesizeMacroRelief({
102
+ geography,
103
+ width,
104
+ height,
105
+ seed,
106
+ kind,
107
+ relief,
108
+ signal: input.signal,
109
+ });
110
+ const adjust = input.adjustHeight;
111
+ if (adjust !== undefined)
112
+ for (let y = 0; y < macro.height; y += 1)
113
+ for (let x = 0; x < macro.width; x += 1) {
114
+ const cell = y * macro.width + x;
115
+ if (macro.kind[cell] === LAND)
116
+ macro.heights[cell] = adjust(2 * x + 1, 2 * y + 1, macro.heights[cell]);
117
+ }
118
+ const hydrology = synthesizeHydrology({
119
+ geography,
120
+ macro,
121
+ seed,
122
+ density: input.rivers?.density,
123
+ count: input.rivers?.count,
124
+ rainfall: input.rainfall,
125
+ landPixels,
126
+ signal: input.signal,
127
+ });
128
+ const notes = [...hydrology.notes];
129
+ // The pass test of the write, on the macro grid: a declared pass must be a saddle lower than
130
+ // the crest on both sides. Only its error is reported, so a preview never blocks on a warning
131
+ // the full-resolution write would not raise.
132
+ const quantised = new Uint8Array(macro.width * macro.height);
133
+ for (let index = 0; index < quantised.length; index += 1)
134
+ quantised[index] = Math.max(0, Math.min(255, Math.round(hydrology.heights[index])));
135
+ for (const note of checkRangesAndFeatures(geography, macro.ranges, { width: macro.width, height: macro.height, kind: macro.kind, heights: quantised }, (peak) => peakHeight(peak, macro.scale), (peak) => peakHeight(peak, macro.scale)))
136
+ if (note.code === 'MAP_PASS_NOT_LOWER')
137
+ notes.push(note);
138
+ return notes;
139
+ }
88
140
  /** Build relief, hydrology, normals and fields for a geography model on a map of the given size. */
89
141
  export function synthesizeTerrain(input) {
90
142
  const { geography, width, height, seed, kind } = input;
@@ -23,6 +23,7 @@ import { WORLD_OUTPUT_MANIFEST } from './world-outputs.js';
23
23
  import { renderMap, renderMapDiff, } from './render.js';
24
24
  import { validateMapAsync } from './validation.js';
25
25
  import { buildWorldPreviewArtifacts, generatedWorldOperationIds } from './world-preview.js';
26
+ import { evaluateTerritoryQuality, territoryArtifactContent, } from './territory-quality.js';
26
27
  /**
27
28
  * Evidence of an imported reference: registered sheets, an exact or frame-fitted picture, or a
28
29
  * geography model beside the picture. Plain pictures keep their previous output.
@@ -625,7 +626,7 @@ export class AgentNudger {
625
626
  this.scanner = this.engine.scanner;
626
627
  this.transactions = this.engine.transactions;
627
628
  }
628
- async scan(workspaceId, principal, signal, includeExternalScriptReferences = false, includeWorldContext = false, includeAppearance = false) {
629
+ async scan(workspaceId, principal, signal, includeExternalScriptReferences = false, includeWorldContext = false, includeAppearance = false, includeBorderInputs = false) {
629
630
  signal?.throwIfAborted();
630
631
  const workspace = this.resolver.get(workspaceId, principal);
631
632
  const sourceRoots = {
@@ -634,7 +635,9 @@ export class AgentNudger {
634
635
  localisation: workspace.registration.roots.localisation,
635
636
  };
636
637
  const selectorSnapshot = await this.engine.scan(workspaceId, { patterns: defaultMapPatterns(sourceRoots) }, principal, signal);
637
- const bitmapPatterns = provinceBitmapPatterns(selectorSnapshot.files, sourceRoots.map, includeAppearance ? BORDER_BITMAP_SELECTORS : PROVINCE_BITMAP_SELECTORS);
638
+ const bitmapPatterns = provinceBitmapPatterns(selectorSnapshot.files, sourceRoots.map, includeAppearance || includeBorderInputs
639
+ ? BORDER_BITMAP_SELECTORS
640
+ : PROVINCE_BITMAP_SELECTORS);
638
641
  const contentSnapshot = await this.engine.scan(workspaceId, { patterns: [...sourceTextPatterns(sourceRoots), ...bitmapPatterns] }, principal, signal);
639
642
  const locatorSnapshot = await this.engine.scan(workspaceId, { patterns: ['**/*.asset'], rootKinds: ['mod', 'fixture'] }, principal, signal);
640
643
  const externalScriptSnapshot = includeExternalScriptReferences
@@ -727,11 +730,11 @@ export class AgentNudger {
727
730
  scriptReferences: { references: consistency.references, missing: consistency.missing },
728
731
  };
729
732
  }
730
- async prepareOperations(workspaceId, operations, principal, signal) {
733
+ async prepareOperations(workspaceId, operations, principal, signal, synthesisCheck = false) {
731
734
  signal?.throwIfAborted();
732
735
  const snapshot = await this.scan(workspaceId, principal, signal, operations.some((operation) => operation.kind === 'renumber_map_entity' ||
733
736
  operation.kind === 'remap_map_references' ||
734
- 'remapReferences' in operation), operations.some((operation) => operation.kind === 'create_world'));
737
+ 'remapReferences' in operation), operations.some((operation) => operation.kind === 'create_world'), false, operations.some((operation) => operation.kind === 'assign_territory' || operation.kind === 'repartition_states'));
735
738
  const referenceWorlds = new Map();
736
739
  const referenceImages = [];
737
740
  const referenceLoads = new Map();
@@ -796,28 +799,56 @@ export class AgentNudger {
796
799
  const plan = await planMapOperationsAsync(snapshot.index, operations, signal, {
797
800
  referenceWorlds,
798
801
  geographyModels,
802
+ ...(synthesisCheck ? { synthesisCheck: true } : {}),
799
803
  });
800
804
  // Model diagnostics (including warnings of a model that is otherwise usable) travel with the plan.
801
805
  for (const geography of geographyModels.values())
802
806
  plan.diagnostics.push(...geography.diagnostics);
807
+ // Border naturalness of what would be written, for the operations that ask for it.
808
+ const territoryQuality = await evaluateTerritoryQuality(plan, signal);
809
+ if (territoryQuality !== undefined) {
810
+ plan.diagnostics.push(...territoryQuality.diagnostics);
811
+ plan.blockers.push(...territoryQuality.blockers);
812
+ for (const report of plan.territory ?? []) {
813
+ const found = territoryQuality.operations.find(({ operationId }) => operationId === report.operationId);
814
+ if (found !== undefined)
815
+ report.borderQuality.verdicts = found.layers.map(({ layer, report: measured, check }) => ({
816
+ layer,
817
+ status: measured.status,
818
+ enrichment: measured.barrier.enrichment,
819
+ naturalShare: measured.natural.share,
820
+ straightFraction: measured.straightness.fraction,
821
+ passed: check.passed,
822
+ }));
823
+ }
824
+ }
803
825
  plan.diagnostics.push(...importDiagnostics);
804
826
  const newWorld = operations.some(({ kind }) => kind === 'create_world');
805
- const freshWorld = newWorld && snapshot.index.provinceBitmapFile?.rootKind !== 'mod';
806
- const baselineValidation = freshWorld
827
+ // A new world replaces the whole map: findings of the map it replaces are not its baseline,
828
+ // so every finding of the generated map is judged on its own and belongs to the world
829
+ // operation (ADR 0070).
830
+ const baselineValidation = newWorld
807
831
  ? { passed: true, diagnostics: [], checks: [] }
808
832
  : await validateMapAsync(snapshot.index, signal === undefined ? {} : { signal });
809
833
  // A new world has nothing to compare its bitmap format and pixels with.
810
834
  const worldOperation = operations.find((operation) => operation.kind === 'create_world');
811
- const rawValidation = await validateMapAsync(plan.finalIndex, {
812
- ...(newWorld ? {} : { baseline: snapshot.index }),
813
- ...(worldOperation?.kind === 'create_world' && worldOperation.edge !== undefined
814
- ? { edgeMode: worldOperation.edge.mode ?? 'sea-band' }
815
- : {}),
816
- ...(signal === undefined ? {} : { signal }),
817
- ...(plan.expectedChangedBounds === undefined
818
- ? {}
819
- : { expectedChangedBounds: plan.expectedChangedBounds }),
820
- });
835
+ // A world that failed to generate left the workspace map unchanged; validating that map would
836
+ // report only its old findings, so the generation blocker stands alone.
837
+ const worldFailed = worldOperation !== undefined &&
838
+ (plan.blockers.some(({ operationId }) => operationId === worldOperation.id) ||
839
+ synthesisCheck);
840
+ const rawValidation = worldFailed
841
+ ? { passed: true, diagnostics: [], checks: [] }
842
+ : await validateMapAsync(plan.finalIndex, {
843
+ ...(newWorld ? {} : { baseline: snapshot.index }),
844
+ ...(worldOperation?.kind === 'create_world' && worldOperation.edge !== undefined
845
+ ? { edgeMode: worldOperation.edge.mode ?? 'sea-band' }
846
+ : {}),
847
+ ...(signal === undefined ? {} : { signal }),
848
+ ...(plan.expectedChangedBounds === undefined
849
+ ? {}
850
+ : { expectedChangedBounds: plan.expectedChangedBounds }),
851
+ });
821
852
  const attributedValidation = {
822
853
  ...rawValidation,
823
854
  diagnostics: attributeMapValidationDiagnostics({
@@ -833,6 +864,7 @@ export class AgentNudger {
833
864
  validation: validationWithPlan(attributedValidation, plan),
834
865
  referenceImages,
835
866
  geography: [...geographyModels.values()],
867
+ territory: { reports: plan.territory ?? [], quality: territoryQuality },
836
868
  imports,
837
869
  };
838
870
  }
@@ -992,6 +1024,25 @@ export class AgentNudger {
992
1024
  }, prepared);
993
1025
  return { ...diff, ...planned };
994
1026
  }
1027
+ /**
1028
+ * Run the relief and river synthesis of every `create_world` with `synthesisCheck` and report
1029
+ * the model's rivers that cannot reach their mouths, without generating or writing anything.
1030
+ */
1031
+ async checkSynthesis(input) {
1032
+ const prepared = await this.prepareOperations(input.workspaceId, input.operations, input.principal, input.signal, true);
1033
+ const writes = await this.geographyArtifacts(prepared, input.signal, new RenderBudget());
1034
+ const artifacts = await this.artifacts.withAtomicChunkedWrites(this.resolver.get(input.workspaceId, input.principal), writes, (stored) => Promise.resolve([...stored]), input.signal);
1035
+ return {
1036
+ plan: prepared.plan,
1037
+ geography: prepared.geography,
1038
+ diagnostics: sortDiagnostics([
1039
+ ...prepared.plan.diagnostics,
1040
+ ...prepared.geography.flatMap(({ diagnostics }) => diagnostics),
1041
+ ]),
1042
+ artifacts,
1043
+ filesScanned: mapSourceEvidence(prepared.snapshot).map(({ displayPath }) => displayPath),
1044
+ };
1045
+ }
995
1046
  /** Build validated offline evidence without planning or applying a write transaction. */
996
1047
  async previewRewriteWithDiff(input) {
997
1048
  const prepared = await this.prepareOperations(input.workspaceId, input.operations, input.principal, input.signal);
@@ -1295,6 +1346,17 @@ export class AgentNudger {
1295
1346
  provenance: comparisonProvenance('before'),
1296
1347
  });
1297
1348
  writes.push(...(await this.geographyArtifacts(prepared, options.signal, budget)));
1349
+ if (prepared.territory.reports.length > 0 || prepared.territory.quality !== undefined)
1350
+ writes.push({
1351
+ name: 'map-territory-report.json',
1352
+ mimeType: 'application/json',
1353
+ content: `${canonicalJson(territoryArtifactContent(prepared.territory.reports, prepared.territory.quality))}\n`,
1354
+ provenance: {
1355
+ ...provenance,
1356
+ kind: 'map-territory-report',
1357
+ schemaVersion: 'map-territory.v1',
1358
+ },
1359
+ });
1298
1360
  for (const [ordinal, imported] of prepared.imports.entries())
1299
1361
  writes.push(...(await buildImportArtifacts(imported, ordinal)));
1300
1362
  if (plan.worldOutputs !== undefined)