@sdeverywhere/compile 0.7.31 → 0.7.32

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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@sdeverywhere/compile",
3
- "version": "0.7.31",
3
+ "version": "0.7.32",
4
4
  "description": "The core Vensim to C compiler for the SDEverywhere tool suite.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "dependencies": {
8
- "@sdeverywhere/parse": "^0.1.4",
8
+ "@sdeverywhere/parse": "^0.1.5",
9
9
  "byline": "^5.0.0",
10
10
  "csv-parse": "^5.3.3",
11
11
  "fflate": "^0.8.3",
@@ -278,10 +278,22 @@ function getAttr(attrs, name) {
278
278
  return end < 0 ? undefined : attrs.slice(start, end)
279
279
  }
280
280
 
281
+ /**
282
+ * Normalize line endings the way a conformant XML parser (and SheetJS) does:
283
+ * `\r\n` and lone `\r` both become `\n`. Applied after entity decoding so a
284
+ * CR encoded as `&#13;` is normalized too.
285
+ *
286
+ * @param {string} s The text to normalize.
287
+ * @returns The normalized string.
288
+ */
289
+ function normalizeEol(s) {
290
+ return s.indexOf('\r') === -1 ? s : s.replace(/\r\n?/g, '\n')
291
+ }
292
+
281
293
  /**
282
294
  * Decode the standard XML entities (`&lt;`, `&gt;`, `&amp;`, `&quot;`,
283
295
  * `&apos;`) along with numeric character references (`&#NN;` and `&#xNN;`)
284
- * in the given text. Returns the input unchanged when no entities are present.
296
+ * in the given text, and normalize line endings to `\n`.
285
297
  *
286
298
  * @param {string} s The raw text from an XML element body or attribute.
287
299
  * @returns The decoded string.
@@ -289,20 +301,22 @@ function getAttr(attrs, name) {
289
301
  function decodeXmlText(s) {
290
302
  // Fast path: most cell text contains no entities, so skip the regex chain
291
303
  if (s.indexOf('&') === -1) {
292
- return s
304
+ return normalizeEol(s)
293
305
  }
294
306
 
295
307
  // Decode the named entities, then decimal and hex numeric refs, and finally
296
308
  // `&amp;` — leaving `&amp;` last avoids accidentally producing `&lt;` etc.
297
309
  // from a literal `&amp;lt;` in the source
298
- return s
299
- .replace(/&lt;/g, '<')
300
- .replace(/&gt;/g, '>')
301
- .replace(/&quot;/g, '"')
302
- .replace(/&apos;/g, "'")
303
- .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
304
- .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
305
- .replace(/&amp;/g, '&')
310
+ return normalizeEol(
311
+ s
312
+ .replace(/&lt;/g, '<')
313
+ .replace(/&gt;/g, '>')
314
+ .replace(/&quot;/g, '"')
315
+ .replace(/&apos;/g, "'")
316
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
317
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
318
+ .replace(/&amp;/g, '&')
319
+ )
306
320
  }
307
321
 
308
322
  //
@@ -392,6 +406,19 @@ function parseWorkbookRels(xml) {
392
406
  return rels
393
407
  }
394
408
 
409
+ /**
410
+ * Extract the text content of the `<v>` element in a cell body, tolerating
411
+ * attributes on the tag (e.g. `<v xml:space="preserve">`). Returns undefined
412
+ * when there is no `<v>` element (e.g. an uncalculated formula cell).
413
+ *
414
+ * @param {string} body The inner XML of a `<c>` element.
415
+ * @returns The raw text between `<v...>` and `</v>`, or undefined.
416
+ */
417
+ function getVText(body) {
418
+ const m = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(body)
419
+ return m ? m[1] : undefined
420
+ }
421
+
395
422
  /**
396
423
  * Scan a worksheet's XML and build a sparse cell map shaped like the SheetJS
397
424
  * worksheet object: `{ [cellRef]: { v }, '!ref': 'A1:Z99' }`. Skips empty
@@ -429,16 +456,7 @@ function parseSheetXml(xml, sharedStrings) {
429
456
  const t = getAttr(attrs, 't')
430
457
 
431
458
  let value
432
- if (t === 's') {
433
- // Shared string: <v>N</v> where N indexes sharedStrings
434
- const vStart = body.indexOf('<v>')
435
- if (vStart < 0) {
436
- continue
437
- }
438
- const vEnd = body.indexOf('</v>', vStart + 3)
439
- const idx = parseInt(body.slice(vStart + 3, vEnd), 10)
440
- value = sharedStrings[idx]
441
- } else if (t === 'inlineStr') {
459
+ if (t === 'inlineStr') {
442
460
  // Inline string: <is><t>...</t></is>
443
461
  const tStart = body.indexOf('<t')
444
462
  if (tStart < 0) {
@@ -447,38 +465,35 @@ function parseSheetXml(xml, sharedStrings) {
447
465
  const tOpenEnd = body.indexOf('>', tStart)
448
466
  const tEnd = body.indexOf('</t>', tOpenEnd)
449
467
  value = decodeXmlText(body.slice(tOpenEnd + 1, tEnd))
450
- } else if (t === 'str') {
451
- // Formula result as string: <v>...</v>
452
- const vStart = body.indexOf('<v>')
453
- if (vStart < 0) {
454
- continue
455
- }
456
- const vEnd = body.indexOf('</v>', vStart + 3)
457
- value = decodeXmlText(body.slice(vStart + 3, vEnd))
458
- } else if (t === 'b') {
459
- // Boolean: <v>0</v> or <v>1</v>
460
- const vStart = body.indexOf('<v>')
461
- if (vStart < 0) {
462
- continue
463
- }
464
- value = body.charCodeAt(vStart + 3) === 49 // '1'
465
468
  } else if (t === 'e') {
466
469
  // Error cell, skip
467
470
  continue
468
471
  } else {
469
- // Numeric (t === 'n' or absent). Skip any <f> formula tag and read the
470
- // cached <v> value. If <v> is missing (e.g. an uncalculated formula),
471
- // skip the cell so the caller's missing-cell handling kicks in.
472
- const vStart = body.indexOf('<v>')
473
- if (vStart < 0) {
472
+ // The remaining cell types carry their value in a <v> element, which
473
+ // may have attributes (e.g. <v xml:space="preserve">). If <v> is
474
+ // missing (e.g. an uncalculated formula), skip the cell so the
475
+ // caller's missing-cell handling kicks in.
476
+ const vText = getVText(body)
477
+ if (vText === undefined) {
474
478
  continue
475
479
  }
476
- const vEnd = body.indexOf('</v>', vStart + 3)
477
- const num = +body.slice(vStart + 3, vEnd)
478
- if (Number.isNaN(num)) {
479
- continue
480
+ if (t === 's') {
481
+ // Shared string: <v>N</v> where N indexes sharedStrings
482
+ value = sharedStrings[parseInt(vText, 10)]
483
+ } else if (t === 'str') {
484
+ // Formula result as string: <v>...</v>
485
+ value = decodeXmlText(vText)
486
+ } else if (t === 'b') {
487
+ // Boolean: <v>0</v> or <v>1</v>
488
+ value = vText.charCodeAt(0) === 49 // '1'
489
+ } else {
490
+ // Numeric (t === 'n' or absent)
491
+ const num = +vText
492
+ if (Number.isNaN(num)) {
493
+ continue
494
+ }
495
+ value = num
480
496
  }
481
- value = num
482
497
  }
483
498
 
484
499
  // Store the cell under its A1 ref, matching the SheetJS sheet shape
@@ -128,6 +128,9 @@ export function generateEquation(variable, mode, extData, directData, modelDir,
128
128
  }
129
129
  }
130
130
 
131
+ // Keep a buffer of code that will be included before all subscript loops
132
+ const preLoopLines = []
133
+
131
134
  // Keep a buffer of code that will be included before the innermost loop
132
135
  const preInnerLoopLines = []
133
136
 
@@ -145,6 +148,7 @@ export function generateEquation(variable, mode, extData, directData, modelDir,
145
148
  cLhs,
146
149
  loopIndexVars,
147
150
  arrayIndexVars,
151
+ emitPreLoop: s => preLoopLines.push(s),
148
152
  emitPreInnerLoop: s => preInnerLoopLines.push(s),
149
153
  emitPreFormula: s => preFormulaLines.push(s),
150
154
  emitPostFormula: s => postFormulaLines.push(s),
@@ -161,7 +165,7 @@ export function generateEquation(variable, mode, extData, directData, modelDir,
161
165
  }
162
166
 
163
167
  // Combine all lines of comments and code into a single array
164
- return [comment, ...openLoops, ...preFormulaLines, formula, ...postFormulaLines, ...closeLoops]
168
+ return [comment, ...preLoopLines, ...openLoops, ...preFormulaLines, formula, ...postFormulaLines, ...closeLoops]
165
169
  }
166
170
 
167
171
  /**
@@ -369,6 +369,9 @@ function generateFunctionCall(callExpr, ctx) {
369
369
  //
370
370
  //
371
371
 
372
+ case '_INVERT_MATRIX':
373
+ return generateInvertMatrixCall(callExpr, ctx)
374
+
372
375
  case '_VECTOR_ELM_MAP':
373
376
  return generateVectorElmMapCall(callExpr, ctx)
374
377
 
@@ -382,10 +385,18 @@ function generateFunctionCall(callExpr, ctx) {
382
385
  //
383
386
 
384
387
  case '_ALLOCATE_AVAILABLE':
388
+ case '_DEMAND_AT_PRICE':
389
+ case '_SUPPLY_AT_PRICE':
385
390
  if (ctx.outFormat === 'js') {
386
391
  throw new Error(`${callExpr.fnName} function not yet implemented for JS code gen`)
387
392
  }
388
- return generateAllocateAvailableCall(callExpr, ctx)
393
+ return generateAllocationFunctionCall(callExpr, ctx)
394
+
395
+ case '_FIND_MARKET_PRICE':
396
+ if (ctx.outFormat === 'js') {
397
+ throw new Error(`FIND MARKET PRICE function not yet implemented for JS code gen`)
398
+ }
399
+ return generateFindMarketPriceFunctionCall(callExpr, ctx)
389
400
 
390
401
  case '_ALLOCATE_BY_PRIORITY':
391
402
  if (ctx.outFormat === 'js') {
@@ -875,19 +886,89 @@ function generateVectorSortOrderCall(callExpr, ctx) {
875
886
  }
876
887
 
877
888
  /**
878
- * Generate C/JS code for an `ALLOCATE AVAILABLE` function call.
889
+ * Generate C/JS code for an `INVERT MATRIX` function call.
890
+ *
891
+ * The function inverts the entire 2D matrix argument at once, so the call is emitted
892
+ * once before the LHS subscript loops are opened, and the per-element expression reads
893
+ * from the resulting temporary.
879
894
  *
880
895
  * @param {*} callExpr The function call expression from the parsed model.
881
896
  * @param {GenExprContext} ctx The context used when generating code for the expression.
882
897
  * @return {string} The generated C/JS code.
883
898
  */
884
- function generateAllocateAvailableCall(callExpr, ctx) {
899
+ function generateInvertMatrixCall(callExpr, ctx) {
900
+ // Process the matrix argument
901
+ const matrixArg = callExpr.args[0]
902
+ if (matrixArg.kind !== 'variable-ref') {
903
+ throw new Error(`INVERT MATRIX argument 'matrix' must be a variable reference`)
904
+ }
905
+ const matrixSubIds = matrixArg.subscriptRefs?.map(subRef => subRef.subId) || []
906
+ if (matrixSubIds.length !== 2) {
907
+ throw new Error(`INVERT MATRIX argument 'matrix' must be a 2D matrix variable`)
908
+ }
909
+
910
+ // The result fills the entire LHS variable, so the LHS must be a square 2D matrix
911
+ const lhsSubIds = ctx.variable.subscripts
912
+ if (lhsSubIds.length !== 2) {
913
+ throw new Error(`The LHS of an equation with INVERT MATRIX must have two dimensions`)
914
+ }
915
+ const rowDimId = lhsSubIds[0]
916
+ const colDimId = lhsSubIds[1]
917
+ const matrixSize = sub(colDimId).size
918
+ if (sub(rowDimId).size !== matrixSize) {
919
+ throw new Error(`The LHS of an equation with INVERT MATRIX must be a square matrix`)
920
+ }
921
+
922
+ // Process the size argument. When it resolves to a constant at code gen time (e.g., a
923
+ // numeric literal or an `ELMCOUNT` call), verify that it matches the LHS dimension size,
924
+ // since the generated code always inverts the full LHS-sized matrix.
925
+ const nArg = generateExpr(callExpr.args[1], ctx)
926
+ const staticN = Number.parseFloat(nArg)
927
+ if (!Number.isNaN(staticN) && staticN !== matrixSize) {
928
+ throw new Error(
929
+ `The size argument for INVERT MATRIX (${staticN}) must match the LHS dimension size (${matrixSize})`
930
+ )
931
+ }
932
+
933
+ // Generate the code that is emitted before the entire block (before any loops are opened)
934
+ const tmpVarId = newTmpVarName()
935
+ switch (ctx.outFormat) {
936
+ case 'c':
937
+ ctx.emitPreLoop(` double* ${tmpVarId} = _INVERT_MATRIX((double*)${matrixArg.varId}, ${matrixSize});`)
938
+ break
939
+ case 'js':
940
+ ctx.emitPreLoop(` let ${tmpVarId} = fns.INVERT_MATRIX(${matrixArg.varId}, ${matrixSize});`)
941
+ break
942
+ default:
943
+ throw new Error(`Unhandled output format '${ctx.outFormat}'`)
944
+ }
945
+
946
+ // Generate the RHS expression used in the inner loop. The C runtime function returns
947
+ // a flat array in row-major order, while the JS one returns a nested array.
948
+ const rowIndexVar = ctx.loopIndexVars.index(rowDimId)
949
+ const colIndexVar = ctx.loopIndexVars.index(colDimId)
950
+ if (ctx.outFormat === 'c') {
951
+ return `${tmpVarId}[${rowIndexVar} * ${matrixSize} + ${colIndexVar}]`
952
+ } else {
953
+ return `${tmpVarId}[${rowIndexVar}][${colIndexVar}]`
954
+ }
955
+ }
956
+
957
+ /**
958
+ * Generate C/JS code for an allocation function call.
959
+ * This includes `_ALLOCATE_AVAILABLE`, `_DEMAND_AT_PRICE`, and `_SUPPLY_AT_PRICE`.
960
+ *
961
+ * @param {*} callExpr The function call expression from the parsed model.
962
+ * @param {GenExprContext} ctx The context used when generating code for the expression.
963
+ * @return {string} The generated C/JS code.
964
+ */
965
+ function generateAllocationFunctionCall(callExpr, ctx) {
885
966
  function validateArg(index, name) {
886
967
  const arg = callExpr.args[index]
887
968
  if (arg.kind === 'variable-ref') {
888
969
  return arg
889
970
  } else {
890
- throw new Error(`ALLOCATE AVAILABLE argument '${name}' must be a variable reference`)
971
+ throw new Error(`${callExpr.fnName} argument '${name}' must be a variable reference`)
891
972
  }
892
973
  }
893
974
 
@@ -896,8 +977,9 @@ function generateAllocateAvailableCall(callExpr, ctx) {
896
977
  function cVarRefWithoutLastIndices(arg, count) {
897
978
  const varRef = ctx.cVarRef(arg)
898
979
  const origIndexParts = Model.splitRefId(varRef).subscripts
899
- if (origIndexParts < count) {
900
- throw new Error(`ALLOCATE AVAILABLE argument '${arg}' should have at least ${count} subscripts`)
980
+ if (origIndexParts.length < count) {
981
+ const plural = count === 1 ? '' : 's'
982
+ throw new Error(`${callExpr.fnName} argument '${arg.varName}' should have at least ${count} subscript${plural}`)
901
983
  }
902
984
  const newIndexParts = origIndexParts.slice(0, -count)
903
985
  if (newIndexParts.length > 0) {
@@ -918,11 +1000,10 @@ function generateAllocateAvailableCall(callExpr, ctx) {
918
1000
  const ppArg = validateArg(1, 'pp')
919
1001
  const ppRef = cVarRefWithoutLastIndices(ppArg, 2)
920
1002
 
921
- // Process the avail argument; include any subscripts
922
- const availArg = validateArg(2, 'avail')
923
- const availRef = ctx.cVarRef(availArg)
1003
+ // Process the avail argument; include any subscripts. The avail arg can be any expression.
1004
+ const availArg = generateExpr(callExpr.args[2], ctx)
924
1005
 
925
- // The `ALLOCATE AVAILABLE` function iterates over the last subscript in its first arg.
1006
+ // Allocation functions iterate over the last subscript in its first arg.
926
1007
  // The `readEquation` process will have already verified that the last dimension matches
927
1008
  // the last dimension for the LHS.
928
1009
  const allocDimId = reqArg.subscriptRefs[reqArg.subscriptRefs.length - 1].subId
@@ -934,13 +1015,14 @@ function generateAllocateAvailableCall(callExpr, ctx) {
934
1015
  switch (ctx.outFormat) {
935
1016
  case 'c':
936
1017
  ctx.emitPreInnerLoop(
937
- ` double* ${tmpVarId} = _ALLOCATE_AVAILABLE(${reqRef}, (double*)${ppRef}, ${availRef}, ${numRequesters});`
1018
+ ` double* ${tmpVarId} = ${callExpr.fnId}(${reqRef}, (double*)${ppRef}, ${availArg}, ${numRequesters});`
938
1019
  )
939
1020
  break
940
1021
  case 'js':
941
- ctx.emitPreInnerLoop(
942
- ` let ${tmpVarId} = fns.ALLOCATE_AVAILABLE(${reqRef}, ${ppRef}, ${availRef}, ${numRequesters});`
943
- )
1022
+ // TODO: Implement allocation functions for JS
1023
+ // ctx.emitPreInnerLoop(
1024
+ // ` let ${tmpVarId} = ${fnRef(callExpr.fnId, ctx)}(${reqRef}, ${ppRef}, ${availArg}, ${numRequesters});`
1025
+ // )
944
1026
  break
945
1027
  default:
946
1028
  throw new Error(`Unhandled output format '${ctx.outFormat}'`)
@@ -972,8 +1054,9 @@ function generateAllocateByPriorityCall(callExpr, ctx) {
972
1054
  function cVarRefWithoutLastIndices(arg, count) {
973
1055
  const varRef = ctx.cVarRef(arg)
974
1056
  const origIndexParts = Model.splitRefId(varRef).subscripts
975
- if (origIndexParts < count) {
976
- throw new Error(`ALLOCATE BY PRIORITY argument '${arg}' should have at least ${count} subscripts`)
1057
+ if (origIndexParts.length < count) {
1058
+ const plural = count === 1 ? '' : 's'
1059
+ throw new Error(`ALLOCATE BY PRIORITY argument '${arg.varName}' should have at least ${count} subscript${plural}`)
977
1060
  }
978
1061
  const newIndexParts = origIndexParts.slice(0, -count)
979
1062
  if (newIndexParts.length > 0) {
@@ -1032,6 +1115,77 @@ function generateAllocateByPriorityCall(callExpr, ctx) {
1032
1115
  return `${tmpVarId}[${allocDimId}[${allocLoopIndexVar}]]`
1033
1116
  }
1034
1117
 
1118
+ /**
1119
+ * Generate C/JS code for a `FIND MARKET PRICE` function call.
1120
+ *
1121
+ * @param {*} callExpr The function call expression from the parsed model.
1122
+ * @param {GenExprContext} ctx The context used when generating code for the expression.
1123
+ * @return {string} The generated C/JS code.
1124
+ */
1125
+ function generateFindMarketPriceFunctionCall(callExpr, ctx) {
1126
+ function validateArg(index, name) {
1127
+ const arg = callExpr.args[index]
1128
+ if (arg.kind === 'variable-ref') {
1129
+ return arg
1130
+ } else {
1131
+ throw new Error(`${callExpr.fnName} argument '${name}' must be a variable reference`)
1132
+ }
1133
+ }
1134
+
1135
+ // Given a C/JS variable reference string (e.g., '_var[i][j]'), return that
1136
+ // string without the last N array index parts
1137
+ function cVarRefWithoutLastIndices(arg, count) {
1138
+ const varRef = ctx.cVarRef(arg)
1139
+ const origIndexParts = Model.splitRefId(varRef).subscripts
1140
+ if (origIndexParts.length < count) {
1141
+ throw new Error(`${callExpr.fnName} argument '${arg}' should have at least ${count} subscripts`)
1142
+ }
1143
+ const newIndexParts = origIndexParts.slice(0, -count)
1144
+ if (newIndexParts.length > 0) {
1145
+ return `${arg.varId}${newIndexParts.map(x => `[${x}]`).join('')}`
1146
+ } else {
1147
+ return arg.varId
1148
+ }
1149
+ }
1150
+
1151
+ // Process the demand quantities argument. Only include subscripts up until the last one;
1152
+ // the implementation function will iterate over the demand quantities array.
1153
+ const demandQtysArg = validateArg(0, 'demandQtys')
1154
+ const demandQtysRef = cVarRefWithoutLastIndices(demandQtysArg, 1)
1155
+
1156
+ // Process the demand profiles argument. Only include subscripts up until the
1157
+ // second to last one; the implementation function will iterate over the priority
1158
+ // profile array.
1159
+ const demandProfilesArg = validateArg(1, 'demandProfiles')
1160
+ const demandProfilesRef = cVarRefWithoutLastIndices(demandProfilesArg, 2)
1161
+
1162
+ // The `FIND MARKET PRICE` implementation sums total demand over all demanders.
1163
+ // When the subscript is an individual index (the Vensim convention of passing the
1164
+ // first element of the array), the count is the size of the index's family dimension;
1165
+ // when it is a dimension (possibly a subdimension), the count is that dimension's size.
1166
+ const demandSubId = demandQtysArg.subscriptRefs[demandQtysArg.subscriptRefs.length - 1].subId
1167
+ const numDemanders = isIndex(demandSubId) ? sub(sub(demandSubId).family).size : sub(demandSubId).size
1168
+
1169
+ // Process the supply quantities argument. Only include subscripts up until the last one;
1170
+ // the implementation function will iterate over the supply quantities array.
1171
+ const supplyQtysArg = validateArg(2, 'supplyQtys')
1172
+ const supplyQtysRef = cVarRefWithoutLastIndices(supplyQtysArg, 1)
1173
+
1174
+ // Process the supply profiles argument. Only include subscripts up until the
1175
+ // second to last one; the implementation function will iterate over the priority
1176
+ // profile array.
1177
+ const supplyProfilesArg = validateArg(3, 'supplyProfiles')
1178
+ const supplyProfilesRef = cVarRefWithoutLastIndices(supplyProfilesArg, 2)
1179
+
1180
+ // The `FIND MARKET PRICE` implementation sums total supply over all suppliers.
1181
+ // The count is determined the same way as for demanders above.
1182
+ const supplySubId = supplyQtysArg.subscriptRefs[supplyQtysArg.subscriptRefs.length - 1].subId
1183
+ const numSuppliers = isIndex(supplySubId) ? sub(sub(supplySubId).family).size : sub(supplySubId).size
1184
+
1185
+ // Generate the RHS expression
1186
+ return `_FIND_MARKET_PRICE(${demandQtysRef}, (double*)${demandProfilesRef}, ${supplyQtysRef}, (double*)${supplyProfilesRef}, ${numDemanders}, ${numSuppliers})`
1187
+ }
1188
+
1035
1189
  /**
1036
1190
  * Recursively traverse the given expression and call the function when visiting a variable ref.
1037
1191
  *
@@ -0,0 +1,208 @@
1
+ // Copyright (c) 2026 Climate Interactive / New Venture Fund
2
+
3
+ import { isDimension, isIndex, sub } from '../_shared/subscript.js'
4
+
5
+ import Model from './model.js'
6
+
7
+ /**
8
+ * Analyze the dependency cycle clusters reported by toposort and find variables that
9
+ * could be separated into individual index instances to break the cycles. A false
10
+ * cycle can appear when a variable keeps a dimension for which the variables it
11
+ * references are defined (or separated) element by element. The whole-array variable
12
+ * then depends on all elements of its references, merging the otherwise independent
13
+ * dependency chains of each element into a single node. Separating the variable on
14
+ * that dimension restores the element-level dependency structure that Vensim uses
15
+ * when it orders equations.
16
+ *
17
+ * Each cycle cluster is a strongly connected component of the dependency graph.
18
+ * For each variable v in a cluster, propose separating v on a dimension D when a
19
+ * successor of v in the cluster carries an individual index in the family of D and
20
+ * a predecessor of v in the cluster references v by an individual element of D
21
+ * (so that the separation actually removes the edge into the other elements of v).
22
+ * If no candidate satisfies the predecessor condition, fall back to the candidates
23
+ * that satisfy the successor condition alone.
24
+ *
25
+ * @param {Array} cycles The cycle clusters (strongly connected components), where each
26
+ * cluster is an array of the ref IDs of the variables that it contains.
27
+ * @param {Map} outgoingEdges A map of each ref ID to the set of ref IDs that it depends on.
28
+ * @returns {Map} A map from variable name to the set of dimension IDs to separate on.
29
+ */
30
+ export function separationCandidatesForCycles(cycles, outgoingEdges) {
31
+ const candidates = new Map()
32
+ const looseCandidates = new Map()
33
+ const addCandidate = (map, varName, dimId) => {
34
+ let dimIds = map.get(varName)
35
+ if (!dimIds) {
36
+ dimIds = new Set()
37
+ map.set(varName, dimIds)
38
+ }
39
+ dimIds.add(dimId)
40
+ }
41
+ // The set of (variable name, family) pairs accepted as candidates so far; a variable
42
+ // that will be separated on a family satisfies the predecessor condition for the
43
+ // variables it references, so acceptance is iterated to a fixpoint below
44
+ const acceptedFamilies = new Set()
45
+ for (const scc of cycles) {
46
+ const inScc = new Set(scc)
47
+ // Build a predecessor map for the nodes in this cluster
48
+ const predsOf = new Map(scc.map(refId => [refId, []]))
49
+ for (const refId of scc) {
50
+ for (const succ of outgoingEdges.get(refId) || []) {
51
+ if (inScc.has(succ)) {
52
+ predsOf.get(succ).push(refId)
53
+ }
54
+ }
55
+ }
56
+ // Collect the possible (v, D) pairs for this cluster
57
+ const sccLooseCandidates = []
58
+ for (const refId of scc) {
59
+ const v = Model.varWithRefId(refId)
60
+ if (!v || !v.subscripts || v.subscripts.length === 0) {
61
+ continue
62
+ }
63
+ // Find the families of the individual indices carried by the successors
64
+ // of this node within the cluster
65
+ const succIndexFamilies = new Set()
66
+ for (const succ of outgoingEdges.get(refId) || []) {
67
+ if (inScc.has(succ)) {
68
+ for (const subId of Model.splitRefId(succ).subscripts) {
69
+ if (isIndex(subId)) {
70
+ succIndexFamilies.add(sub(subId).family)
71
+ }
72
+ }
73
+ }
74
+ }
75
+ for (const subId of v.subscripts) {
76
+ if (isDimension(subId) && succIndexFamilies.has(sub(subId).family)) {
77
+ // Skip the candidate when every predecessor references this variable
78
+ // exclusively through a marked full dimension (e.g., `SUM(x[DimA!])`):
79
+ // such references span all elements regardless of separation, so
80
+ // separating this variable can never narrow the incoming edges
81
+ const familyId = sub(subId).family
82
+ const possiblyNarrowing = predsOf.get(refId).some(predRefId => {
83
+ const pv = Model.varWithRefId(predRefId)
84
+ if (!pv) {
85
+ return false
86
+ }
87
+ const refKinds = elementRefKinds(pv, v.varName, familyId)
88
+ return refKinds.elementRef || refKinds.fullDimRef || !refKinds.markedFullDimRef
89
+ })
90
+ if (possiblyNarrowing) {
91
+ sccLooseCandidates.push({ refId, v, dimId: subId })
92
+ }
93
+ }
94
+ }
95
+ }
96
+ // Accept the candidates that satisfy the predecessor condition, iterating to a
97
+ // fixpoint since accepting one variable can qualify the variables it references
98
+ const sccAccepted = new Set()
99
+ let changed
100
+ do {
101
+ changed = false
102
+ for (const c of sccLooseCandidates) {
103
+ if (sccAccepted.has(c)) {
104
+ continue
105
+ }
106
+ const familyId = sub(c.dimId).family
107
+ const predQualifies = predRefId => {
108
+ const pv = Model.varWithRefId(predRefId)
109
+ if (!pv) {
110
+ return false
111
+ }
112
+ const refKinds = elementRefKinds(pv, c.v.varName, familyId)
113
+ if (refKinds.markedFullDimRef) {
114
+ // The predecessor operates on all elements in the family (e.g., in a
115
+ // `SUM` expression), so separating this variable does not narrow the edge
116
+ return false
117
+ }
118
+ if (refKinds.elementRef) {
119
+ // The predecessor references this variable by an individual element
120
+ // (or through a subdimension, which Vensim maps element by element)
121
+ return true
122
+ }
123
+ if (refKinds.fullDimRef) {
124
+ // The predecessor references this variable through the full dimension;
125
+ // that reference narrows to an element when the predecessor itself is
126
+ // (or will be) separated on the same family
127
+ if (pv.subscripts?.some(s => isIndex(s) && sub(s).family === familyId)) {
128
+ return true
129
+ }
130
+ return acceptedFamilies.has(`${pv.varName}|${familyId}`)
131
+ }
132
+ return false
133
+ }
134
+ if (predsOf.get(c.refId).some(predQualifies)) {
135
+ sccAccepted.add(c)
136
+ acceptedFamilies.add(`${c.v.varName}|${familyId}`)
137
+ addCandidate(candidates, c.v.varName, c.dimId)
138
+ changed = true
139
+ }
140
+ }
141
+ } while (changed)
142
+ if (sccAccepted.size === 0) {
143
+ // No candidate in this cluster satisfied the predecessor condition, so fall
144
+ // back to the candidates that satisfied the successor condition alone
145
+ for (const c of sccLooseCandidates) {
146
+ addCandidate(looseCandidates, c.v.varName, c.dimId)
147
+ }
148
+ }
149
+ }
150
+ if (candidates.size > 0) {
151
+ return candidates
152
+ }
153
+ return looseCandidates
154
+ }
155
+
156
+ /**
157
+ * Examine how the given variable's parsed equation references the named variable
158
+ * in subscript positions of the given family.
159
+ *
160
+ * @param {*} referencingVar The `Variable` instance whose equation is examined.
161
+ * @param {string} varName The name (in canonical form) of the referenced variable.
162
+ * @param {string} familyId The ID of the subscript family of interest.
163
+ * @returns {object} An object with three flags:
164
+ * - `elementRef` is set when a reference uses an individual index or a subdimension
165
+ * (Vensim maps subdimension references element by element, as in the common
166
+ * `x[current pass] = f(x[preceeding pass])` iteration idiom)
167
+ * - `fullDimRef` is set when a reference uses the full dimension for the family
168
+ * - `markedFullDimRef` is set when a reference uses the full dimension marked for
169
+ * vector operations (e.g., `SUM(x[DimA!])`), which always spans all elements
170
+ */
171
+ function elementRefKinds(referencingVar, varName, familyId) {
172
+ const kinds = { elementRef: false, fullDimRef: false, markedFullDimRef: false }
173
+ const visit = node => {
174
+ if (node === null || typeof node !== 'object') {
175
+ return
176
+ }
177
+ if (Array.isArray(node)) {
178
+ node.forEach(visit)
179
+ return
180
+ }
181
+ if (node.kind === 'variable-ref' && node.varId === varName && node.subscriptRefs) {
182
+ for (const subRef of node.subscriptRefs) {
183
+ // Remove the mark from a marked dimension (e.g., `_dima!`)
184
+ const marked = subRef.subId.includes('!')
185
+ const subId = subRef.subId.replace('!', '')
186
+ const s = sub(subId)
187
+ if (s?.family !== familyId) {
188
+ continue
189
+ }
190
+ if (isIndex(subId) || s.size < sub(familyId).size) {
191
+ kinds.elementRef = true
192
+ } else if (marked) {
193
+ kinds.markedFullDimRef = true
194
+ } else {
195
+ kinds.fullDimRef = true
196
+ }
197
+ }
198
+ }
199
+ for (const key of Object.keys(node)) {
200
+ visit(node[key])
201
+ }
202
+ }
203
+ const eqn = referencingVar.parsedEqn
204
+ if (eqn?.rhs?.kind === 'expr') {
205
+ visit(eqn.rhs.expr)
206
+ }
207
+ return kinds
208
+ }
@@ -3,7 +3,7 @@ import * as R from 'ramda'
3
3
  import { canonicalVarId, toPrettyString } from '@sdeverywhere/parse'
4
4
 
5
5
  import B from '../_shared/bufx.js'
6
- import { decanonicalize, isIterable, strlist, vlog, vsort } from '../_shared/helpers.js'
6
+ import { decanonicalize, isIterable, resetHelperState, strlist, vlog, vsort } from '../_shared/helpers.js'
7
7
  import {
8
8
  addIndex,
9
9
  allAliases,
@@ -11,11 +11,13 @@ import {
11
11
  indexNamesForSubscript,
12
12
  isDimension,
13
13
  isIndex,
14
+ resetSubscriptsAndDimensions,
14
15
  sub,
15
16
  subscriptFamilies
16
17
  } from '../_shared/subscript.js'
17
18
  import { cName } from '../_shared/var-names.js'
18
19
 
20
+ import { separationCandidatesForCycles } from './analyze-cycles.js'
19
21
  import { expandVar } from './expand-var-instances.js'
20
22
  import { readEquation, resolveXmileDimensionWildcards } from './read-equations.js'
21
23
  import { readDimensionDefs } from './read-subscripts.js'
@@ -60,6 +62,12 @@ function resetModelState() {
60
62
  * Note that this function currently does not return anything and instead stores the parsed subscript
61
63
  * definitions in the `subscript` module and the parsed/analyzed variables in this module.
62
64
  *
65
+ * After a full read, the variables are sorted in dependency order (and the sorted lists are cached
66
+ * for later use by code generation and variable listings). A false cyclic dependency detected
67
+ * during sorting (one that Vensim's element-by-element evaluation order would not produce) is
68
+ * repaired by separating the variables identified by the cycle analysis and re-reading the model,
69
+ * as if those variables had been listed in `specialSeparationDims` in the spec file.
70
+ *
63
71
  * TODO: FIX TYPE
64
72
  * @param {*} parsedModel The parsed model structure.
65
73
  * @param {*} spec The parsed `spec.json` object.
@@ -71,6 +79,70 @@ function resetModelState() {
71
79
  * @param {*} [opts] An optional object used by tests to stop the read process after a specific phase.
72
80
  */
73
81
  function read(parsedModel, spec, extData, directData, modelDirname, opts) {
82
+ const maxAttempts = 20
83
+ for (let attempt = 1; ; attempt++) {
84
+ try {
85
+ readModel(parsedModel, spec, extData, directData, modelDirname, opts)
86
+ if (
87
+ opts?.stopAfterReadSubscripts ||
88
+ opts?.stopAfterResolveSubscripts ||
89
+ opts?.stopAfterReadVariables ||
90
+ opts?.stopAfterAnalyze
91
+ ) {
92
+ // The read was stopped early (used by tests), so skip the dependency sorting
93
+ return
94
+ }
95
+ // Sort the variables in dependency order now so that any cyclic dependency is
96
+ // detected here; the sorted lists are cached for later use
97
+ auxVars()
98
+ levelVars()
99
+ initVars()
100
+ return
101
+ } catch (e) {
102
+ if (!e.cycles || !spec || attempt >= maxAttempts) {
103
+ throw e
104
+ }
105
+ if (process.env.SDE_PRINT_CYCLES === '1') {
106
+ console.error(`Cycle found on attempt ${attempt}:\n${e.cycle.join(' →\n')}\n`)
107
+ }
108
+ // Find variables in the cycle clusters that can be separated to break the cycles
109
+ const candidates = separationCandidatesForCycles(e.cycles, e.outgoingEdges)
110
+ const specialSeparationDims = spec.specialSeparationDims || {}
111
+ let addedDims = false
112
+ for (const [varName, dimIds] of candidates) {
113
+ let dims = specialSeparationDims[varName] || []
114
+ if (!Array.isArray(dims)) {
115
+ dims = [dims]
116
+ }
117
+ for (const dimId of dimIds) {
118
+ if (!dims.includes(dimId)) {
119
+ dims.push(dimId)
120
+ addedDims = true
121
+ if (process.env.SDE_PRINT_CYCLES === '1') {
122
+ console.error(`Breaking a dependency cycle by separating ${varName} on dimension ${dimId}`)
123
+ }
124
+ }
125
+ }
126
+ specialSeparationDims[varName] = dims
127
+ }
128
+ if (!addedDims) {
129
+ // The cycle analysis did not find any new separations, so the cycle cannot
130
+ // be broken this way; report it to the user
131
+ throw e
132
+ }
133
+ spec.specialSeparationDims = specialSeparationDims
134
+ // Reset the model state and read the model again with the added separations
135
+ resetHelperState()
136
+ resetSubscriptsAndDimensions()
137
+ resetModelState()
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Perform a single pass of the model read process (see `read` above).
144
+ */
145
+ function readModel(parsedModel, spec, extData, directData, modelDirname, opts) {
74
146
  // Some arrays need to be separated into variables with individual indices to
75
147
  // prevent eval cycles. They are manually added to the spec file.
76
148
  let specialSeparationDims = spec.specialSeparationDims
@@ -508,6 +508,10 @@ function visitFunctionCall(v, callExpr, context) {
508
508
  argModes[2] = 'init'
509
509
  break
510
510
 
511
+ case '_DEMAND_AT_PRICE':
512
+ validateCallArgs(callExpr, 3)
513
+ break
514
+
511
515
  case '_DEPRECIATE_STRAIGHTLINE':
512
516
  validateCallDepth(callExpr, context)
513
517
  validateCallArgs(callExpr, 4)
@@ -522,6 +526,10 @@ function visitFunctionCall(v, callExpr, context) {
522
526
  argModes[2] = 'init'
523
527
  break
524
528
 
529
+ case '_FIND_MARKET_PRICE':
530
+ validateCallArgs(callExpr, 4)
531
+ break
532
+
525
533
  case '_GAME':
526
534
  validateCallDepth(callExpr, context)
527
535
  validateCallArgs(callExpr, 1)
@@ -587,6 +595,11 @@ function visitFunctionCall(v, callExpr, context) {
587
595
  argModes[1] = 'init'
588
596
  break
589
597
 
598
+ case '_INVERT_MATRIX':
599
+ validateCallDepth(callExpr, context)
600
+ validateCallArgs(callExpr, 2)
601
+ break
602
+
590
603
  case '_NPV':
591
604
  validateCallArgs(callExpr, 4)
592
605
  addFnReference = false
@@ -612,6 +625,10 @@ function visitFunctionCall(v, callExpr, context) {
612
625
  generateSmoothVariables(v, callExpr, context)
613
626
  break
614
627
 
628
+ case '_SUPPLY_AT_PRICE':
629
+ validateCallArgs(callExpr, 3)
630
+ break
631
+
615
632
  case '_TREND':
616
633
  validateCallArgs(callExpr, 3)
617
634
  addFnReference = false
@@ -847,25 +864,27 @@ function visitFunctionCall(v, callExpr, context) {
847
864
  if (callExpr.fnId === '_WITH_LOOKUP' && index > 1) {
848
865
  // XXX: For `WITH LOOKUP` calls, only process the first argument; need to generalize this
849
866
  break
850
- } else if (callExpr.fnId === '_ALLOCATE_AVAILABLE' && index === 1) {
851
- // Handle the second (`pp` or priority profile) argument of `ALLOCATE AVAILABLE` calls
867
+ } else if (
868
+ (callExpr.fnId === '_ALLOCATE_AVAILABLE' && index === 1) ||
869
+ (callExpr.fnId === '_DEMAND_AT_PRICE' && index === 1) ||
870
+ (callExpr.fnId === '_SUPPLY_AT_PRICE' && index === 1) ||
871
+ (callExpr.fnId === '_FIND_MARKET_PRICE' && (index === 1 || index === 3))
872
+ ) {
873
+ // Handle the second (`pp` or priority profile) argument of allocation function calls
852
874
  // specially. An example call with a 2D `pp` looks like this:
853
875
  // shipments[branch] = ALLOCATE AVAILABLE(demand[branch], priority[branch,ptype], avail) ~~|
854
876
  // Or a 3D `pp` with a dimension:
855
877
  // shipments[item,branch] = ALLOCATE AVAILABLE(demand[branch], priority[item,branch,ptype], avail) ~~|
856
878
  // Or a 3D `pp` with a specific subscript:
857
879
  // shipments[branch] = ALLOCATE AVAILABLE(demand[branch], priority[item1,branch,ptype], avail) ~~|
858
- // Vensim requires passing a reference with `ptype` as the last subscript, but the function
859
- // implementation uses the `ppriority` and `pwidth` values (the `ptype` is currently assumed
860
- // to be 3). Therefore we need to add references to all variants of the variable, not just
861
- // the ones for `ptype`.
880
+ // Vensim requires passing a reference with `ptype` as the last subscript.
862
881
  if (argExpr.kind !== 'variable-ref') {
863
- throw new Error(`ALLOCATE AVAILABLE argument 'pp' must be a variable reference`)
882
+ throw new Error(`${callExpr.fnName} argument 'pp' must be a variable reference`)
864
883
  }
865
884
  // TODO: Throw an error if the last dimension of arg0 does not match last dimension of LHS
866
885
  // TODO: Throw an error if the second-to-last dimension of arg1 does not match last dimension of LHS
867
886
  // TODO: Throw an error if the last subscript of arg1 does not have the "shape" of a `ppriority` dimension
868
- // TODO: Throw an error if the `ptype` value is not 3
887
+ // TODO: Throw an error if the `ptype` value is unsupported
869
888
  // Get the RHS subscript/dimension IDs
870
889
  const rhsVarBaseRefId = argExpr.varId
871
890
  const rhsVarSubIds = argExpr.subscriptRefs?.map(subRef => subRef.subId) || []
@@ -120,22 +120,27 @@ function variablesForEquation(eqn, specialSeparationDims, separateAllVarsWithDim
120
120
  let separationDims = specialSeparationDims[baseVarId] || []
121
121
  if (!Array.isArray(separationDims)) {
122
122
  separationDims = [separationDims]
123
+ } else {
124
+ // Copy the array so that the spec object is not mutated below
125
+ separationDims = [...separationDims]
123
126
  }
124
- // Alternatively, if the variable was not in `specialSeparationDims`, separate
125
- // on dims from `separateAllVarsWithDims` if the var matches one of the dim lists.
126
- if (separationDims.length === 0) {
127
- for (let dimList of separateAllVarsWithDims) {
128
- // Technically we allow each entry in the spec array to be either a single
129
- // dim ID string or an array of dim IDs, so convert to array if needed
130
- if (!Array.isArray(dimList)) {
131
- dimList = [dimList]
132
- }
133
- // The list entry from the spec is only considered a match if every dimension
134
- // in the spec list appears on the LHS
135
- if (dimList.every(dim => subIds.includes(dim))) {
136
- separationDims = dimList
137
- break
127
+ // Additionally, separate on dims from `separateAllVarsWithDims` if the var
128
+ // matches one of the dim lists.
129
+ for (let dimList of separateAllVarsWithDims) {
130
+ // Technically we allow each entry in the spec array to be either a single
131
+ // dim ID string or an array of dim IDs, so convert to array if needed
132
+ if (!Array.isArray(dimList)) {
133
+ dimList = [dimList]
134
+ }
135
+ // The list entry from the spec is only considered a match if every dimension
136
+ // in the spec list appears on the LHS
137
+ if (dimList.every(dim => subIds.includes(dim))) {
138
+ for (const dim of dimList) {
139
+ if (!separationDims.includes(dim)) {
140
+ separationDims.push(dim)
141
+ }
138
142
  }
143
+ break
139
144
  }
140
145
  }
141
146
  positionsToExpand = subscriptPositionsToExpand(subIds, exceptSubIdSets, separationDims, variable.modelFormula)
@@ -42,7 +42,19 @@ function toposort(nodes, edges) {
42
42
  } catch (_) {
43
43
  nodeRep = ''
44
44
  }
45
- throw new Error('Found cyclic dependency during toposort:\n' + [...predecessors].join(' →\n') + ' →' + nodeRep)
45
+ var chain = [...predecessors]
46
+ var error = new Error('Found cyclic dependency during toposort:\n' + chain.join(' →\n') + ' →' + nodeRep)
47
+ // Attach the cycle itself (the portion of the dependency chain from the first
48
+ // occurrence of the repeated node) so that callers can analyze it.
49
+ error.cycle = chain.slice(chain.indexOf(node))
50
+ // Also attach all cycle clusters in the graph (the strongly connected components
51
+ // with more than one node, plus any single node that depends on itself) along
52
+ // with the graph edges so that callers can analyze every cycle at once.
53
+ error.cycles = stronglyConnectedComponents(nodes, outgoingEdges).filter(
54
+ scc => scc.length > 1 || (outgoingEdges.get(scc[0]) || new Set()).has(scc[0])
55
+ )
56
+ error.outgoingEdges = outgoingEdges
57
+ throw error
46
58
  }
47
59
 
48
60
  if (!nodesHash.has(node)) {
@@ -70,6 +82,76 @@ function toposort(nodes, edges) {
70
82
  }
71
83
  }
72
84
 
85
+ /**
86
+ * Find the strongly connected components of the graph using an iterative form of
87
+ * Tarjan's algorithm (iterative to avoid stack overflow on the deep dependency
88
+ * chains found in large models).
89
+ *
90
+ * @param {Array} nodes The nodes in the graph.
91
+ * @param {Map} outgoingEdges A map of each node to the set of nodes that it points to.
92
+ * @returns {Array} An array of strongly connected components, where each component is
93
+ * an array of the nodes that it contains.
94
+ */
95
+ function stronglyConnectedComponents(nodes, outgoingEdges) {
96
+ var index = 0
97
+ var nodeIndex = new Map()
98
+ var lowlink = new Map()
99
+ var onStack = new Set()
100
+ var stack = []
101
+ var sccs = []
102
+ for (var start of nodes) {
103
+ if (nodeIndex.has(start)) {
104
+ continue
105
+ }
106
+ var frames = [{ node: start, edges: null, i: 0, child: undefined }]
107
+ while (frames.length > 0) {
108
+ var frame = frames[frames.length - 1]
109
+ var node = frame.node
110
+ if (frame.edges === null) {
111
+ // First visit to this node
112
+ nodeIndex.set(node, index)
113
+ lowlink.set(node, index)
114
+ index++
115
+ stack.push(node)
116
+ onStack.add(node)
117
+ frame.edges = Array.from(outgoingEdges.get(node) || [])
118
+ } else if (frame.child !== undefined) {
119
+ // Returning from a child visit
120
+ lowlink.set(node, Math.min(lowlink.get(node), lowlink.get(frame.child)))
121
+ frame.child = undefined
122
+ }
123
+ var descended = false
124
+ while (frame.i < frame.edges.length) {
125
+ var w = frame.edges[frame.i++]
126
+ if (!nodeIndex.has(w)) {
127
+ frame.child = w
128
+ frames.push({ node: w, edges: null, i: 0, child: undefined })
129
+ descended = true
130
+ break
131
+ } else if (onStack.has(w)) {
132
+ lowlink.set(node, Math.min(lowlink.get(node), nodeIndex.get(w)))
133
+ }
134
+ }
135
+ if (descended) {
136
+ continue
137
+ }
138
+ // All edges have been visited, so the node is complete
139
+ if (lowlink.get(node) === nodeIndex.get(node)) {
140
+ var scc = []
141
+ var member
142
+ do {
143
+ member = stack.pop()
144
+ onStack.delete(member)
145
+ scc.push(member)
146
+ } while (member !== node)
147
+ sccs.push(scc)
148
+ }
149
+ frames.pop()
150
+ }
151
+ }
152
+ return sccs
153
+ }
154
+
73
155
  function uniqueNodes(arr) {
74
156
  var res = new Set()
75
157
  for (var i = 0, len = arr.length; i < len; i++) {