@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.
@@ -0,0 +1,47 @@
1
+ import { VariableFile, VaultLink } from "./types";
2
+ export declare const SOURCE_CLOUD = "~source";
3
+ export declare const SINK_CLOUD = "~sink";
4
+ export interface FlowSpec {
5
+ from: string;
6
+ to: string;
7
+ }
8
+ export interface SfdPosition {
9
+ x: number;
10
+ y: number;
11
+ }
12
+ export interface FlowTouch {
13
+ stockId: string;
14
+ sign: 1 | -1;
15
+ }
16
+ export declare function isCloud(end: string | null | undefined): boolean;
17
+ /** Explicit `extra.flow` topology on a flow note, or null when absent/malformed. */
18
+ export declare function flowOf(v: VariableFile): FlowSpec | null;
19
+ /** Optional SFD-view position from `extra.sfd`, distinct from CLD `x`/`y`. */
20
+ export declare function sfdPositionOf(v: VariableFile): SfdPosition | null;
21
+ /** True when the model has any authored SFD coordinate. */
22
+ export declare function hasAuthoredSfd(nodes: VariableFile[]): boolean;
23
+ export declare function extraWithFlow(extra: Record<string, unknown>, flow: FlowSpec): Record<string, unknown>;
24
+ export declare function extraWithoutFlow(extra: Record<string, unknown>): Record<string, unknown>;
25
+ export declare function extraWithSfdPosition(extra: Record<string, unknown>, x: number, y: number): Record<string, unknown>;
26
+ /** The stock deltas this flow implies: +1 inflow, -1 outflow. */
27
+ export declare function flowTouches(flow: VariableFile, byId: Map<string, VariableFile>): FlowTouch[];
28
+ /**
29
+ * Material endpoints, using an authoritative stored block when present and
30
+ * legacy flow->stock inference only when that block is absent.
31
+ * Returns null when the flow cannot be represented as exactly one from/to pair.
32
+ */
33
+ export declare function resolveFlowSpec(flow: VariableFile, byId: Map<string, VariableFile>): FlowSpec | null;
34
+ export declare function validateFlowEndpoints(from: string, to: string, byId: Map<string, VariableFile>): {
35
+ ok: true;
36
+ } | {
37
+ ok: false;
38
+ error: string;
39
+ };
40
+ /**
41
+ * In SFD mode, material flow->stock links are represented by pipes. Remaining
42
+ * links, including auxiliaries feeding a flow's rate, stay information connectors.
43
+ */
44
+ export declare function isMaterialLink(source: VariableFile, link: VaultLink, byId: Map<string, VariableFile>): boolean;
45
+ /** Deterministic SFD fallback layout, ported from loopy core `sfd_layout.dart`. */
46
+ export declare function computeSfdLayout(vars: VariableFile[], byId: Map<string, VariableFile>): Map<string, SfdPosition>;
47
+ export declare function sfdPositionsFor(nodes: VariableFile[]): Map<string, SfdPosition>;
@@ -0,0 +1,320 @@
1
+ export const SOURCE_CLOUD = "~source";
2
+ export const SINK_CLOUD = "~sink";
3
+ export function isCloud(end) {
4
+ return end === SOURCE_CLOUD || end === SINK_CLOUD;
5
+ }
6
+ function objectMap(v) {
7
+ return v !== null && typeof v === "object" && !Array.isArray(v)
8
+ ? v
9
+ : null;
10
+ }
11
+ function cleanEndpoint(v) {
12
+ return String(v ?? "").trim();
13
+ }
14
+ function num(v) {
15
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
16
+ }
17
+ /** Explicit `extra.flow` topology on a flow note, or null when absent/malformed. */
18
+ export function flowOf(v) {
19
+ const f = objectMap(v.extra["flow"]);
20
+ if (!f)
21
+ return null;
22
+ const from = cleanEndpoint(f["from"]);
23
+ const to = cleanEndpoint(f["to"]);
24
+ return from.length > 0 && to.length > 0 ? { from, to } : null;
25
+ }
26
+ /** Optional SFD-view position from `extra.sfd`, distinct from CLD `x`/`y`. */
27
+ export function sfdPositionOf(v) {
28
+ const s = objectMap(v.extra["sfd"]);
29
+ if (!s)
30
+ return null;
31
+ const x = num(s["x"]);
32
+ const y = num(s["y"]);
33
+ return x !== undefined && y !== undefined ? { x, y } : null;
34
+ }
35
+ /** True when the model has any authored SFD coordinate. */
36
+ export function hasAuthoredSfd(nodes) {
37
+ return nodes.some((v) => {
38
+ const s = objectMap(v.extra["sfd"]);
39
+ return !!s && (num(s["x"]) !== undefined || num(s["y"]) !== undefined);
40
+ });
41
+ }
42
+ export function extraWithFlow(extra, flow) {
43
+ return { ...extra, flow: { from: flow.from, to: flow.to } };
44
+ }
45
+ export function extraWithoutFlow(extra) {
46
+ const next = { ...extra };
47
+ delete next["flow"];
48
+ return next;
49
+ }
50
+ export function extraWithSfdPosition(extra, x, y) {
51
+ return { ...extra, sfd: { x, y } };
52
+ }
53
+ /** The stock deltas this flow implies: +1 inflow, -1 outflow. */
54
+ export function flowTouches(flow, byId) {
55
+ const out = [];
56
+ const spec = flowOf(flow);
57
+ if (spec) {
58
+ if (!isCloud(spec.from) && byId.get(spec.from)?.type === "stock") {
59
+ out.push({ stockId: spec.from, sign: -1 });
60
+ }
61
+ if (!isCloud(spec.to) && byId.get(spec.to)?.type === "stock") {
62
+ out.push({ stockId: spec.to, sign: 1 });
63
+ }
64
+ return out;
65
+ }
66
+ // A present explicit block is authoritative. Malformed explicit topology
67
+ // must not silently fall through to a different legacy interpretation.
68
+ if (Object.prototype.hasOwnProperty.call(flow.extra, "flow"))
69
+ return out;
70
+ for (const l of flow.links) {
71
+ const t = byId.get(l.to);
72
+ if (t?.type !== "stock")
73
+ continue;
74
+ if (l.indirect)
75
+ continue;
76
+ if (l.polarity !== "+" && l.polarity !== "-")
77
+ continue;
78
+ out.push({ stockId: l.to, sign: l.polarity === "-" ? -1 : 1 });
79
+ }
80
+ return out;
81
+ }
82
+ /**
83
+ * Material endpoints, using an authoritative stored block when present and
84
+ * legacy flow->stock inference only when that block is absent.
85
+ * Returns null when the flow cannot be represented as exactly one from/to pair.
86
+ */
87
+ export function resolveFlowSpec(flow, byId) {
88
+ const stored = flowOf(flow);
89
+ if (stored) {
90
+ return validateFlowEndpoints(stored.from, stored.to, byId).ok ? stored : null;
91
+ }
92
+ if (Object.prototype.hasOwnProperty.call(flow.extra, "flow"))
93
+ return null;
94
+ for (const link of flow.links) {
95
+ if (byId.get(link.to)?.type !== "stock")
96
+ continue;
97
+ if (link.indirect || (link.polarity !== "+" && link.polarity !== "-"))
98
+ return null;
99
+ }
100
+ const touched = flowTouches(flow, byId);
101
+ const inflows = touched.filter((t) => t.sign > 0).map((t) => t.stockId);
102
+ const outflows = touched.filter((t) => t.sign < 0).map((t) => t.stockId);
103
+ let inferred = null;
104
+ if (inflows.length === 1 && outflows.length === 1) {
105
+ inferred = { from: outflows[0], to: inflows[0] };
106
+ }
107
+ else if (inflows.length === 1 && outflows.length === 0) {
108
+ inferred = { from: SOURCE_CLOUD, to: inflows[0] };
109
+ }
110
+ else if (outflows.length === 1 && inflows.length === 0) {
111
+ inferred = { from: outflows[0], to: SINK_CLOUD };
112
+ }
113
+ return inferred && validateFlowEndpoints(inferred.from, inferred.to, byId).ok
114
+ ? inferred
115
+ : null;
116
+ }
117
+ export function validateFlowEndpoints(from, to, byId) {
118
+ const bad = (end, isFrom) => {
119
+ if (isCloud(end)) {
120
+ if (isFrom && end !== SOURCE_CLOUD)
121
+ return "From cloud must be ~source.";
122
+ if (!isFrom && end !== SINK_CLOUD)
123
+ return "To cloud must be ~sink.";
124
+ return null;
125
+ }
126
+ const v = byId.get(end);
127
+ if (!v)
128
+ return `Endpoint not found: ${end}`;
129
+ if (v.type !== "stock")
130
+ return `Endpoint must be a stock: ${v.label || v.id}`;
131
+ return null;
132
+ };
133
+ const fromErr = bad(from, true);
134
+ if (fromErr)
135
+ return { ok: false, error: fromErr };
136
+ const toErr = bad(to, false);
137
+ if (toErr)
138
+ return { ok: false, error: toErr };
139
+ if (from === to)
140
+ return { ok: false, error: "Flow endpoints must differ." };
141
+ if (isCloud(from) && isCloud(to)) {
142
+ return { ok: false, error: "A flow must touch at least one stock." };
143
+ }
144
+ return { ok: true };
145
+ }
146
+ /**
147
+ * In SFD mode, material flow->stock links are represented by pipes. Remaining
148
+ * links, including auxiliaries feeding a flow's rate, stay information connectors.
149
+ */
150
+ export function isMaterialLink(source, link, byId) {
151
+ if (source.type !== "flow")
152
+ return false;
153
+ const spec = resolveFlowSpec(source, byId);
154
+ if (!spec)
155
+ return false;
156
+ if (Object.prototype.hasOwnProperty.call(source.extra, "flow")) {
157
+ return link.to === spec.from || link.to === spec.to;
158
+ }
159
+ if (link.indirect || (link.polarity !== "+" && link.polarity !== "-"))
160
+ return false;
161
+ return ((link.to === spec.from && link.polarity === "-") ||
162
+ (link.to === spec.to && link.polarity === "+"));
163
+ }
164
+ /** Deterministic SFD fallback layout, ported from loopy core `sfd_layout.dart`. */
165
+ export function computeSfdLayout(vars, byId) {
166
+ const cmp = (a, b) => {
167
+ const byLabel = a.label.localeCompare(b.label);
168
+ return byLabel !== 0 ? byLabel : a.id.localeCompare(b.id);
169
+ };
170
+ const stocks = vars.filter((v) => v.type === "stock").sort(cmp);
171
+ const flows = vars.filter((v) => v.type === "flow").sort(cmp);
172
+ const auxes = vars.filter((v) => v.type === "auxiliary").sort(cmp);
173
+ const stockIds = stocks.map((s) => s.id);
174
+ const stockSet = new Set(stockIds);
175
+ const cmpId = (x, y) => {
176
+ const a = byId.get(x);
177
+ const b = byId.get(y);
178
+ return a && b ? cmp(a, b) : x.localeCompare(y);
179
+ };
180
+ const out = new Map(stockIds.map((id) => [id, new Set()]));
181
+ const inn = new Map(stockIds.map((id) => [id, new Set()]));
182
+ for (const f of flows) {
183
+ const spec = resolveFlowSpec(f, byId);
184
+ if (!spec)
185
+ continue;
186
+ if (!stockSet.has(spec.from) || !stockSet.has(spec.to))
187
+ continue;
188
+ if (spec.from === spec.to)
189
+ continue;
190
+ out.get(spec.from)?.add(spec.to);
191
+ inn.get(spec.to)?.add(spec.from);
192
+ }
193
+ const sortedOut = new Map();
194
+ for (const id of stockIds)
195
+ sortedOut.set(id, [...(out.get(id) ?? [])].sort(cmpId));
196
+ const white = 0;
197
+ const grey = 1;
198
+ const black = 2;
199
+ const color = new Map(stockIds.map((id) => [id, white]));
200
+ const backEdges = new Set();
201
+ const edgeKey = (from, to) => `${from}\u001f${to}`;
202
+ const visit = (u) => {
203
+ color.set(u, grey);
204
+ for (const v of sortedOut.get(u) ?? []) {
205
+ const c = color.get(v) ?? white;
206
+ if (c === grey)
207
+ backEdges.add(edgeKey(u, v));
208
+ else if (c === white)
209
+ visit(v);
210
+ }
211
+ color.set(u, black);
212
+ };
213
+ for (const id of stockIds)
214
+ if ((color.get(id) ?? white) === white)
215
+ visit(id);
216
+ const dagPreds = new Map();
217
+ for (const id of stockIds) {
218
+ dagPreds.set(id, [...(inn.get(id) ?? [])].filter((p) => !backEdges.has(edgeKey(p, id))).sort(cmpId));
219
+ }
220
+ const col = new Map();
221
+ const onStack = new Set();
222
+ const columnOf = (u) => {
223
+ const cached = col.get(u);
224
+ if (cached !== undefined)
225
+ return cached;
226
+ if (onStack.has(u))
227
+ return 0;
228
+ onStack.add(u);
229
+ let c = 0;
230
+ for (const p of dagPreds.get(u) ?? [])
231
+ c = Math.max(c, columnOf(p) + 1);
232
+ onStack.delete(u);
233
+ col.set(u, c);
234
+ return c;
235
+ };
236
+ for (const id of stockIds)
237
+ columnOf(id);
238
+ const maxCol = Math.max(0, ...col.values());
239
+ const byColumn = new Map();
240
+ for (let c = 0; c <= maxCol; c++)
241
+ byColumn.set(c, []);
242
+ for (const id of stockIds)
243
+ byColumn.get(col.get(id) ?? 0)?.push(id);
244
+ const row = new Map();
245
+ for (let c = 0; c <= maxCol; c++) {
246
+ const ids = (byColumn.get(c) ?? []).sort(cmpId);
247
+ const used = new Set();
248
+ for (const id of ids) {
249
+ const preds = dagPreds.get(id) ?? [];
250
+ let r;
251
+ if (preds.length === 1) {
252
+ const pr = row.get(preds[0]);
253
+ if (pr !== undefined && !used.has(pr))
254
+ r = pr;
255
+ }
256
+ if (r === undefined) {
257
+ r = 0;
258
+ while (used.has(r))
259
+ r++;
260
+ }
261
+ used.add(r);
262
+ row.set(id, r);
263
+ }
264
+ }
265
+ const result = new Map();
266
+ for (const id of stockIds) {
267
+ result.set(id, { x: (col.get(id) ?? 0) * 220, y: (row.get(id) ?? 0) * 130 });
268
+ }
269
+ for (const f of flows) {
270
+ const spec = resolveFlowSpec(f, byId);
271
+ let valve;
272
+ if (spec) {
273
+ const fromPos = isCloud(spec.from) ? undefined : result.get(spec.from);
274
+ const toPos = isCloud(spec.to) ? undefined : result.get(spec.to);
275
+ if (fromPos && toPos)
276
+ valve = { x: (fromPos.x + toPos.x) / 2, y: (fromPos.y + toPos.y) / 2 };
277
+ else if (toPos)
278
+ valve = { x: toPos.x - 80, y: toPos.y };
279
+ else if (fromPos)
280
+ valve = { x: fromPos.x + 80, y: fromPos.y };
281
+ }
282
+ if (!valve) {
283
+ const touched = flowTouches(f, byId).map((t) => result.get(t.stockId)).filter((p) => !!p);
284
+ valve = touched.length === 0
285
+ ? { x: 0, y: 0 }
286
+ : {
287
+ x: touched.reduce((sum, p) => sum + p.x, 0) / touched.length,
288
+ y: touched.reduce((sum, p) => sum + p.y, 0) / touched.length,
289
+ };
290
+ }
291
+ result.set(f.id, valve);
292
+ }
293
+ const auxIds = new Set(auxes.map((v) => v.id));
294
+ const incoming = new Map(auxes.map((v) => [v.id, new Set()]));
295
+ for (const v of vars) {
296
+ if (auxIds.has(v.id))
297
+ continue;
298
+ for (const l of v.links)
299
+ incoming.get(l.to)?.add(v.id);
300
+ }
301
+ const minStockY = stockIds.length === 0
302
+ ? 0
303
+ : Math.min(...stockIds.map((id) => result.get(id)?.y ?? 0));
304
+ const auxY = minStockY - 110;
305
+ for (const v of auxes) {
306
+ const anchors = new Set();
307
+ for (const l of v.links) {
308
+ if (result.has(l.to) && !auxIds.has(l.to))
309
+ anchors.add(l.to);
310
+ }
311
+ for (const id of incoming.get(v.id) ?? [])
312
+ anchors.add(id);
313
+ const xs = [...anchors].sort(cmpId).map((id) => result.get(id)?.x).filter((x) => x !== undefined);
314
+ result.set(v.id, { x: xs.length === 0 ? 0 : xs.reduce((a, b) => a + b, 0) / xs.length, y: auxY });
315
+ }
316
+ return result;
317
+ }
318
+ export function sfdPositionsFor(nodes) {
319
+ return new Map(nodes.map((n) => [n.id, sfdPositionOf(n) ?? { x: n.x, y: n.y }]));
320
+ }
@@ -14,6 +14,7 @@
14
14
  * the qualitative plugin never computes quant staleness.
15
15
  */
16
16
  import { varTypeName } from "./types";
17
+ import { flowOf } from "./sfd";
17
18
  /** Provenance value stored in a note's `source` key for plugin writes. */
18
19
  export const WRITE_SOURCE_PLUGIN = "plugin";
19
20
  const encoder = new TextEncoder();
@@ -35,6 +36,7 @@ export function fnv1a32(s) {
35
36
  export function canonicalContent(v) {
36
37
  const tags = [...v.tags].sort();
37
38
  const links = [...v.links].sort((a, b) => (a.to < b.to ? -1 : a.to > b.to ? 1 : 0));
39
+ const flow = flowOf(v);
38
40
  const linkPart = (l) => {
39
41
  const base = `${l.to}|${l.polarity}|${l.delay ? 1 : 0}|${l.indirect ? 1 : 0}|${l.nonlinear ? 1 : 0}`;
40
42
  const conf = l.confidence !== undefined ? `|c${l.confidence.toFixed(3)}` : "";
@@ -52,6 +54,8 @@ export function canonicalContent(v) {
52
54
  ...links.map(linkPart),
53
55
  v.body.trim(),
54
56
  ];
57
+ if (flow)
58
+ parts.push(`flow:${flow.from}->${flow.to}`);
55
59
  if ((v.shared ?? "").length > 0)
56
60
  parts.push(v.shared);
57
61
  return parts.join("\n");
@@ -26,7 +26,8 @@ export declare enum LoopType {
26
26
  */
27
27
  export interface VaultLink {
28
28
  to: string;
29
- polarity: "+" | "-";
29
+ /** `?` is preserved unknown-sign input and never participates in a loop. */
30
+ polarity: "+" | "-" | "?";
30
31
  delay: boolean;
31
32
  indirect: boolean;
32
33
  nonlinear: boolean;
@@ -93,11 +94,61 @@ export interface ModelManifest {
93
94
  export declare function toUtcIso(v: unknown): string | undefined;
94
95
  export declare function manifestFromJson(j: Record<string, unknown>): ModelManifest;
95
96
  export declare function manifestToJson(m: ModelManifest): Record<string, unknown>;
97
+ export type CanvasLoopLegKind = "causal" | "material";
98
+ /** One exact declared information connector in a resolved executable cycle. */
99
+ export interface CausalCanvasLoopLeg {
100
+ readonly kind: "causal";
101
+ readonly fromNodeId: string;
102
+ readonly toNodeId: string;
103
+ readonly edgeId: string;
104
+ readonly polarity: 1 | -1;
105
+ }
106
+ /**
107
+ * One exact first-class flow/stock pipe leg in a resolved executable cycle.
108
+ * `cldEdgeId` is either the matching declared connector or the deterministic,
109
+ * non-persistent CLD projection that represents this material effect.
110
+ */
111
+ export interface MaterialCanvasLoopLeg {
112
+ readonly kind: "material";
113
+ readonly fromNodeId: string;
114
+ readonly toNodeId: string;
115
+ readonly flowId: string;
116
+ readonly stockId: string;
117
+ readonly cldEdgeId: string;
118
+ readonly polarity: 1 | -1;
119
+ }
120
+ export type CanvasLoopLeg = CausalCanvasLoopLeg | MaterialCanvasLoopLeg;
121
+ /**
122
+ * A complete executable cycle resolved to exact visible canvas elements.
123
+ * Partial paths are never represented.
124
+ */
125
+ export declare class CanvasLoopPath {
126
+ readonly legs: readonly CanvasLoopLeg[];
127
+ constructor(legs: readonly CanvasLoopLeg[]);
128
+ get hasMaterialLeg(): boolean;
129
+ }
130
+ /** Rotate a directed cycle to its lexicographically smallest rotation. */
131
+ export declare function canonicalDirectedCycle(nodeIds: Iterable<string>): string[];
132
+ /** Rotation-invariant and routing-sensitive identity for a directed cycle. */
133
+ export declare function directedCycleKey(nodeIds: Iterable<string>): string;
134
+ export type LoopIdentityMode = "qualitative" | "quantitative";
96
135
  /** A detected feedback loop: variable ids in cycle order + R/B classification. */
97
136
  export declare class DetectedLoop {
98
137
  readonly nodeIds: string[];
99
138
  readonly type: LoopType;
100
- constructor(nodeIds: string[], type: LoopType);
101
- /** Stable identity = type + the sorted unique node ids (one badge per loop). */
139
+ readonly canvasPath?: CanvasLoopPath | undefined;
140
+ readonly identityMode: LoopIdentityMode;
141
+ /** Multiple directed routes collapsed to this legacy qualitative key. */
142
+ readonly exactRouteAmbiguous: boolean;
143
+ constructor(nodeIds: string[], type: LoopType, canvasPath?: CanvasLoopPath | undefined, identityMode?: LoopIdentityMode,
144
+ /** Multiple directed routes collapsed to this legacy qualitative key. */
145
+ exactRouteAmbiguous?: boolean);
146
+ /** Rotation-invariant, routing-sensitive identity used for exact dedup. */
147
+ get exactKey(): string;
148
+ /**
149
+ * Qualitative loops retain the package's established numeric-type/sorted-id
150
+ * key. Quantitative-only badges use the exact directed key so distinct
151
+ * executable routings through the same nodes cannot collide.
152
+ */
102
153
  get key(): string;
103
154
  }
@@ -38,9 +38,14 @@ export function normalizeBasis(v) {
38
38
  }
39
39
  export function linkFromMap(m) {
40
40
  const pol = m["polarity"];
41
+ const hasPolarity = Object.prototype.hasOwnProperty.call(m, "polarity");
41
42
  return {
42
43
  to: String(m["to"]),
43
- polarity: pol === "-" || pol === -1 ? "-" : "+",
44
+ polarity: pol === "-" || pol === -1
45
+ ? "-"
46
+ : pol === "+" || pol === 1 || !hasPolarity
47
+ ? "+"
48
+ : "?",
44
49
  delay: m["delay"] === true,
45
50
  indirect: m["indirect"] === true,
46
51
  nonlinear: m["nonlinear"] === true,
@@ -141,15 +146,71 @@ export function manifestToJson(m) {
141
146
  out["order"] = m.order;
142
147
  return { ...out, ...m.extra };
143
148
  }
149
+ /**
150
+ * A complete executable cycle resolved to exact visible canvas elements.
151
+ * Partial paths are never represented.
152
+ */
153
+ export class CanvasLoopPath {
154
+ constructor(legs) {
155
+ this.legs = Object.freeze([...legs]);
156
+ }
157
+ get hasMaterialLeg() {
158
+ return this.legs.some((leg) => leg.kind === "material");
159
+ }
160
+ }
161
+ /** Rotate a directed cycle to its lexicographically smallest rotation. */
162
+ export function canonicalDirectedCycle(nodeIds) {
163
+ const nodes = [...nodeIds];
164
+ if (nodes.length > 1 && nodes[0] === nodes[nodes.length - 1])
165
+ nodes.pop();
166
+ if (nodes.length === 0)
167
+ return [];
168
+ const compareRotation = (a, b) => {
169
+ for (let offset = 0; offset < nodes.length; offset++) {
170
+ const left = nodes[(a + offset) % nodes.length];
171
+ const right = nodes[(b + offset) % nodes.length];
172
+ if (left < right)
173
+ return -1;
174
+ if (left > right)
175
+ return 1;
176
+ }
177
+ return 0;
178
+ };
179
+ let best = 0;
180
+ for (let candidate = 1; candidate < nodes.length; candidate++) {
181
+ if (compareRotation(candidate, best) < 0)
182
+ best = candidate;
183
+ }
184
+ return [...nodes.slice(best), ...nodes.slice(0, best)];
185
+ }
186
+ /** Rotation-invariant and routing-sensitive identity for a directed cycle. */
187
+ export function directedCycleKey(nodeIds) {
188
+ return canonicalDirectedCycle(nodeIds).map(encodeURIComponent).join(">");
189
+ }
144
190
  /** A detected feedback loop: variable ids in cycle order + R/B classification. */
145
191
  export class DetectedLoop {
146
- constructor(nodeIds, type) {
192
+ constructor(nodeIds, type, canvasPath, identityMode = "qualitative",
193
+ /** Multiple directed routes collapsed to this legacy qualitative key. */
194
+ exactRouteAmbiguous = false) {
147
195
  this.nodeIds = nodeIds;
148
196
  this.type = type;
197
+ this.canvasPath = canvasPath;
198
+ this.identityMode = identityMode;
199
+ this.exactRouteAmbiguous = exactRouteAmbiguous;
200
+ }
201
+ /** Rotation-invariant, routing-sensitive identity used for exact dedup. */
202
+ get exactKey() {
203
+ const prefix = this.type === LoopType.reinforcing ? "R" : "B";
204
+ return `${prefix}:${directedCycleKey(this.nodeIds)}`;
149
205
  }
150
- /** Stable identity = type + the sorted unique node ids (one badge per loop). */
206
+ /**
207
+ * Qualitative loops retain the package's established numeric-type/sorted-id
208
+ * key. Quantitative-only badges use the exact directed key so distinct
209
+ * executable routings through the same nodes cannot collide.
210
+ */
151
211
  get key() {
152
- const names = [...this.nodeIds].sort();
153
- return `${this.type}:${names.join("|")}`;
212
+ if (this.identityMode === "quantitative")
213
+ return this.exactKey;
214
+ return `${this.type}:${[...this.nodeIds].sort().join("|")}`;
154
215
  }
155
216
  }
package/dist/index.d.ts CHANGED
@@ -13,7 +13,10 @@ export * from "./engine/engine";
13
13
  export * from "./engine/exporters";
14
14
  export * from "./engine/analysis";
15
15
  export * from "./engine/subsystemLinks";
16
+ export * from "./engine/publicInterface";
16
17
  export * from "./engine/loopKey";
17
18
  export * from "./engine/loopNote";
18
19
  export * from "./engine/noteNaming";
19
20
  export * from "./engine/specHash";
21
+ export * from "./engine/sfd";
22
+ export * from "./engine/quantCanvasLoops";
package/dist/index.js CHANGED
@@ -13,7 +13,10 @@ export * from "./engine/engine";
13
13
  export * from "./engine/exporters";
14
14
  export * from "./engine/analysis";
15
15
  export * from "./engine/subsystemLinks";
16
+ export * from "./engine/publicInterface";
16
17
  export * from "./engine/loopKey";
17
18
  export * from "./engine/loopNote";
18
19
  export * from "./engine/noteNaming";
19
20
  export * from "./engine/specHash";
21
+ export * from "./engine/sfd";
22
+ export * from "./engine/quantCanvasLoops";
@@ -8,6 +8,11 @@
8
8
  */
9
9
  import { DetectedLoop, VariableFile, VaultLink } from "../engine/types";
10
10
  import { Bounds, Point } from "./camera";
11
+ export type DiagramViewMode = "cld" | "sfd";
12
+ /** Loops whose complete representation exists in the requested canvas view. */
13
+ export declare function loopsForMode(loops: DetectedLoop[], mode: DiagramViewMode): DetectedLoop[];
14
+ /** Keep a selected badge only when that exact loop exists in the next view. */
15
+ export declare function retainedLoopKeyForMode(loops: DetectedLoop[], selectedKey: string | null, mode: DiagramViewMode): string | null;
11
16
  export interface NodeBox {
12
17
  id: string;
13
18
  cx: number;
@@ -21,6 +26,9 @@ export interface EdgeRef {
21
26
  source: string;
22
27
  target: string;
23
28
  link: VaultLink;
29
+ /** View-only topology (for example a CLD material projection).
30
+ * Rendered normally, but never participates in edge hit/edit routing. */
31
+ renderOnly?: boolean;
24
32
  }
25
33
  export interface EdgeGeom extends EdgeRef {
26
34
  points: Point[];
@@ -33,6 +41,18 @@ export interface EdgeGeom extends EdgeRef {
33
41
  arrowTip: Point;
34
42
  arrowAngle: number;
35
43
  }
44
+ export interface SfdPipeGeom {
45
+ id: string;
46
+ flowId: string;
47
+ from: string;
48
+ to: string;
49
+ fromPoint: Point;
50
+ valvePoint: Point;
51
+ toPoint: Point;
52
+ fromCloud: Point | null;
53
+ toCloud: Point | null;
54
+ axisAngle: number;
55
+ }
36
56
  /**
37
57
  * Node (x,y) is the box center, matching the Dart painter + autoLayout.
38
58
  *
@@ -43,8 +63,19 @@ export interface EdgeGeom extends EdgeRef {
43
63
  * character-count estimate `layout.boxSize`/`autoLayout` use, so positions stay
44
64
  * deterministic.
45
65
  */
46
- export declare function buildNodeBoxes(nodes: VariableFile[], measure?: (label: string) => number): Map<string, NodeBox>;
66
+ export declare function buildNodeBoxes(nodes: VariableFile[], measure?: (label: string) => number, positions?: Map<string, Point>): Map<string, NodeBox>;
47
67
  export declare function collectEdges(nodes: VariableFile[]): EdgeRef[];
68
+ export declare function collectInfoEdges(nodes: VariableFile[], mode: DiagramViewMode): EdgeRef[];
69
+ /**
70
+ * Minimum non-persistent causal projections needed to close fully resolved
71
+ * quantitative loops in CLD notation. A matching declared connector is already
72
+ * present in `collectEdges`; only legs whose resolver assigned a synthetic
73
+ * `cldEdgeId` are emitted here. These refs are `renderOnly`, so they cannot be
74
+ * selected, bowed, deleted, or serialized as authored causal links.
75
+ */
76
+ export declare function collectCldMaterialProjectionEdges(nodes: VariableFile[], loops: DetectedLoop[]): EdgeRef[];
77
+ export declare function sfdRenderPositions(nodes: VariableFile[]): Map<string, Point>;
78
+ export declare function buildSfdPipeGeoms(nodes: VariableFile[], boxes: Map<string, NodeBox>): SfdPipeGeom[];
48
79
  /**
49
80
  * @param bowCache Per-edge frozen bow signs (keyed by edge id). Lone edges pick
50
81
  * a bow side from the graph centroid **once** and reuse it; without this, a
@@ -66,6 +97,8 @@ export declare function computeBadges(loops: DetectedLoop[], boxes: Map<string,
66
97
  * to the first. Used to highlight the loop when its badge is selected.
67
98
  */
68
99
  export declare function loopEdgeIds(loop: DetectedLoop): Set<string>;
100
+ /** Exact material pipe legs (`flowId` + `stockId`) belonging to a resolved loop. */
101
+ export declare function loopPipeLegIds(loop: DetectedLoop): Set<string>;
69
102
  export declare function hitNode(boxes: Map<string, NodeBox>, p: Point): string | null;
70
103
  export declare function hitEdge(geoms: EdgeGeom[], p: Point, scale: number): string | null;
71
104
  /**
@@ -81,3 +114,4 @@ export declare function hitBadge(badges: Map<string, Point>, p: Point, scale: nu
81
114
  export declare function inConnectBand(box: NodeBox, p: Point, tol: number, gap?: number): boolean;
82
115
  export declare function distToSegment(p: Point, a: Point, b: Point): number;
83
116
  export declare function nodeBounds(boxes: Map<string, NodeBox>): Bounds;
117
+ export declare function sceneBounds(boxes: Map<string, NodeBox>, pipes?: SfdPipeGeom[]): Bounds;