@sdeverywhere/compile 0.7.30 → 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.
@@ -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
  *
@@ -1,8 +1,8 @@
1
1
  import * as R from 'ramda'
2
- import XLSX from 'xlsx'
3
2
 
4
3
  import { listConcat } from '../_shared/helpers.js'
5
4
  import { sub } from '../_shared/subscript.js'
5
+ import { decodeCell, decodeCol, decodeRow } from '../_shared/xlsx.js'
6
6
 
7
7
  import { handleExcelOrCsvFile } from './direct-data-helpers.js'
8
8
 
@@ -61,7 +61,7 @@ function generateDirectDataLookup(varLhs, getCellValue, timeRowOrCol, startCell,
61
61
  // The cell(c,r) function wraps data access by column and row.
62
62
  let lookupData = ''
63
63
  let lookupSize = 0
64
- let dataAddress = XLSX.utils.decode_cell(startCell.toUpperCase())
64
+ let dataAddress = decodeCell(startCell.toUpperCase())
65
65
  let dataCol = dataAddress.c
66
66
  let dataRow = dataAddress.r
67
67
  if (dataCol < 0 || dataRow < 0) {
@@ -71,7 +71,7 @@ function generateDirectDataLookup(varLhs, getCellValue, timeRowOrCol, startCell,
71
71
  let timeCol, timeRow, nextCell
72
72
  if (isNaN(parseInt(timeRowOrCol))) {
73
73
  // Time values are in a column.
74
- timeCol = XLSX.utils.decode_col(timeRowOrCol.toUpperCase())
74
+ timeCol = decodeCol(timeRowOrCol.toUpperCase())
75
75
  timeRow = dataRow
76
76
  dataCol += indexNum
77
77
  nextCell = () => {
@@ -81,7 +81,7 @@ function generateDirectDataLookup(varLhs, getCellValue, timeRowOrCol, startCell,
81
81
  } else {
82
82
  // Time values are in a row.
83
83
  timeCol = dataCol
84
- timeRow = XLSX.utils.decode_row(timeRowOrCol)
84
+ timeRow = decodeRow(timeRowOrCol)
85
85
  dataRow += indexNum
86
86
  nextCell = () => {
87
87
  dataCol++
@@ -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