@neoloopy/cld-canvas 0.1.4 → 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 +22 -3
- package/dist/engine/engine.d.ts +16 -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 +8 -2
- package/dist/engine/nativeEngine.js +193 -56
- package/dist/engine/noteCodec.js +46 -2
- 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 +2 -0
- package/dist/index.js +2 -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 +2 -2
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -472,21 +472,40 @@ import { buildMermaid, render } from "@neoloopy/cld-canvas";
|
|
|
472
472
|
<sub>The same loop detection these helpers expose, surfaced in a neoloopy surface — a project-dynamics model with its reinforcing and balancing loops and the insight panel.</sub>
|
|
473
473
|
|
|
474
474
|
```ts
|
|
475
|
-
import { LoopGraph, endogeneity } from "@neoloopy/cld-canvas";
|
|
475
|
+
import { LoopGraph, discoverCanvasLoops, endogeneity } from "@neoloopy/cld-canvas";
|
|
476
476
|
|
|
477
477
|
const loopGraph = new LoopGraph(graph.nodes);
|
|
478
|
-
const
|
|
478
|
+
const qualitative = loopGraph.detectLoops();
|
|
479
|
+
const { loops, analysisError } = discoverCanvasLoops(graph.nodes, qualitative, {
|
|
480
|
+
manifest: graph.manifest,
|
|
481
|
+
});
|
|
479
482
|
const metrics = loopGraph.metrics();
|
|
480
483
|
const summary = endogeneity(graph.nodes, loops);
|
|
481
484
|
```
|
|
482
485
|
|
|
486
|
+
`discoverCanvasLoops` first enriches each declared qualitative loop whose whole
|
|
487
|
+
authored route resolves to exact visible connectors or material pipe legs, even
|
|
488
|
+
when quantitative analysis is unavailable or incomplete. It adds a synthesized
|
|
489
|
+
quantitative loop only when its complete executable route resolves just as
|
|
490
|
+
exactly.
|
|
491
|
+
Material endpoints may be explicit or use the unique, direct, signed legacy
|
|
492
|
+
flow-to-stock encoding already supported by SFD rendering. It preserves
|
|
493
|
+
qualitative loop identity and deduplicates exact counterparts. Quantitative
|
|
494
|
+
discovery failures surface as `analysisError` and add no partial quantitative
|
|
495
|
+
badge. A declared qualitative loop whose exact canvas route cannot be resolved
|
|
496
|
+
keeps its established CLD identity and badge but is excluded from SFD; that
|
|
497
|
+
qualitative exclusion by itself does not set `analysisError`. SFD retains every
|
|
498
|
+
exactly resolved qualitative loop; a quantitative-only SFD badge additionally
|
|
499
|
+
requires at least one first-class material leg.
|
|
500
|
+
|
|
483
501
|
## What is exported
|
|
484
502
|
|
|
485
503
|
The package exports the public modules from `src/index.ts`, including:
|
|
486
504
|
|
|
487
505
|
- Engine: `NativeEngine`, `NeoloopyEngine`, `MemoryStorage`, `VaultStorage`
|
|
488
506
|
- Domain types: `VariableFile`, `VaultLink`, `ModelManifest`, `GraphView`
|
|
489
|
-
- Graph logic: `LoopGraph`, `DetectedLoop`, `LoopType`, `labelLoopsByKey
|
|
507
|
+
- Graph logic: `LoopGraph`, `DetectedLoop`, `LoopType`, `labelLoopsByKey`,
|
|
508
|
+
`discoverCanvasLoops`
|
|
490
509
|
- Rendering: `paint`, `SceneCache`, `Camera`, `LIGHT`, `DARK`, geometry helpers
|
|
491
510
|
- File codecs: `parseNote`, `serializeNote`, `manifestFromJson`
|
|
492
511
|
- Exporters: `render`, `buildMermaid`, `loopNoteKey`
|
package/dist/engine/engine.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface GraphView {
|
|
|
34
34
|
/** loop.key -> "R1" / "B2" badge label. */
|
|
35
35
|
labels: Map<string, string>;
|
|
36
36
|
quant: boolean;
|
|
37
|
+
/** Non-null when quantitative canvas-loop discovery could not complete. */
|
|
38
|
+
analysisError?: string | null;
|
|
37
39
|
}
|
|
38
40
|
export interface NewVariable {
|
|
39
41
|
label: string;
|
|
@@ -158,7 +160,17 @@ export interface NeoloopyEngine {
|
|
|
158
160
|
setEquation(folder: string, id: string, patch: QuantPatch): Promise<VariableFile>;
|
|
159
161
|
/** Cosmetic position change — never bumps rev/modified. */
|
|
160
162
|
moveVariable(folder: string, id: string, x: number, y: number): Promise<void>;
|
|
163
|
+
/** Cosmetic SFD-view position change (`extra.sfd`) — never bumps rev/modified. */
|
|
164
|
+
moveVariableSfd(folder: string, id: string, x: number, y: number): Promise<void>;
|
|
165
|
+
/**
|
|
166
|
+
* If a model has no authored SFD coordinates, persist the deterministic SFD
|
|
167
|
+
* fallback positions into `extra.sfd` so the first manual SFD edit does not
|
|
168
|
+
* make the diagram jump back to CLD coordinates.
|
|
169
|
+
*/
|
|
170
|
+
pinSfdLayout(folder: string): Promise<void>;
|
|
161
171
|
removeVariable(folder: string, id: string): Promise<void>;
|
|
172
|
+
/** Validated SFD material topology write for a flow note (`extra.flow`). */
|
|
173
|
+
setFlowEndpoints(folder: string, flowId: string, from: string, to: string): Promise<void>;
|
|
162
174
|
/**
|
|
163
175
|
* Link (or clear, with `child=null`) a node's subsystem anchor — the same
|
|
164
176
|
* `[[../<childDir>/System|<Child Name>]]` wikilink the app/CLI write, so the
|
|
@@ -177,10 +189,10 @@ export interface NeoloopyEngine {
|
|
|
177
189
|
export(folder: string, format: ExportFormat): Promise<Rendered>;
|
|
178
190
|
/**
|
|
179
191
|
* Loop notes — one `Loops/<slug>.md` file per feedback loop, identity-anchored
|
|
180
|
-
* in frontmatter (the same files the Dart app/CLI write).
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
192
|
+
* in frontmatter (the same files the Dart app/CLI write). Qualitative entries
|
|
193
|
+
* retain the established `<R|B>:<sorted unique variable names>` map key;
|
|
194
|
+
* quantitative-only entries use their exact directed `loop.key` so distinct
|
|
195
|
+
* routes cannot collide. `setLoopNote` accepts that one resolved key.
|
|
184
196
|
*/
|
|
185
197
|
getLoopNotes(folder: string): Promise<Record<string, string>>;
|
|
186
198
|
setLoopNote(folder: string, key: string, text: string): Promise<void>;
|
package/dist/engine/exporters.js
CHANGED
|
@@ -26,7 +26,7 @@ export function buildMermaid(nodes, edges) {
|
|
|
26
26
|
const t = key.get(String(e.target));
|
|
27
27
|
if (!s || !t)
|
|
28
28
|
continue;
|
|
29
|
-
const sym = (e.polarity ?? 1) >= 0 ? "+" : "−";
|
|
29
|
+
const sym = e.polarity === "?" ? "?" : (e.polarity ?? 1) >= 0 ? "+" : "−";
|
|
30
30
|
const arrow = e.delay === true ? "-.->" : "-->";
|
|
31
31
|
lines.push(` ${s} ${arrow}|${sym}| ${t}`);
|
|
32
32
|
}
|
|
@@ -23,9 +23,9 @@ export interface VarMetrics {
|
|
|
23
23
|
loopCount: number;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* Deterministic loop labels: reinforcing -> R1,R2,… and balancing -> B1,B2
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* Deterministic loop labels: reinforcing -> R1,R2,… and balancing -> B1,B2,….
|
|
27
|
+
* Existing qualitative loops keep their historical ordering and numbering;
|
|
28
|
+
* quantitative-only loops follow them in the same deterministic name order.
|
|
29
29
|
*/
|
|
30
30
|
export declare function labelLoopsByKey(loops: DetectedLoop[], name: (id: string) => string): Map<string, string>;
|
|
31
31
|
export declare class LoopGraph {
|
|
@@ -37,7 +37,11 @@ export declare class LoopGraph {
|
|
|
37
37
|
adjacency(reverse?: boolean): Map<string, TracedEdge[]>;
|
|
38
38
|
/** Direct (loop-relevant) adjacency: source -> [target, polaritySign]. */
|
|
39
39
|
private directAdjacency;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* All simple directed cycles, R/B classified, deduped by the legacy node
|
|
42
|
+
* set. When distinct directed routes collapse to that compatibility key, the
|
|
43
|
+
* retained CLD loop is marked ambiguous so SFD resolution can fail closed.
|
|
44
|
+
*/
|
|
41
45
|
detectLoops(): DetectedLoop[];
|
|
42
46
|
/**
|
|
43
47
|
* The Shortest Independent Loop Set (SILS): the minimum-weight basis of the
|
package/dist/engine/loopGraph.js
CHANGED
|
Binary file
|
package/dist/engine/loopKey.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DetectedLoop } from "./types";
|
|
1
2
|
/**
|
|
2
3
|
* The resolved-map key for a feedback loop: `<R|B>:<sorted unique member
|
|
3
4
|
* labels>`. This is the key every UI consumer indexes loop notes by; it is NOT
|
|
@@ -9,6 +10,14 @@
|
|
|
9
10
|
* its own copy.
|
|
10
11
|
*/
|
|
11
12
|
export declare function loopKey(labels: string[], type: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* The one UI/engine lookup key for a detected loop note. Existing qualitative
|
|
15
|
+
* loops, including a qualitative counterpart enriched with a quantitative
|
|
16
|
+
* canvas path, retain the sorted-label key shared with earlier releases.
|
|
17
|
+
* Quantitative-only loops use exact directed id identity so different routes
|
|
18
|
+
* through the same displayed members remain independent.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolvedLoopNoteKey(loop: DetectedLoop, nameOf: (id: string) => string): string;
|
|
12
21
|
/**
|
|
13
22
|
* Human-readable, non-link echo for a loop note's `loop:` frontmatter field:
|
|
14
23
|
* `<R|B> · <sorted unique labels joined by " | ">`. Same membership rule as
|
package/dist/engine/loopKey.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LoopType } from "./types";
|
|
1
2
|
/**
|
|
2
3
|
* The resolved-map key for a feedback loop: `<R|B>:<sorted unique member
|
|
3
4
|
* labels>`. This is the key every UI consumer indexes loop notes by; it is NOT
|
|
@@ -13,6 +14,19 @@ export function loopKey(labels, type) {
|
|
|
13
14
|
const uniq = [...new Set(labels.map((l) => String(l)))].sort();
|
|
14
15
|
return `${letter}:${uniq.join("|")}`;
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The one UI/engine lookup key for a detected loop note. Existing qualitative
|
|
19
|
+
* loops, including a qualitative counterpart enriched with a quantitative
|
|
20
|
+
* canvas path, retain the sorted-label key shared with earlier releases.
|
|
21
|
+
* Quantitative-only loops use exact directed id identity so different routes
|
|
22
|
+
* through the same displayed members remain independent.
|
|
23
|
+
*/
|
|
24
|
+
export function resolvedLoopNoteKey(loop, nameOf) {
|
|
25
|
+
if (loop.identityMode === "quantitative")
|
|
26
|
+
return loop.exactKey;
|
|
27
|
+
const type = loop.type === LoopType.reinforcing ? "R" : "B";
|
|
28
|
+
return loopKey(loop.nodeIds.map(nameOf), type);
|
|
29
|
+
}
|
|
16
30
|
/**
|
|
17
31
|
* Human-readable, non-link echo for a loop note's `loop:` frontmatter field:
|
|
18
32
|
* `<R|B> · <sorted unique labels joined by " | ">`. Same membership rule as
|
|
@@ -85,13 +85,17 @@ export declare class NativeEngine implements NeoloopyEngine {
|
|
|
85
85
|
name: string;
|
|
86
86
|
} | null): Promise<void>;
|
|
87
87
|
moveVariable(folder: string, id: string, x: number, y: number): Promise<void>;
|
|
88
|
+
moveVariableSfd(folder: string, id: string, x: number, y: number): Promise<void>;
|
|
89
|
+
pinSfdLayout(folder: string): Promise<void>;
|
|
88
90
|
removeVariable(folder: string, id: string): Promise<void>;
|
|
91
|
+
setFlowEndpoints(folder: string, flowId: string, from: string, to: string): Promise<void>;
|
|
89
92
|
addLink(folder: string, from: string, to: string, init?: LinkInit): Promise<void>;
|
|
90
93
|
updateLink(folder: string, from: string, to: string, patch: LinkPatch): Promise<void>;
|
|
91
94
|
removeLink(folder: string, from: string, to: string): Promise<void>;
|
|
92
95
|
buildModel(folder: string, spec: BuildSpec): Promise<void>;
|
|
93
96
|
relayout(folder: string): Promise<void>;
|
|
94
97
|
setViewport(folder: string, viewport: Viewport): Promise<void>;
|
|
98
|
+
private liveLoopContext;
|
|
95
99
|
getLoopNotes(folder: string): Promise<Record<string, string>>;
|
|
96
100
|
setLoopNote(folder: string, key: string, text: string): Promise<void>;
|
|
97
101
|
/**
|
|
@@ -112,6 +116,8 @@ export declare class NativeEngine implements NeoloopyEngine {
|
|
|
112
116
|
/** Pre-`Nodes/` location of a variable note, flat at the model root. */
|
|
113
117
|
private legacyNotePath;
|
|
114
118
|
private readNote;
|
|
119
|
+
private removeNoteById;
|
|
120
|
+
private detachStockFlowEndpoints;
|
|
115
121
|
private writeNote;
|
|
116
122
|
private readManifest;
|
|
117
123
|
private writeManifest;
|
|
@@ -136,13 +142,13 @@ export declare class NativeEngine implements NeoloopyEngine {
|
|
|
136
142
|
* One-way, idempotent migration of legacy loop annotations from the
|
|
137
143
|
* `model.json` maps into `Loops/*.md` files. Guard: if any `Loops/` file
|
|
138
144
|
* exists the model is treated as migrated and this returns immediately. Each
|
|
139
|
-
* legacy key is matched to a live loop by its
|
|
145
|
+
* legacy key is matched to a live loop by its resolved note key; a match keeps
|
|
140
146
|
* the loop's canonical member ids, an unmatched key becomes a pre-flagged
|
|
141
147
|
* orphan (`members: []`, original key kept in `loop:`) so nothing is lost.
|
|
142
148
|
* After writing, the four legacy keys are stripped from the manifest.
|
|
143
149
|
*/
|
|
144
150
|
private migrateLoopNotesIfNeeded;
|
|
145
|
-
/** Find the live loop whose
|
|
151
|
+
/** Find the live loop whose resolved note key == `key`, then upsert its file. No-op
|
|
146
152
|
* (and no file) when the graph carries no such loop. */
|
|
147
153
|
private upsertLoopFileByKey;
|
|
148
154
|
/** Find-or-create the identity-matched `Loops/` file and apply `fields`
|
|
@@ -21,20 +21,25 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { emptyVariable, manifestFromJson, manifestToJson, normalizeBasis, normalizeConfidence, } from "./types";
|
|
23
23
|
import { LoopGraph, labelLoopsByKey } from "./loopGraph";
|
|
24
|
+
import { discoverCanvasLoops } from "./quantCanvasLoops";
|
|
24
25
|
import { parseNote, serializeNote, splitFrontmatter } from "./noteCodec";
|
|
25
26
|
import { noteSlug, noteUnslug } from "./noteNaming";
|
|
26
27
|
import { canonicalLoopMembers, loopMatchesNote, loopSlug, parseLoopNote, serializeLoopNote, } from "./loopNote";
|
|
27
28
|
import { contentSignature, stampMeta, WRITE_SOURCE_PLUGIN } from "./specHash";
|
|
28
|
-
import { loopEchoLabel,
|
|
29
|
+
import { loopEchoLabel, resolvedLoopNoteKey } from "./loopKey";
|
|
29
30
|
import { autoLayout } from "./layout";
|
|
30
31
|
import { render, } from "./exporters";
|
|
31
32
|
import { LoopType } from "./types";
|
|
32
33
|
import { baseName, joinPath, parentPath, } from "./storage";
|
|
33
34
|
import { deriveParentAnchors, linkPointsToModel, parseSubsystemLink, } from "./subsystemLinks";
|
|
34
35
|
import { isPublicInput, isPublicOutput, } from "./publicInterface";
|
|
36
|
+
import { SINK_CLOUD, SOURCE_CLOUD, extraWithFlow, extraWithSfdPosition, extraWithoutFlow, flowOf, hasAuthoredSfd, sfdPositionsFor, validateFlowEndpoints, } from "./sfd";
|
|
35
37
|
/** Non-variable notes that share a model folder. */
|
|
36
38
|
const SPECIAL_NOTES = new Set(["System.md", "Futures.md", "CLA.md"]);
|
|
37
39
|
const MAX_SCAN_DEPTH = 12;
|
|
40
|
+
function loopTypeLetter(loop) {
|
|
41
|
+
return loop.type === LoopType.reinforcing ? "R" : "B";
|
|
42
|
+
}
|
|
38
43
|
export class NativeEngine {
|
|
39
44
|
constructor(storage, yaml, opts) {
|
|
40
45
|
this.storage = storage;
|
|
@@ -70,10 +75,19 @@ export class NativeEngine {
|
|
|
70
75
|
const manifest = await this.readManifest(folder);
|
|
71
76
|
const nodes = await this.loadNotes(folder);
|
|
72
77
|
const graph = new LoopGraph(nodes);
|
|
73
|
-
const
|
|
78
|
+
const discovered = discoverCanvasLoops(nodes, graph.detectLoops(), { manifest });
|
|
79
|
+
const loops = discovered.loops;
|
|
74
80
|
const labels = labelLoopsByKey(loops, (id) => graph.node(id)?.label ?? id);
|
|
75
81
|
const quant = manifestIsQuant(manifest) || nodes.some((n) => "quant" in n.extra);
|
|
76
|
-
return {
|
|
82
|
+
return {
|
|
83
|
+
folder,
|
|
84
|
+
manifest,
|
|
85
|
+
nodes,
|
|
86
|
+
loops,
|
|
87
|
+
labels,
|
|
88
|
+
quant,
|
|
89
|
+
analysisError: discovered.analysisError,
|
|
90
|
+
};
|
|
77
91
|
}
|
|
78
92
|
// ---- model lifecycle -----------------------------------------------------
|
|
79
93
|
async createModel(name) {
|
|
@@ -306,7 +320,13 @@ export class NativeEngine {
|
|
|
306
320
|
next.tags = [...patch.tags];
|
|
307
321
|
if (patch.body !== undefined)
|
|
308
322
|
next.body = patch.body;
|
|
323
|
+
if (patch.type !== undefined && patch.type !== "flow" && prev.type === "flow") {
|
|
324
|
+
next.extra = extraWithoutFlow(next.extra);
|
|
325
|
+
}
|
|
309
326
|
const out = await this.writeNote(folder, prev, next);
|
|
327
|
+
if (prev.type === "stock" && patch.type !== undefined && patch.type !== "stock") {
|
|
328
|
+
await this.detachStockFlowEndpoints(folder, prev.id);
|
|
329
|
+
}
|
|
310
330
|
await this.touchManifest(folder);
|
|
311
331
|
return out;
|
|
312
332
|
}
|
|
@@ -351,26 +371,94 @@ export class NativeEngine {
|
|
|
351
371
|
// stampMeta treats an x/y-only change as cosmetic → rev/modified preserved.
|
|
352
372
|
await this.writeNote(folder, prev, { ...prev, x, y });
|
|
353
373
|
}
|
|
354
|
-
async
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
await this.storage.remove(p);
|
|
363
|
-
}
|
|
364
|
-
// Drop inbound links so loop detection and the manifest stay consistent.
|
|
374
|
+
async moveVariableSfd(folder, id, x, y) {
|
|
375
|
+
const prev = await this.readNote(folder, id);
|
|
376
|
+
await this.writeNote(folder, prev, {
|
|
377
|
+
...prev,
|
|
378
|
+
extra: extraWithSfdPosition(prev.extra, x, y),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
async pinSfdLayout(folder) {
|
|
365
382
|
const notes = await this.loadNotes(folder);
|
|
383
|
+
if (hasAuthoredSfd(notes))
|
|
384
|
+
return;
|
|
385
|
+
const pos = sfdPositionsFor(notes);
|
|
366
386
|
for (const n of notes) {
|
|
367
|
-
|
|
387
|
+
const p = pos.get(n.id);
|
|
388
|
+
if (p) {
|
|
368
389
|
await this.writeNote(folder, n, {
|
|
369
390
|
...n,
|
|
370
|
-
|
|
391
|
+
extra: extraWithSfdPosition(n.extra, p.x, p.y),
|
|
371
392
|
});
|
|
372
393
|
}
|
|
373
394
|
}
|
|
395
|
+
}
|
|
396
|
+
async removeVariable(folder, id) {
|
|
397
|
+
const notes = await this.loadNotes(folder);
|
|
398
|
+
const removed = notes.find((n) => n.id === id);
|
|
399
|
+
const removeIds = new Set([id]);
|
|
400
|
+
if (removed?.type === "stock") {
|
|
401
|
+
for (const n of notes) {
|
|
402
|
+
if (n.type !== "flow" || n.id === id)
|
|
403
|
+
continue;
|
|
404
|
+
const spec = flowOf(n);
|
|
405
|
+
if (!spec || (spec.from !== id && spec.to !== id))
|
|
406
|
+
continue;
|
|
407
|
+
const from = spec.from === id ? SOURCE_CLOUD : spec.from;
|
|
408
|
+
const to = spec.to === id ? SINK_CLOUD : spec.to;
|
|
409
|
+
if (from === SOURCE_CLOUD && to === SINK_CLOUD)
|
|
410
|
+
removeIds.add(n.id);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
for (const rid of removeIds)
|
|
414
|
+
await this.removeNoteById(folder, rid);
|
|
415
|
+
// Drop inbound links to removed notes and recloud flow endpoints that touched
|
|
416
|
+
// a removed stock, so the SFD topology remains valid after deletion.
|
|
417
|
+
for (const n of notes) {
|
|
418
|
+
if (removeIds.has(n.id))
|
|
419
|
+
continue;
|
|
420
|
+
let changed = false;
|
|
421
|
+
const links = n.links.filter((l) => !removeIds.has(l.to));
|
|
422
|
+
let next = n;
|
|
423
|
+
if (links.length !== n.links.length) {
|
|
424
|
+
next = { ...next, links };
|
|
425
|
+
changed = true;
|
|
426
|
+
}
|
|
427
|
+
if (removed?.type === "stock" && n.type === "flow") {
|
|
428
|
+
const spec = flowOf(n);
|
|
429
|
+
if (spec && (spec.from === id || spec.to === id)) {
|
|
430
|
+
next = {
|
|
431
|
+
...next,
|
|
432
|
+
extra: extraWithFlow(next.extra, {
|
|
433
|
+
from: spec.from === id ? SOURCE_CLOUD : spec.from,
|
|
434
|
+
to: spec.to === id ? SINK_CLOUD : spec.to,
|
|
435
|
+
}),
|
|
436
|
+
};
|
|
437
|
+
changed = true;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (changed)
|
|
441
|
+
await this.writeNote(folder, n, next);
|
|
442
|
+
}
|
|
443
|
+
await this.touchManifest(folder);
|
|
444
|
+
}
|
|
445
|
+
async setFlowEndpoints(folder, flowId, from, to) {
|
|
446
|
+
const flow = await this.readNote(folder, flowId);
|
|
447
|
+
if (flow.type !== "flow")
|
|
448
|
+
throw new Error(`Flow endpoints apply to flow variables only: ${flow.label || flow.id}`);
|
|
449
|
+
const notes = await this.loadNotes(folder);
|
|
450
|
+
const byId = new Map(notes.map((n) => [n.id, n]));
|
|
451
|
+
const check = validateFlowEndpoints(from, to, byId);
|
|
452
|
+
if (!check.ok)
|
|
453
|
+
throw new Error(check.error);
|
|
454
|
+
const next = {
|
|
455
|
+
...flow,
|
|
456
|
+
extra: extraWithFlow(flow.extra, { from, to }),
|
|
457
|
+
// Once the pipe is explicit, legacy flow->stock links become redundant
|
|
458
|
+
// material edges. Preserve all remaining information connectors.
|
|
459
|
+
links: flow.links.filter((l) => byId.get(l.to)?.type !== "stock"),
|
|
460
|
+
};
|
|
461
|
+
await this.writeNote(folder, flow, next);
|
|
374
462
|
await this.touchManifest(folder);
|
|
375
463
|
}
|
|
376
464
|
// ---- links ---------------------------------------------------------------
|
|
@@ -446,8 +534,18 @@ export class NativeEngine {
|
|
|
446
534
|
// filename or the labels. The legacy `model.json` maps (loopNotes/loopTitles/
|
|
447
535
|
// loopValence/loopArchetypes) are auto-migrated to files on first touch. This
|
|
448
536
|
// mirrors `core/lib/cli/vault_engine.dart` so the plugin and the app/CLI agree
|
|
449
|
-
// on one vault.
|
|
450
|
-
//
|
|
537
|
+
// on one vault. Qualitative resolved maps retain the legacy sorted-label key;
|
|
538
|
+
// quantitative-only loops use their exact directed id so routes cannot fold.
|
|
539
|
+
async liveLoopContext(folder) {
|
|
540
|
+
const manifest = await this.readManifest(folder);
|
|
541
|
+
const nodes = await this.loadNotes(folder);
|
|
542
|
+
const graph = new LoopGraph(nodes);
|
|
543
|
+
const loops = discoverCanvasLoops(nodes, graph.detectLoops(), { manifest }).loops;
|
|
544
|
+
return {
|
|
545
|
+
loops,
|
|
546
|
+
nameOf: (id) => graph.node(id)?.label ?? id,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
451
549
|
async getLoopNotes(folder) {
|
|
452
550
|
await this.migrateLoopNotesIfNeeded(folder);
|
|
453
551
|
const files = await this.listLoopNoteFiles(folder);
|
|
@@ -461,12 +559,11 @@ export class NativeEngine {
|
|
|
461
559
|
}
|
|
462
560
|
return out;
|
|
463
561
|
}
|
|
464
|
-
const
|
|
465
|
-
const nameOf = (id) => graph.node(id)?.label ?? id;
|
|
562
|
+
const { loops, nameOf } = await this.liveLoopContext(folder);
|
|
466
563
|
const out = {};
|
|
467
564
|
const matched = new Set();
|
|
468
|
-
for (const l of
|
|
469
|
-
const type = l
|
|
565
|
+
for (const l of loops) {
|
|
566
|
+
const type = loopTypeLetter(l);
|
|
470
567
|
let note;
|
|
471
568
|
for (const f of files) {
|
|
472
569
|
if (matched.has(f))
|
|
@@ -481,7 +578,7 @@ export class NativeEngine {
|
|
|
481
578
|
if (!note)
|
|
482
579
|
continue;
|
|
483
580
|
if (note.body.trim().length > 0) {
|
|
484
|
-
out[
|
|
581
|
+
out[resolvedLoopNoteKey(l, nameOf)] = note.body;
|
|
485
582
|
}
|
|
486
583
|
}
|
|
487
584
|
return out;
|
|
@@ -497,23 +594,21 @@ export class NativeEngine {
|
|
|
497
594
|
*/
|
|
498
595
|
async loopNotePath(folder, key) {
|
|
499
596
|
await this.migrateLoopNotesIfNeeded(folder);
|
|
500
|
-
const
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
597
|
+
const { loops, nameOf } = await this.liveLoopContext(folder);
|
|
598
|
+
const matches = loops.filter((loop) => resolvedLoopNoteKey(loop, nameOf) === key);
|
|
599
|
+
if (matches.length !== 1)
|
|
600
|
+
return null;
|
|
601
|
+
const l = matches[0];
|
|
602
|
+
const type = loopTypeLetter(l);
|
|
603
|
+
const memberLabels = l.nodeIds.map(nameOf);
|
|
604
|
+
for (const f of await this.listLoopNoteFiles(folder)) {
|
|
605
|
+
const parsed = parseLoopNote((await this.readLoopNoteFile(folder, f)) ?? "", this.yaml);
|
|
606
|
+
if (loopMatchesNote(type, l.nodeIds, parsed)) {
|
|
607
|
+
return joinPath(this.loopsDir(folder), f);
|
|
512
608
|
}
|
|
513
|
-
const file = await this.writeLoopFile(folder, l.nodeIds, type, memberLabels, {});
|
|
514
|
-
return joinPath(this.loopsDir(folder), file);
|
|
515
609
|
}
|
|
516
|
-
|
|
610
|
+
const file = await this.writeLoopFile(folder, l.nodeIds, type, memberLabels, {});
|
|
611
|
+
return joinPath(this.loopsDir(folder), file);
|
|
517
612
|
}
|
|
518
613
|
async ensureSystemNote(folder) {
|
|
519
614
|
const path = joinPath(folder, "System.md");
|
|
@@ -585,6 +680,45 @@ export class NativeEngine {
|
|
|
585
680
|
const path = (await this.noteFilesById(folder)).get(id) ?? this.notePath(folder, id);
|
|
586
681
|
return parseNote(await this.storage.read(path), this.yaml, id);
|
|
587
682
|
}
|
|
683
|
+
async removeNoteById(folder, id) {
|
|
684
|
+
const resolved = (await this.noteFilesById(folder)).get(id);
|
|
685
|
+
if (resolved)
|
|
686
|
+
await this.storage.remove(resolved);
|
|
687
|
+
for (const p of [this.notePath(folder, id), this.legacyNotePath(folder, id)]) {
|
|
688
|
+
if (p !== resolved)
|
|
689
|
+
await this.storage.remove(p);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
async detachStockFlowEndpoints(folder, stockId) {
|
|
693
|
+
const notes = await this.loadNotes(folder);
|
|
694
|
+
const removeIds = new Set();
|
|
695
|
+
for (const n of notes) {
|
|
696
|
+
if (n.type !== "flow")
|
|
697
|
+
continue;
|
|
698
|
+
const spec = flowOf(n);
|
|
699
|
+
if (!spec || (spec.from !== stockId && spec.to !== stockId))
|
|
700
|
+
continue;
|
|
701
|
+
const from = spec.from === stockId ? SOURCE_CLOUD : spec.from;
|
|
702
|
+
const to = spec.to === stockId ? SINK_CLOUD : spec.to;
|
|
703
|
+
if (from === SOURCE_CLOUD && to === SINK_CLOUD) {
|
|
704
|
+
removeIds.add(n.id);
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
await this.writeNote(folder, n, { ...n, extra: extraWithFlow(n.extra, { from, to }) });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (removeIds.size === 0)
|
|
711
|
+
return;
|
|
712
|
+
for (const id of removeIds)
|
|
713
|
+
await this.removeNoteById(folder, id);
|
|
714
|
+
for (const n of notes) {
|
|
715
|
+
if (removeIds.has(n.id))
|
|
716
|
+
continue;
|
|
717
|
+
const links = n.links.filter((l) => !removeIds.has(l.to));
|
|
718
|
+
if (links.length !== n.links.length)
|
|
719
|
+
await this.writeNote(folder, n, { ...n, links });
|
|
720
|
+
}
|
|
721
|
+
}
|
|
588
722
|
async writeNote(folder, prev, next) {
|
|
589
723
|
const stamped = stampMeta(prev, next, WRITE_SOURCE_PLUGIN);
|
|
590
724
|
// The file is named after the label (so the vault reads like the diagram);
|
|
@@ -739,7 +873,7 @@ export class NativeEngine {
|
|
|
739
873
|
* One-way, idempotent migration of legacy loop annotations from the
|
|
740
874
|
* `model.json` maps into `Loops/*.md` files. Guard: if any `Loops/` file
|
|
741
875
|
* exists the model is treated as migrated and this returns immediately. Each
|
|
742
|
-
* legacy key is matched to a live loop by its
|
|
876
|
+
* legacy key is matched to a live loop by its resolved note key; a match keeps
|
|
743
877
|
* the loop's canonical member ids, an unmatched key becomes a pre-flagged
|
|
744
878
|
* orphan (`members: []`, original key kept in `loop:`) so nothing is lost.
|
|
745
879
|
* After writing, the four legacy keys are stripped from the manifest.
|
|
@@ -771,16 +905,15 @@ export class NativeEngine {
|
|
|
771
905
|
...Object.keys(valence),
|
|
772
906
|
...Object.keys(archetypes),
|
|
773
907
|
]);
|
|
774
|
-
const
|
|
775
|
-
const nameOf = (id) => graph.node(id)?.label ?? id;
|
|
908
|
+
const { loops, nameOf } = await this.liveLoopContext(folder);
|
|
776
909
|
const liveByKey = new Map();
|
|
777
|
-
for (const l of
|
|
778
|
-
const
|
|
779
|
-
liveByKey.set(
|
|
910
|
+
for (const l of loops) {
|
|
911
|
+
const key = resolvedLoopNoteKey(l, nameOf);
|
|
912
|
+
liveByKey.set(key, liveByKey.has(key) ? null : l);
|
|
780
913
|
}
|
|
781
914
|
for (const key of allKeys) {
|
|
782
915
|
const type = key.startsWith("B") ? "B" : "R";
|
|
783
|
-
const live = liveByKey.get(key);
|
|
916
|
+
const live = liveByKey.get(key) ?? undefined;
|
|
784
917
|
const members = live ? canonicalLoopMembers(live.nodeIds) : [];
|
|
785
918
|
const labels = live
|
|
786
919
|
? live.nodeIds.map(nameOf)
|
|
@@ -810,20 +943,16 @@ export class NativeEngine {
|
|
|
810
943
|
modified: new Date().toISOString(),
|
|
811
944
|
});
|
|
812
945
|
}
|
|
813
|
-
/** Find the live loop whose
|
|
946
|
+
/** Find the live loop whose resolved note key == `key`, then upsert its file. No-op
|
|
814
947
|
* (and no file) when the graph carries no such loop. */
|
|
815
948
|
async upsertLoopFileByKey(folder, key, fields) {
|
|
816
949
|
await this.migrateLoopNotesIfNeeded(folder);
|
|
817
|
-
const
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
const type = l.type === LoopType.reinforcing ? "R" : "B";
|
|
821
|
-
const memberLabels = l.nodeIds.map(nameOf);
|
|
822
|
-
if (loopKey(memberLabels, type) !== key)
|
|
823
|
-
continue;
|
|
824
|
-
await this.writeLoopFile(folder, l.nodeIds, type, memberLabels, fields);
|
|
950
|
+
const { loops, nameOf } = await this.liveLoopContext(folder);
|
|
951
|
+
const matches = loops.filter((loop) => resolvedLoopNoteKey(loop, nameOf) === key);
|
|
952
|
+
if (matches.length !== 1)
|
|
825
953
|
return;
|
|
826
|
-
|
|
954
|
+
const loop = matches[0];
|
|
955
|
+
await this.writeLoopFile(folder, loop.nodeIds, loopTypeLetter(loop), loop.nodeIds.map(nameOf), fields);
|
|
827
956
|
}
|
|
828
957
|
/** Find-or-create the identity-matched `Loops/` file and apply `fields`
|
|
829
958
|
* (undefined leaves a field unchanged; `archetype: ""` clears it). Returns the
|
|
@@ -1007,10 +1136,18 @@ export class NativeEngine {
|
|
|
1007
1136
|
// ---- pure helpers ----------------------------------------------------------
|
|
1008
1137
|
/** A copy of `v` with its id and every link target re-pointed through `idMap`. */
|
|
1009
1138
|
function remapVariableIds(v, idMap) {
|
|
1139
|
+
const flow = flowOf(v);
|
|
1140
|
+
const extra = flow
|
|
1141
|
+
? extraWithFlow(v.extra, {
|
|
1142
|
+
from: idMap.get(flow.from) ?? flow.from,
|
|
1143
|
+
to: idMap.get(flow.to) ?? flow.to,
|
|
1144
|
+
})
|
|
1145
|
+
: v.extra;
|
|
1010
1146
|
return {
|
|
1011
1147
|
...v,
|
|
1012
1148
|
id: idMap.get(v.id) ?? v.id,
|
|
1013
1149
|
links: v.links.map((l) => ({ ...l, to: idMap.get(l.to) ?? l.to })),
|
|
1150
|
+
extra,
|
|
1014
1151
|
};
|
|
1015
1152
|
}
|
|
1016
1153
|
function makeLink(to, init) {
|
|
@@ -1074,7 +1211,7 @@ function toExportGraph(view) {
|
|
|
1074
1211
|
id: `${n.id}__${l.to}`,
|
|
1075
1212
|
source: n.id,
|
|
1076
1213
|
target: l.to,
|
|
1077
|
-
polarity: l.polarity === "-" ? -1 : 1,
|
|
1214
|
+
polarity: l.polarity === "-" ? -1 : l.polarity === "+" ? 1 : "?",
|
|
1078
1215
|
delay: l.delay,
|
|
1079
1216
|
curvature: l.curvature,
|
|
1080
1217
|
dashed: l.indirect,
|