@neoloopy/cld-canvas 0.1.3 → 0.1.7
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/LICENSE +1 -1
- package/README.md +30 -3
- package/dist/engine/engine.d.ts +26 -4
- package/dist/engine/exporters.d.ts +1 -1
- package/dist/engine/exporters.js +1 -1
- package/dist/engine/loopGraph.d.ts +8 -4
- package/dist/engine/loopGraph.js +0 -0
- package/dist/engine/loopKey.d.ts +9 -0
- package/dist/engine/loopKey.js +14 -0
- package/dist/engine/nativeEngine.d.ts +10 -2
- package/dist/engine/nativeEngine.js +226 -70
- package/dist/engine/noteCodec.js +57 -5
- package/dist/engine/publicInterface.d.ts +43 -0
- package/dist/engine/publicInterface.js +52 -0
- package/dist/engine/quantCanvasLoops.d.ts +36 -0
- package/dist/engine/quantCanvasLoops.js +694 -0
- package/dist/engine/sfd.d.ts +47 -0
- package/dist/engine/sfd.js +320 -0
- package/dist/engine/specHash.js +4 -0
- package/dist/engine/types.d.ts +54 -3
- package/dist/engine/types.js +66 -5
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/view/geometry.d.ts +35 -1
- package/dist/view/geometry.js +174 -7
- package/dist/view/painter.d.ts +10 -1
- package/dist/view/painter.js +243 -15
- package/dist/view/sceneCache.d.ts +2 -1
- package/dist/view/sceneCache.js +35 -13
- package/package.json +12 -2
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative, renderer-facing resolution of qualitative and quantitative loops.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately not a simulation or dominance implementation. It only
|
|
5
|
+
* enriches a declared qualitative cycle independently when every authored leg
|
|
6
|
+
* maps to one exact visible connector or material pipe. It admits a synthesized
|
|
7
|
+
* static scalar cycle only when every equation dependency and material effect
|
|
8
|
+
* is equally exact. Incomplete quantitative analysis returns no synthesized
|
|
9
|
+
* prefix but never strips an independently resolved qualitative path.
|
|
10
|
+
*/
|
|
11
|
+
import { CanvasLoopPath, DetectedLoop, LoopType, canonicalDirectedCycle, } from "./types";
|
|
12
|
+
import { flowOf, isCloud, resolveFlowSpec, validateFlowEndpoints, } from "./sfd";
|
|
13
|
+
export const USER_FACING_CANVAS_LOOP_LIMITS = Object.freeze({
|
|
14
|
+
maxLoops: 2048,
|
|
15
|
+
maxEdgeVisits: 100000,
|
|
16
|
+
maxDepth: 512,
|
|
17
|
+
});
|
|
18
|
+
const UNSUPPORTED_STATEFUL_FUNCTIONS = new Set([
|
|
19
|
+
"delay",
|
|
20
|
+
"delay1",
|
|
21
|
+
"delay3",
|
|
22
|
+
"delay_fixed",
|
|
23
|
+
"delay_information",
|
|
24
|
+
"delay_material",
|
|
25
|
+
"smooth",
|
|
26
|
+
"smooth3",
|
|
27
|
+
"smoothi",
|
|
28
|
+
"smooth3i",
|
|
29
|
+
"trend",
|
|
30
|
+
"forecast",
|
|
31
|
+
"npv",
|
|
32
|
+
]);
|
|
33
|
+
const SUPPORTED_SCALAR_FUNCTIONS = new Set([
|
|
34
|
+
"abs",
|
|
35
|
+
"acos",
|
|
36
|
+
"asin",
|
|
37
|
+
"atan",
|
|
38
|
+
"ceil",
|
|
39
|
+
"ceiling",
|
|
40
|
+
"cos",
|
|
41
|
+
"exp",
|
|
42
|
+
"floor",
|
|
43
|
+
"if_then_else",
|
|
44
|
+
"integer",
|
|
45
|
+
"ln",
|
|
46
|
+
"log",
|
|
47
|
+
"max",
|
|
48
|
+
"min",
|
|
49
|
+
"mod",
|
|
50
|
+
"power",
|
|
51
|
+
"pulse",
|
|
52
|
+
"pulse_train",
|
|
53
|
+
"ramp",
|
|
54
|
+
"round",
|
|
55
|
+
"sin",
|
|
56
|
+
"sqrt",
|
|
57
|
+
"step",
|
|
58
|
+
"tan",
|
|
59
|
+
]);
|
|
60
|
+
const SCALAR_NAMES = new Set(["false", "pi", "time", "true"]);
|
|
61
|
+
const ARRAY_KEYS = new Set([
|
|
62
|
+
"cell",
|
|
63
|
+
"cells",
|
|
64
|
+
"dimension",
|
|
65
|
+
"dimensions",
|
|
66
|
+
"dims",
|
|
67
|
+
"element",
|
|
68
|
+
"elements",
|
|
69
|
+
"flattened",
|
|
70
|
+
"subscript",
|
|
71
|
+
"subscriptinstance",
|
|
72
|
+
"subscripts",
|
|
73
|
+
]);
|
|
74
|
+
const COMPOSITION_KEYS = new Set([
|
|
75
|
+
"binding",
|
|
76
|
+
"bindings",
|
|
77
|
+
"composed",
|
|
78
|
+
"composedfrom",
|
|
79
|
+
"composition",
|
|
80
|
+
"inputbinding",
|
|
81
|
+
"inputbindings",
|
|
82
|
+
"subsystem",
|
|
83
|
+
"subsystems",
|
|
84
|
+
]);
|
|
85
|
+
const NODE_HIERARCHY_KEYS = new Set([
|
|
86
|
+
"composedfrom",
|
|
87
|
+
"inputbinding",
|
|
88
|
+
"inputbindings",
|
|
89
|
+
"subsystem",
|
|
90
|
+
"subsystems",
|
|
91
|
+
]);
|
|
92
|
+
function objectMap(value) {
|
|
93
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
94
|
+
? value
|
|
95
|
+
: null;
|
|
96
|
+
}
|
|
97
|
+
function nonEmpty(value) {
|
|
98
|
+
if (value === null || value === undefined || value === false)
|
|
99
|
+
return false;
|
|
100
|
+
if (typeof value === "string")
|
|
101
|
+
return value.trim().length > 0;
|
|
102
|
+
if (Array.isArray(value))
|
|
103
|
+
return value.length > 0;
|
|
104
|
+
if (typeof value === "object")
|
|
105
|
+
return Object.keys(value).length > 0;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
function containsMarkedKey(value, keys) {
|
|
109
|
+
const map = objectMap(value);
|
|
110
|
+
if (!map)
|
|
111
|
+
return false;
|
|
112
|
+
for (const [rawKey, nested] of Object.entries(map)) {
|
|
113
|
+
const key = rawKey.toLowerCase().replace(/_/g, "");
|
|
114
|
+
if (keys.has(key) && nonEmpty(nested))
|
|
115
|
+
return true;
|
|
116
|
+
if (containsMarkedKey(nested, keys))
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
function scalarText(value) {
|
|
122
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
123
|
+
const text = String(value).trim();
|
|
124
|
+
return text.length > 0 ? text : null;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
function incomplete(reason, limits) {
|
|
129
|
+
return `quant-loop-analysis-incomplete: ${reason} ` +
|
|
130
|
+
`(maxLoops=${limits.maxLoops}, maxEdgeVisits=${limits.maxEdgeVisits}, ` +
|
|
131
|
+
`maxDepth=${limits.maxDepth}); repair or simplify the model and retry`;
|
|
132
|
+
}
|
|
133
|
+
function pairKey(from, to) {
|
|
134
|
+
return `${from.length}:${from}>${to.length}:${to}`;
|
|
135
|
+
}
|
|
136
|
+
/** Stable first-class SFD pipe-leg identity. */
|
|
137
|
+
export function materialPipeLegId(flowId, stockId) {
|
|
138
|
+
return pairKey(flowId, stockId);
|
|
139
|
+
}
|
|
140
|
+
/** Stable base id for a non-persistent CLD projection of a material effect. */
|
|
141
|
+
export function materialProjectionEdgeId(flowId, stockId) {
|
|
142
|
+
return `__cld_material_projection__${flowId.length}:${flowId}:` +
|
|
143
|
+
`${stockId.length}:${stockId}`;
|
|
144
|
+
}
|
|
145
|
+
function uniqueProjectionId(flowId, stockId, persistedIds) {
|
|
146
|
+
const base = materialProjectionEdgeId(flowId, stockId);
|
|
147
|
+
if (!persistedIds.has(base))
|
|
148
|
+
return base;
|
|
149
|
+
let suffix = 1;
|
|
150
|
+
while (persistedIds.has(`${base}:${suffix}`))
|
|
151
|
+
suffix++;
|
|
152
|
+
return `${base}:${suffix}`;
|
|
153
|
+
}
|
|
154
|
+
function isWord(ch) {
|
|
155
|
+
return ch.length > 0 && /[A-Za-z0-9_]/.test(ch);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Parse only enough scalar expression syntax to prove exact model references.
|
|
159
|
+
* Unknown identifiers, stateful functions, arrays, and malformed token order
|
|
160
|
+
* fail closed. Polarity still comes from the declared connector, not algebraic
|
|
161
|
+
* inference.
|
|
162
|
+
*/
|
|
163
|
+
function parseScalarEquation(source, labelsByLongest) {
|
|
164
|
+
if (/[[\]{};"'`]/.test(source)) {
|
|
165
|
+
return { ok: false, reason: "unsupported scalar equation syntax" };
|
|
166
|
+
}
|
|
167
|
+
const refs = [];
|
|
168
|
+
const seenRefs = new Set();
|
|
169
|
+
const parens = [];
|
|
170
|
+
let i = 0;
|
|
171
|
+
let expectValue = true;
|
|
172
|
+
let pendingFunction = false;
|
|
173
|
+
const nextNonSpaceIndex = (at) => {
|
|
174
|
+
let cursor = at;
|
|
175
|
+
while (cursor < source.length && /\s/.test(source[cursor]))
|
|
176
|
+
cursor++;
|
|
177
|
+
return cursor;
|
|
178
|
+
};
|
|
179
|
+
const addRef = (id) => {
|
|
180
|
+
if (seenRefs.add(id))
|
|
181
|
+
refs.push(id);
|
|
182
|
+
};
|
|
183
|
+
while (i < source.length) {
|
|
184
|
+
if (/\s/.test(source[i])) {
|
|
185
|
+
i++;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
let matchedLabel;
|
|
189
|
+
for (const candidate of labelsByLongest) {
|
|
190
|
+
const end = i + candidate.label.length;
|
|
191
|
+
if (source.slice(i, end).toLowerCase() !== candidate.label.toLowerCase())
|
|
192
|
+
continue;
|
|
193
|
+
const before = i > 0 ? source[i - 1] : "";
|
|
194
|
+
const after = end < source.length ? source[end] : "";
|
|
195
|
+
if (isWord(before) || isWord(after))
|
|
196
|
+
continue;
|
|
197
|
+
if (source[nextNonSpaceIndex(end)] === "(")
|
|
198
|
+
continue;
|
|
199
|
+
matchedLabel = candidate;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
if (matchedLabel) {
|
|
203
|
+
if (!expectValue)
|
|
204
|
+
return { ok: false, reason: "malformed equation token order" };
|
|
205
|
+
addRef(matchedLabel.id);
|
|
206
|
+
i += matchedLabel.label.length;
|
|
207
|
+
expectValue = false;
|
|
208
|
+
pendingFunction = false;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const rest = source.slice(i);
|
|
212
|
+
const number = /^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/.exec(rest);
|
|
213
|
+
if (number) {
|
|
214
|
+
if (!expectValue)
|
|
215
|
+
return { ok: false, reason: "malformed equation token order" };
|
|
216
|
+
i += number[0].length;
|
|
217
|
+
expectValue = false;
|
|
218
|
+
pendingFunction = false;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const multiFunction = /^(IF\s+THEN\s+ELSE|PULSE\s+TRAIN)\b/i.exec(rest);
|
|
222
|
+
const identifier = multiFunction ?? /^[A-Za-z_][A-Za-z0-9_]*/.exec(rest);
|
|
223
|
+
if (identifier) {
|
|
224
|
+
const raw = identifier[0];
|
|
225
|
+
const normalized = raw.trim().toLowerCase().replace(/\s+/g, "_");
|
|
226
|
+
const next = nextNonSpaceIndex(i + raw.length);
|
|
227
|
+
const called = source[next] === "(";
|
|
228
|
+
if (called) {
|
|
229
|
+
if (!expectValue)
|
|
230
|
+
return { ok: false, reason: "malformed equation token order" };
|
|
231
|
+
if (UNSUPPORTED_STATEFUL_FUNCTIONS.has(normalized)) {
|
|
232
|
+
return { ok: false, reason: `unsupported stateful function ${raw.trim()}` };
|
|
233
|
+
}
|
|
234
|
+
if (!SUPPORTED_SCALAR_FUNCTIONS.has(normalized)) {
|
|
235
|
+
return { ok: false, reason: `unsupported function ${raw.trim()}` };
|
|
236
|
+
}
|
|
237
|
+
pendingFunction = true;
|
|
238
|
+
i += raw.length;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (normalized === "not") {
|
|
242
|
+
if (!expectValue)
|
|
243
|
+
return { ok: false, reason: "malformed equation token order" };
|
|
244
|
+
i += raw.length;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (normalized === "and" || normalized === "or") {
|
|
248
|
+
if (expectValue)
|
|
249
|
+
return { ok: false, reason: "malformed equation token order" };
|
|
250
|
+
expectValue = true;
|
|
251
|
+
i += raw.length;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (!SCALAR_NAMES.has(normalized)) {
|
|
255
|
+
return { ok: false, reason: `unknown equation identifier ${raw}` };
|
|
256
|
+
}
|
|
257
|
+
if (!expectValue)
|
|
258
|
+
return { ok: false, reason: "malformed equation token order" };
|
|
259
|
+
expectValue = false;
|
|
260
|
+
pendingFunction = false;
|
|
261
|
+
i += raw.length;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (source[i] === "(") {
|
|
265
|
+
if (!expectValue && !pendingFunction) {
|
|
266
|
+
return { ok: false, reason: "malformed equation token order" };
|
|
267
|
+
}
|
|
268
|
+
parens.push(pendingFunction ? "function" : "group");
|
|
269
|
+
pendingFunction = false;
|
|
270
|
+
expectValue = true;
|
|
271
|
+
i++;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (source[i] === ")") {
|
|
275
|
+
if (expectValue || parens.length === 0) {
|
|
276
|
+
return { ok: false, reason: "unbalanced or empty parentheses" };
|
|
277
|
+
}
|
|
278
|
+
parens.pop();
|
|
279
|
+
expectValue = false;
|
|
280
|
+
pendingFunction = false;
|
|
281
|
+
i++;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (source[i] === ",") {
|
|
285
|
+
if (expectValue || parens[parens.length - 1] !== "function") {
|
|
286
|
+
return { ok: false, reason: "malformed function arguments" };
|
|
287
|
+
}
|
|
288
|
+
expectValue = true;
|
|
289
|
+
pendingFunction = false;
|
|
290
|
+
i++;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const operator = /^(<=|>=|<>|==|!=|&&|\|\||[+\-*/^%<>=!])/.exec(rest);
|
|
294
|
+
if (operator) {
|
|
295
|
+
const unary = expectValue && ["+", "-", "!"].includes(operator[0]);
|
|
296
|
+
if (expectValue && !unary) {
|
|
297
|
+
return { ok: false, reason: "malformed equation token order" };
|
|
298
|
+
}
|
|
299
|
+
expectValue = true;
|
|
300
|
+
pendingFunction = false;
|
|
301
|
+
i += operator[0].length;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
return { ok: false, reason: `unsupported equation token ${source[i]}` };
|
|
305
|
+
}
|
|
306
|
+
if (pendingFunction || expectValue || parens.length > 0) {
|
|
307
|
+
return { ok: false, reason: "incomplete or unbalanced equation" };
|
|
308
|
+
}
|
|
309
|
+
return { ok: true, references: refs };
|
|
310
|
+
}
|
|
311
|
+
function resolveQualitativeCanvasPath(nodes, loop) {
|
|
312
|
+
const byId = new Map();
|
|
313
|
+
for (const node of nodes) {
|
|
314
|
+
if (byId.has(node.id))
|
|
315
|
+
return null;
|
|
316
|
+
byId.set(node.id, node);
|
|
317
|
+
}
|
|
318
|
+
if (loop.nodeIds.length < 2 || new Set(loop.nodeIds).size !== loop.nodeIds.length) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
const resolvedNodes = [];
|
|
322
|
+
for (const id of loop.nodeIds) {
|
|
323
|
+
const node = byId.get(id);
|
|
324
|
+
if (!node)
|
|
325
|
+
return null;
|
|
326
|
+
resolvedNodes.push(node);
|
|
327
|
+
}
|
|
328
|
+
const legs = [];
|
|
329
|
+
let polarityProduct = 1;
|
|
330
|
+
for (let index = 0; index < resolvedNodes.length; index++) {
|
|
331
|
+
const from = resolvedNodes[index];
|
|
332
|
+
const to = resolvedNodes[(index + 1) % resolvedNodes.length];
|
|
333
|
+
const candidates = from.links.filter((link) => link.to === to.id);
|
|
334
|
+
if (candidates.length !== 1)
|
|
335
|
+
return null;
|
|
336
|
+
const link = candidates[0];
|
|
337
|
+
if (link.indirect || (link.polarity !== "+" && link.polarity !== "-")) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
const declaredPolarity = link.polarity === "-" ? -1 : 1;
|
|
341
|
+
if (from.type === "flow" && to.type === "stock") {
|
|
342
|
+
const spec = resolveFlowSpec(from, byId);
|
|
343
|
+
if (!spec)
|
|
344
|
+
return null;
|
|
345
|
+
const drains = spec.from === to.id;
|
|
346
|
+
const fills = spec.to === to.id;
|
|
347
|
+
if (drains && fills)
|
|
348
|
+
return null;
|
|
349
|
+
if (drains || fills) {
|
|
350
|
+
const materialPolarity = fills ? 1 : -1;
|
|
351
|
+
if (declaredPolarity !== materialPolarity)
|
|
352
|
+
return null;
|
|
353
|
+
legs.push({
|
|
354
|
+
kind: "material",
|
|
355
|
+
fromNodeId: from.id,
|
|
356
|
+
toNodeId: to.id,
|
|
357
|
+
flowId: from.id,
|
|
358
|
+
stockId: to.id,
|
|
359
|
+
cldEdgeId: `${from.id}__${to.id}`,
|
|
360
|
+
polarity: materialPolarity,
|
|
361
|
+
});
|
|
362
|
+
polarityProduct *= materialPolarity;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
legs.push({
|
|
367
|
+
kind: "causal",
|
|
368
|
+
fromNodeId: from.id,
|
|
369
|
+
toNodeId: to.id,
|
|
370
|
+
edgeId: `${from.id}__${to.id}`,
|
|
371
|
+
polarity: declaredPolarity,
|
|
372
|
+
});
|
|
373
|
+
polarityProduct *= declaredPolarity;
|
|
374
|
+
}
|
|
375
|
+
const resolvedType = polarityProduct === 1
|
|
376
|
+
? LoopType.reinforcing
|
|
377
|
+
: LoopType.balancing;
|
|
378
|
+
if (resolvedType !== loop.type)
|
|
379
|
+
return null;
|
|
380
|
+
return new DetectedLoop(loop.nodeIds, loop.type, new CanvasLoopPath(legs), loop.identityMode, loop.exactRouteAmbiguous);
|
|
381
|
+
}
|
|
382
|
+
function qualitativeOnly(nodes, qualitativeLoops, analysisError) {
|
|
383
|
+
const grouped = new Map();
|
|
384
|
+
for (const loop of qualitativeLoops) {
|
|
385
|
+
const existing = grouped.get(loop.key);
|
|
386
|
+
if (!existing) {
|
|
387
|
+
grouped.set(loop.key, {
|
|
388
|
+
retained: loop,
|
|
389
|
+
exactKeys: new Set([loop.exactKey]),
|
|
390
|
+
ambiguous: loop.exactRouteAmbiguous,
|
|
391
|
+
});
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
existing.exactKeys.add(loop.exactKey);
|
|
395
|
+
existing.ambiguous = existing.ambiguous ||
|
|
396
|
+
loop.exactRouteAmbiguous ||
|
|
397
|
+
existing.exactKeys.size > 1;
|
|
398
|
+
}
|
|
399
|
+
const loops = [...grouped.values()].map(({ retained, ambiguous }) => {
|
|
400
|
+
if (ambiguous) {
|
|
401
|
+
return new DetectedLoop(retained.nodeIds, retained.type, undefined, retained.identityMode, true);
|
|
402
|
+
}
|
|
403
|
+
return resolveQualitativeCanvasPath(nodes, retained) ??
|
|
404
|
+
(retained.canvasPath === undefined
|
|
405
|
+
? retained
|
|
406
|
+
: new DetectedLoop(retained.nodeIds, retained.type, undefined, retained.identityMode, retained.exactRouteAmbiguous));
|
|
407
|
+
});
|
|
408
|
+
return { loops, analysisError };
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Discover complete canvas-resolvable quantitative cycles and merge them with
|
|
412
|
+
* exact qualitative counterparts. Declared loops are first resolved from their
|
|
413
|
+
* own authored topology, so a quantitative error never suppresses their honest
|
|
414
|
+
* CLD/SFD representation.
|
|
415
|
+
*/
|
|
416
|
+
export function discoverCanvasLoops(nodes, qualitativeLoops, options = {}) {
|
|
417
|
+
const limits = options.limits ?? USER_FACING_CANVAS_LOOP_LIMITS;
|
|
418
|
+
const fail = (reason) => qualitativeOnly(nodes, qualitativeLoops, incomplete(reason, limits));
|
|
419
|
+
if (limits.maxLoops < 1 || limits.maxEdgeVisits < 1 || limits.maxDepth < 2) {
|
|
420
|
+
return fail("invalid discovery limits");
|
|
421
|
+
}
|
|
422
|
+
const manifestQuant = options.manifest?.extra["mode"] === "quantitative" ||
|
|
423
|
+
objectMap(options.manifest?.extra["quantitative"]) !== null;
|
|
424
|
+
const nodeQuant = nodes.some((node) => objectMap(node.extra["quant"]) !== null);
|
|
425
|
+
if (!manifestQuant && !nodeQuant) {
|
|
426
|
+
return qualitativeOnly(nodes, qualitativeLoops, null);
|
|
427
|
+
}
|
|
428
|
+
if (containsMarkedKey(options.manifest?.extra["quantitative"], ARRAY_KEYS) ||
|
|
429
|
+
nodes.some((node) => containsMarkedKey(node.extra["quant"], ARRAY_KEYS))) {
|
|
430
|
+
return fail("arrayed or flattened quantitative topology is not canvas-resolvable");
|
|
431
|
+
}
|
|
432
|
+
if (containsMarkedKey(options.manifest?.extra, COMPOSITION_KEYS) ||
|
|
433
|
+
nodes.some((node) => (node.subsystem ?? "").trim().length > 0 ||
|
|
434
|
+
containsMarkedKey(node.extra["quant"], NODE_HIERARCHY_KEYS))) {
|
|
435
|
+
return fail("subsystem or composed quantitative topology is not canvas-resolvable");
|
|
436
|
+
}
|
|
437
|
+
const byId = new Map();
|
|
438
|
+
const byLabel = new Map();
|
|
439
|
+
for (const node of nodes) {
|
|
440
|
+
if (byId.has(node.id))
|
|
441
|
+
return fail(`duplicate variable id ${node.id}`);
|
|
442
|
+
byId.set(node.id, node);
|
|
443
|
+
const label = node.label.trim();
|
|
444
|
+
if (label.length === 0)
|
|
445
|
+
return fail(`missing variable label for ${node.id}`);
|
|
446
|
+
const folded = label.toLowerCase();
|
|
447
|
+
if (byLabel.has(folded))
|
|
448
|
+
return fail(`duplicate variable label ${label}`);
|
|
449
|
+
byLabel.set(folded, node);
|
|
450
|
+
}
|
|
451
|
+
const labelsByLongest = [...nodes]
|
|
452
|
+
.map((node) => ({ id: node.id, label: node.label.trim() }))
|
|
453
|
+
.sort((a, b) => b.label.length - a.label.length || a.label.localeCompare(b.label));
|
|
454
|
+
let edgeVisits = 0;
|
|
455
|
+
const workLimitReached = () => ++edgeVisits > limits.maxEdgeVisits;
|
|
456
|
+
const displayed = new Map();
|
|
457
|
+
const persistedEdgeIds = new Set();
|
|
458
|
+
for (const source of nodes) {
|
|
459
|
+
for (let index = 0; index < source.links.length; index++) {
|
|
460
|
+
if (workLimitReached())
|
|
461
|
+
return fail("loop discovery exceeded its work limit");
|
|
462
|
+
const link = source.links[index];
|
|
463
|
+
if (!byId.has(link.to))
|
|
464
|
+
continue;
|
|
465
|
+
const key = pairKey(source.id, link.to);
|
|
466
|
+
const entries = displayed.get(key) ?? [];
|
|
467
|
+
entries.push({ node: source, index });
|
|
468
|
+
displayed.set(key, entries);
|
|
469
|
+
persistedEdgeIds.add(`${source.id}__${link.to}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const edges = [];
|
|
473
|
+
const executablePairs = new Set();
|
|
474
|
+
const addEdge = (edge) => {
|
|
475
|
+
const key = pairKey(edge.from, edge.to);
|
|
476
|
+
if (!executablePairs.add(key))
|
|
477
|
+
return fail(`duplicate executable leg ${edge.from} -> ${edge.to}`);
|
|
478
|
+
edges.push(edge);
|
|
479
|
+
return null;
|
|
480
|
+
};
|
|
481
|
+
for (const target of nodes) {
|
|
482
|
+
const quant = objectMap(target.extra["quant"]);
|
|
483
|
+
if (!quant)
|
|
484
|
+
return fail(`missing quantitative definition for ${target.label}`);
|
|
485
|
+
if (target.type === "stock") {
|
|
486
|
+
const initial = scalarText(quant["initial"]);
|
|
487
|
+
if (initial === null || initial.includes(":")) {
|
|
488
|
+
return fail(`missing or unsupported scalar initial for ${target.label}`);
|
|
489
|
+
}
|
|
490
|
+
const parsedInitial = parseScalarEquation(initial, labelsByLongest);
|
|
491
|
+
if (!parsedInitial.ok) {
|
|
492
|
+
return fail(`${parsedInitial.reason} in initial for ${target.label}`);
|
|
493
|
+
}
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
const equation = scalarText(quant["equation"]);
|
|
497
|
+
if (equation === null)
|
|
498
|
+
return fail(`missing equation for ${target.label}`);
|
|
499
|
+
const parsed = parseScalarEquation(equation, labelsByLongest);
|
|
500
|
+
if (!parsed.ok)
|
|
501
|
+
return fail(`${parsed.reason} in ${target.label}`);
|
|
502
|
+
for (const sourceId of parsed.references) {
|
|
503
|
+
if (workLimitReached())
|
|
504
|
+
return fail("loop discovery exceeded its work limit");
|
|
505
|
+
const candidates = displayed.get(pairKey(sourceId, target.id)) ?? [];
|
|
506
|
+
if (candidates.length !== 1) {
|
|
507
|
+
return fail(`equation dependency ${sourceId} -> ${target.id} has ` +
|
|
508
|
+
`${candidates.length} displayed connectors`);
|
|
509
|
+
}
|
|
510
|
+
const entry = candidates[0];
|
|
511
|
+
const link = entry.node.links[entry.index];
|
|
512
|
+
if (link.indirect)
|
|
513
|
+
return fail(`equation dependency ${sourceId} -> ${target.id} is dashed`);
|
|
514
|
+
if (link.polarity !== "+" && link.polarity !== "-") {
|
|
515
|
+
return fail(`equation dependency ${sourceId} -> ${target.id} has unknown polarity`);
|
|
516
|
+
}
|
|
517
|
+
const polarity = link.polarity === "-" ? -1 : 1;
|
|
518
|
+
const failed = addEdge({
|
|
519
|
+
from: sourceId,
|
|
520
|
+
to: target.id,
|
|
521
|
+
polarity,
|
|
522
|
+
leg: {
|
|
523
|
+
kind: "causal",
|
|
524
|
+
fromNodeId: sourceId,
|
|
525
|
+
toNodeId: target.id,
|
|
526
|
+
edgeId: `${sourceId}__${target.id}`,
|
|
527
|
+
polarity,
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
if (failed)
|
|
531
|
+
return failed;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
for (const flow of nodes) {
|
|
535
|
+
if (flow.type !== "flow")
|
|
536
|
+
continue;
|
|
537
|
+
const hasExplicitFlow = Object.prototype.hasOwnProperty.call(flow.extra, "flow");
|
|
538
|
+
const explicitSpec = flowOf(flow);
|
|
539
|
+
if (hasExplicitFlow && !explicitSpec) {
|
|
540
|
+
return fail(`flow ${flow.label} has malformed explicit material endpoints`);
|
|
541
|
+
}
|
|
542
|
+
if (!hasExplicitFlow) {
|
|
543
|
+
for (const link of flow.links) {
|
|
544
|
+
if (byId.get(link.to)?.type !== "stock")
|
|
545
|
+
continue;
|
|
546
|
+
if (link.indirect) {
|
|
547
|
+
return fail(`legacy material candidate ${flow.id} -> ${link.to} is dashed`);
|
|
548
|
+
}
|
|
549
|
+
if (link.polarity !== "+" && link.polarity !== "-") {
|
|
550
|
+
return fail(`legacy material candidate ${flow.id} -> ${link.to} has unknown polarity`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// Use the same canonical endpoint resolution as SFD painting. This keeps
|
|
555
|
+
// older, supported flow->stock material links resolvable without inventing
|
|
556
|
+
// topology, while resolveFlowSpec still rejects ambiguous legacy shapes.
|
|
557
|
+
const spec = explicitSpec ?? resolveFlowSpec(flow, byId);
|
|
558
|
+
if (!spec)
|
|
559
|
+
return fail(`flow ${flow.label} lacks resolvable material endpoints`);
|
|
560
|
+
const validation = validateFlowEndpoints(spec.from, spec.to, byId);
|
|
561
|
+
if (!validation.ok)
|
|
562
|
+
return fail(`invalid material endpoints for ${flow.label}: ${validation.error}`);
|
|
563
|
+
const touches = [];
|
|
564
|
+
if (!isCloud(spec.from))
|
|
565
|
+
touches.push({ stockId: spec.from, polarity: -1 });
|
|
566
|
+
if (!isCloud(spec.to))
|
|
567
|
+
touches.push({ stockId: spec.to, polarity: 1 });
|
|
568
|
+
for (const touch of touches) {
|
|
569
|
+
if (workLimitReached())
|
|
570
|
+
return fail("loop discovery exceeded its work limit");
|
|
571
|
+
const candidates = displayed.get(pairKey(flow.id, touch.stockId)) ?? [];
|
|
572
|
+
let cldEdgeId;
|
|
573
|
+
if (candidates.length === 0) {
|
|
574
|
+
cldEdgeId = uniqueProjectionId(flow.id, touch.stockId, persistedEdgeIds);
|
|
575
|
+
}
|
|
576
|
+
else if (candidates.length === 1) {
|
|
577
|
+
const entry = candidates[0];
|
|
578
|
+
const link = entry.node.links[entry.index];
|
|
579
|
+
const declared = link.polarity === "-" ? -1 : link.polarity === "+" ? 1 : 0;
|
|
580
|
+
if (link.indirect || declared !== touch.polarity) {
|
|
581
|
+
return fail(`material leg ${flow.id} -> ${touch.stockId} has a ` +
|
|
582
|
+
"dashed, conflicting, or unknown displayed connector");
|
|
583
|
+
}
|
|
584
|
+
cldEdgeId = `${flow.id}__${touch.stockId}`;
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
return fail(`material leg ${flow.id} -> ${touch.stockId} has duplicate displayed connectors`);
|
|
588
|
+
}
|
|
589
|
+
const failed = addEdge({
|
|
590
|
+
from: flow.id,
|
|
591
|
+
to: touch.stockId,
|
|
592
|
+
polarity: touch.polarity,
|
|
593
|
+
leg: {
|
|
594
|
+
kind: "material",
|
|
595
|
+
fromNodeId: flow.id,
|
|
596
|
+
toNodeId: touch.stockId,
|
|
597
|
+
flowId: flow.id,
|
|
598
|
+
stockId: touch.stockId,
|
|
599
|
+
cldEdgeId,
|
|
600
|
+
polarity: touch.polarity,
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
if (failed)
|
|
604
|
+
return failed;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const adjacency = new Map();
|
|
608
|
+
for (const id of byId.keys())
|
|
609
|
+
adjacency.set(id, []);
|
|
610
|
+
for (const edge of edges)
|
|
611
|
+
adjacency.get(edge.from)?.push(edge);
|
|
612
|
+
for (const outgoing of adjacency.values()) {
|
|
613
|
+
outgoing.sort((a, b) => a.to.localeCompare(b.to) || a.leg.kind.localeCompare(b.leg.kind));
|
|
614
|
+
}
|
|
615
|
+
const quantByKey = new Map();
|
|
616
|
+
const orderedIds = [...byId.keys()].sort();
|
|
617
|
+
for (const start of orderedIds) {
|
|
618
|
+
const nodeStack = [start];
|
|
619
|
+
const legStack = [];
|
|
620
|
+
const onPath = new Set([start]);
|
|
621
|
+
let discoveryFailure = null;
|
|
622
|
+
const dfs = (current, product) => {
|
|
623
|
+
if (discoveryFailure)
|
|
624
|
+
return;
|
|
625
|
+
for (const edge of adjacency.get(current) ?? []) {
|
|
626
|
+
if (workLimitReached()) {
|
|
627
|
+
discoveryFailure = "loop discovery exceeded its work limit";
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (edge.to === start) {
|
|
631
|
+
if (nodeStack.length < 2)
|
|
632
|
+
continue;
|
|
633
|
+
const canonicalNodes = canonicalDirectedCycle(nodeStack);
|
|
634
|
+
const shift = nodeStack.indexOf(canonicalNodes[0]);
|
|
635
|
+
const cycleLegs = [...legStack, edge.leg];
|
|
636
|
+
const canonicalLegs = [...cycleLegs.slice(shift), ...cycleLegs.slice(0, shift)];
|
|
637
|
+
const type = product * edge.polarity === 1
|
|
638
|
+
? LoopType.reinforcing
|
|
639
|
+
: LoopType.balancing;
|
|
640
|
+
const loop = new DetectedLoop(canonicalNodes, type, new CanvasLoopPath(canonicalLegs), "quantitative");
|
|
641
|
+
if (quantByKey.has(loop.exactKey)) {
|
|
642
|
+
discoveryFailure = `duplicate directed cycle ${loop.exactKey}`;
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (quantByKey.size >= limits.maxLoops) {
|
|
646
|
+
discoveryFailure = "loop discovery exceeded its output limit";
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
quantByKey.set(loop.exactKey, loop);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (edge.to < start || onPath.has(edge.to))
|
|
653
|
+
continue;
|
|
654
|
+
if (nodeStack.length >= limits.maxDepth) {
|
|
655
|
+
discoveryFailure = "loop discovery exceeded its depth limit";
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
nodeStack.push(edge.to);
|
|
659
|
+
legStack.push(edge.leg);
|
|
660
|
+
onPath.add(edge.to);
|
|
661
|
+
dfs(edge.to, product * edge.polarity);
|
|
662
|
+
onPath.delete(edge.to);
|
|
663
|
+
legStack.pop();
|
|
664
|
+
nodeStack.pop();
|
|
665
|
+
if (discoveryFailure)
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
dfs(start, 1);
|
|
670
|
+
if (discoveryFailure)
|
|
671
|
+
return fail(discoveryFailure);
|
|
672
|
+
}
|
|
673
|
+
const qualitative = qualitativeOnly(nodes, qualitativeLoops, null).loops;
|
|
674
|
+
const merged = [...qualitative];
|
|
675
|
+
const ambiguousQualitativeKeys = new Set(qualitative
|
|
676
|
+
.filter((loop) => loop.exactRouteAmbiguous)
|
|
677
|
+
.map((loop) => loop.key));
|
|
678
|
+
const qualitativeIndex = new Map(merged.map((loop, index) => [loop.exactKey, index]));
|
|
679
|
+
for (const quant of [...quantByKey.values()].sort((a, b) => a.nodeIds.length - b.nodeIds.length || a.exactKey.localeCompare(b.exactKey))) {
|
|
680
|
+
const compatibilityKey = new DetectedLoop(quant.nodeIds, quant.type).key;
|
|
681
|
+
if (ambiguousQualitativeKeys.has(compatibilityKey))
|
|
682
|
+
continue;
|
|
683
|
+
const index = qualitativeIndex.get(quant.exactKey);
|
|
684
|
+
if (index === undefined) {
|
|
685
|
+
qualitativeIndex.set(quant.exactKey, merged.length);
|
|
686
|
+
merged.push(quant);
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
const existing = merged[index];
|
|
690
|
+
merged[index] = new DetectedLoop(existing.nodeIds, existing.type, quant.canvasPath, "qualitative");
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return { loops: merged, analysisError: null };
|
|
694
|
+
}
|