@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 neoloopy
3
+ Copyright (c) 2026 Abraham Kim
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -5,6 +5,10 @@ engine used by neoloopy surfaces, including the Obsidian plugin and web app.
5
5
  Pure TypeScript, ESM-only, with no runtime dependency on Obsidian, React, Vue,
6
6
  or a server.
7
7
 
8
+ ![An SIR epidemic causal-loop diagram open on the neoloopy canvas in Obsidian, with the link-editing toolbar visible](https://raw.githubusercontent.com/frozenabe/neoloopy-obsidian/main/screenshots/example-model.png)
9
+
10
+ <sub>The canvas this package renders — an SIR epidemic model open in Obsidian with the link-editing toolbar.</sub>
11
+
8
12
  **This README is ordered easy → hard** — start at the top with the one-tag React
9
13
  drop-in and go only as deep as you need:
10
14
 
@@ -463,22 +467,45 @@ import { buildMermaid, render } from "@neoloopy/cld-canvas";
463
467
 
464
468
  ## 5. Analysis helpers
465
469
 
470
+ ![A project-dynamics model — effort, schedule pressure, rework, burnout — showing detected reinforcing and balancing loops alongside the insight panel](https://raw.githubusercontent.com/frozenabe/neoloopy-obsidian/main/screenshots/sample-model.png)
471
+
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
+
466
474
  ```ts
467
- import { LoopGraph, endogeneity } from "@neoloopy/cld-canvas";
475
+ import { LoopGraph, discoverCanvasLoops, endogeneity } from "@neoloopy/cld-canvas";
468
476
 
469
477
  const loopGraph = new LoopGraph(graph.nodes);
470
- const loops = loopGraph.detectLoops();
478
+ const qualitative = loopGraph.detectLoops();
479
+ const { loops, analysisError } = discoverCanvasLoops(graph.nodes, qualitative, {
480
+ manifest: graph.manifest,
481
+ });
471
482
  const metrics = loopGraph.metrics();
472
483
  const summary = endogeneity(graph.nodes, loops);
473
484
  ```
474
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
+
475
501
  ## What is exported
476
502
 
477
503
  The package exports the public modules from `src/index.ts`, including:
478
504
 
479
505
  - Engine: `NativeEngine`, `NeoloopyEngine`, `MemoryStorage`, `VaultStorage`
480
506
  - Domain types: `VariableFile`, `VaultLink`, `ModelManifest`, `GraphView`
481
- - Graph logic: `LoopGraph`, `DetectedLoop`, `LoopType`, `labelLoopsByKey`
507
+ - Graph logic: `LoopGraph`, `DetectedLoop`, `LoopType`, `labelLoopsByKey`,
508
+ `discoverCanvasLoops`
482
509
  - Rendering: `paint`, `SceneCache`, `Camera`, `LIGHT`, `DARK`, geometry helpers
483
510
  - File codecs: `parseNote`, `serializeNote`, `manifestFromJson`
484
511
  - Exporters: `render`, `buildMermaid`, `loopNoteKey`
@@ -7,6 +7,7 @@
7
7
  import { DetectedLoop, ModelManifest, VarType, VariableFile, Viewport } from "./types";
8
8
  import { Rendered } from "./exporters";
9
9
  import { ParentAnchor } from "./subsystemLinks";
10
+ import { ChildInterface } from "./publicInterface";
10
11
  /** Lightweight model descriptor for pickers/lists (no notes loaded). */
11
12
  export interface ModelRef {
12
13
  id: string;
@@ -33,6 +34,8 @@ export interface GraphView {
33
34
  /** loop.key -> "R1" / "B2" badge label. */
34
35
  labels: Map<string, string>;
35
36
  quant: boolean;
37
+ /** Non-null when quantitative canvas-loop discovery could not complete. */
38
+ analysisError?: string | null;
36
39
  }
37
40
  export interface NewVariable {
38
41
  label: string;
@@ -157,7 +160,17 @@ export interface NeoloopyEngine {
157
160
  setEquation(folder: string, id: string, patch: QuantPatch): Promise<VariableFile>;
158
161
  /** Cosmetic position change — never bumps rev/modified. */
159
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>;
160
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>;
161
174
  /**
162
175
  * Link (or clear, with `child=null`) a node's subsystem anchor — the same
163
176
  * `[[../<childDir>/System|<Child Name>]]` wikilink the app/CLI write, so the
@@ -176,10 +189,10 @@ export interface NeoloopyEngine {
176
189
  export(folder: string, format: ExportFormat): Promise<Rendered>;
177
190
  /**
178
191
  * Loop notes — one `Loops/<slug>.md` file per feedback loop, identity-anchored
179
- * in frontmatter (the same files the Dart app/CLI write). Resolved into a map
180
- * keyed by `<R|B>:<sorted unique variable names>` so both surfaces agree.
181
- * `getLoopNotes` returns the body text per live loop; `setLoopNote` writes it
182
- * into the identity-matched file (preserving title/valence/archetype).
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.
183
196
  */
184
197
  getLoopNotes(folder: string): Promise<Record<string, string>>;
185
198
  setLoopNote(folder: string, key: string, text: string): Promise<void>;
@@ -200,4 +213,13 @@ export interface NeoloopyEngine {
200
213
  * Each anchor names the parent model + the linking variable. Empty when none.
201
214
  */
202
215
  deriveParents(folder: string): Promise<ParentAnchor[]>;
216
+ /**
217
+ * The public interface of the child model linked from `varId`'s subsystem
218
+ * anchor: a display qualifier (the link alias, else the child model name) plus
219
+ * the labels of the child's public OUTPUTS and INPUTS. Null when the node has
220
+ * no subsystem link, or the child can't be resolved/loaded (fail closed —
221
+ * callers render a muted fallback). Read-only: the qualitative engine never
222
+ * edits the quant interface (publish/bind live in the app/CLI/MCP).
223
+ */
224
+ childInterface(folder: string, varId: string): Promise<ChildInterface | null>;
203
225
  }
@@ -16,7 +16,7 @@ export interface ExportEdge {
16
16
  id?: string;
17
17
  source: string;
18
18
  target: string;
19
- polarity?: number;
19
+ polarity?: number | "?";
20
20
  delay?: boolean;
21
21
  curvature?: number;
22
22
  dashed?: boolean;
@@ -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
- * ordered within each type by case-insensitive sorted variable names. Returns a
28
- * map from `DetectedLoop.key` to its label.
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
- /** All simple directed cycles, R/B classified, deduped to one per node-set. */
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
Binary file
@@ -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
@@ -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
@@ -26,6 +26,7 @@ import { contentSignature } from "./specHash";
26
26
  import { Rendered } from "./exporters";
27
27
  import { VaultStorage } from "./storage";
28
28
  import { ParentAnchor } from "./subsystemLinks";
29
+ import { ChildInterface } from "./publicInterface";
29
30
  export interface NativeEngineOptions {
30
31
  /** Vault-relative base folder for newly created models (may be ""). */
31
32
  modelsRoot: string;
@@ -84,13 +85,17 @@ export declare class NativeEngine implements NeoloopyEngine {
84
85
  name: string;
85
86
  } | null): Promise<void>;
86
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>;
87
90
  removeVariable(folder: string, id: string): Promise<void>;
91
+ setFlowEndpoints(folder: string, flowId: string, from: string, to: string): Promise<void>;
88
92
  addLink(folder: string, from: string, to: string, init?: LinkInit): Promise<void>;
89
93
  updateLink(folder: string, from: string, to: string, patch: LinkPatch): Promise<void>;
90
94
  removeLink(folder: string, from: string, to: string): Promise<void>;
91
95
  buildModel(folder: string, spec: BuildSpec): Promise<void>;
92
96
  relayout(folder: string): Promise<void>;
93
97
  setViewport(folder: string, viewport: Viewport): Promise<void>;
98
+ private liveLoopContext;
94
99
  getLoopNotes(folder: string): Promise<Record<string, string>>;
95
100
  setLoopNote(folder: string, key: string, text: string): Promise<void>;
96
101
  /**
@@ -102,6 +107,7 @@ export declare class NativeEngine implements NeoloopyEngine {
102
107
  loopNotePath(folder: string, key: string): Promise<string | null>;
103
108
  ensureSystemNote(folder: string): Promise<string>;
104
109
  deriveParents(folder: string): Promise<ParentAnchor[]>;
110
+ childInterface(folder: string, varId: string): Promise<ChildInterface | null>;
105
111
  export(folder: string, format: ExportFormat): Promise<Rendered>;
106
112
  /** The model's `Nodes/` subfolder, where variable notes now live. */
107
113
  private nodesDir;
@@ -110,6 +116,8 @@ export declare class NativeEngine implements NeoloopyEngine {
110
116
  /** Pre-`Nodes/` location of a variable note, flat at the model root. */
111
117
  private legacyNotePath;
112
118
  private readNote;
119
+ private removeNoteById;
120
+ private detachStockFlowEndpoints;
113
121
  private writeNote;
114
122
  private readManifest;
115
123
  private writeManifest;
@@ -134,13 +142,13 @@ export declare class NativeEngine implements NeoloopyEngine {
134
142
  * One-way, idempotent migration of legacy loop annotations from the
135
143
  * `model.json` maps into `Loops/*.md` files. Guard: if any `Loops/` file
136
144
  * exists the model is treated as migrated and this returns immediately. Each
137
- * legacy key is matched to a live loop by its `loopKey`; a matched key keeps
145
+ * legacy key is matched to a live loop by its resolved note key; a match keeps
138
146
  * the loop's canonical member ids, an unmatched key becomes a pre-flagged
139
147
  * orphan (`members: []`, original key kept in `loop:`) so nothing is lost.
140
148
  * After writing, the four legacy keys are stripped from the manifest.
141
149
  */
142
150
  private migrateLoopNotesIfNeeded;
143
- /** Find the live loop whose `loopKey` == `key`, then upsert its file. No-op
151
+ /** Find the live loop whose resolved note key == `key`, then upsert its file. No-op
144
152
  * (and no file) when the graph carries no such loop. */
145
153
  private upsertLoopFileByKey;
146
154
  /** Find-or-create the identity-matched `Loops/` file and apply `fields`