@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.
@@ -21,19 +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, loopKey } from "./loopKey";
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
- import { deriveParentAnchors } from "./subsystemLinks";
34
+ import { deriveParentAnchors, linkPointsToModel, parseSubsystemLink, } from "./subsystemLinks";
35
+ import { isPublicInput, isPublicOutput, } from "./publicInterface";
36
+ import { SINK_CLOUD, SOURCE_CLOUD, extraWithFlow, extraWithSfdPosition, extraWithoutFlow, flowOf, hasAuthoredSfd, sfdPositionsFor, validateFlowEndpoints, } from "./sfd";
34
37
  /** Non-variable notes that share a model folder. */
35
38
  const SPECIAL_NOTES = new Set(["System.md", "Futures.md", "CLA.md"]);
36
39
  const MAX_SCAN_DEPTH = 12;
40
+ function loopTypeLetter(loop) {
41
+ return loop.type === LoopType.reinforcing ? "R" : "B";
42
+ }
37
43
  export class NativeEngine {
38
44
  constructor(storage, yaml, opts) {
39
45
  this.storage = storage;
@@ -69,10 +75,19 @@ export class NativeEngine {
69
75
  const manifest = await this.readManifest(folder);
70
76
  const nodes = await this.loadNotes(folder);
71
77
  const graph = new LoopGraph(nodes);
72
- const loops = graph.detectLoops();
78
+ const discovered = discoverCanvasLoops(nodes, graph.detectLoops(), { manifest });
79
+ const loops = discovered.loops;
73
80
  const labels = labelLoopsByKey(loops, (id) => graph.node(id)?.label ?? id);
74
81
  const quant = manifestIsQuant(manifest) || nodes.some((n) => "quant" in n.extra);
75
- return { folder, manifest, nodes, loops, labels, quant };
82
+ return {
83
+ folder,
84
+ manifest,
85
+ nodes,
86
+ loops,
87
+ labels,
88
+ quant,
89
+ analysisError: discovered.analysisError,
90
+ };
76
91
  }
77
92
  // ---- model lifecycle -----------------------------------------------------
78
93
  async createModel(name) {
@@ -305,7 +320,13 @@ export class NativeEngine {
305
320
  next.tags = [...patch.tags];
306
321
  if (patch.body !== undefined)
307
322
  next.body = patch.body;
323
+ if (patch.type !== undefined && patch.type !== "flow" && prev.type === "flow") {
324
+ next.extra = extraWithoutFlow(next.extra);
325
+ }
308
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
+ }
309
330
  await this.touchManifest(folder);
310
331
  return out;
311
332
  }
@@ -350,26 +371,94 @@ export class NativeEngine {
350
371
  // stampMeta treats an x/y-only change as cosmetic → rev/modified preserved.
351
372
  await this.writeNote(folder, prev, { ...prev, x, y });
352
373
  }
353
- async removeVariable(folder, id) {
354
- // The note is named by its label now, so resolve its real path by id; also
355
- // clear any stale id-named copies (a legacy flat note or an old Nodes/<id>.md).
356
- const resolved = (await this.noteFilesById(folder)).get(id);
357
- if (resolved)
358
- await this.storage.remove(resolved);
359
- for (const p of [this.notePath(folder, id), this.legacyNotePath(folder, id)]) {
360
- if (p !== resolved)
361
- await this.storage.remove(p);
362
- }
363
- // 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) {
364
382
  const notes = await this.loadNotes(folder);
383
+ if (hasAuthoredSfd(notes))
384
+ return;
385
+ const pos = sfdPositionsFor(notes);
365
386
  for (const n of notes) {
366
- if (n.links.some((l) => l.to === id)) {
387
+ const p = pos.get(n.id);
388
+ if (p) {
367
389
  await this.writeNote(folder, n, {
368
390
  ...n,
369
- links: n.links.filter((l) => l.to !== id),
391
+ extra: extraWithSfdPosition(n.extra, p.x, p.y),
370
392
  });
371
393
  }
372
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);
373
462
  await this.touchManifest(folder);
374
463
  }
375
464
  // ---- links ---------------------------------------------------------------
@@ -445,8 +534,18 @@ export class NativeEngine {
445
534
  // filename or the labels. The legacy `model.json` maps (loopNotes/loopTitles/
446
535
  // loopValence/loopArchetypes) are auto-migrated to files on first touch. This
447
536
  // mirrors `core/lib/cli/vault_engine.dart` so the plugin and the app/CLI agree
448
- // on one vault. Resolved maps stay keyed by the legacy `<R|B>:<sorted labels>`
449
- // key (see `loopKey`) so existing UI consumers are unchanged.
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
+ }
450
549
  async getLoopNotes(folder) {
451
550
  await this.migrateLoopNotesIfNeeded(folder);
452
551
  const files = await this.listLoopNoteFiles(folder);
@@ -460,12 +559,11 @@ export class NativeEngine {
460
559
  }
461
560
  return out;
462
561
  }
463
- const graph = new LoopGraph(await this.loadNotes(folder));
464
- const nameOf = (id) => graph.node(id)?.label ?? id;
562
+ const { loops, nameOf } = await this.liveLoopContext(folder);
465
563
  const out = {};
466
564
  const matched = new Set();
467
- for (const l of graph.detectLoops()) {
468
- const type = l.type === LoopType.reinforcing ? "R" : "B";
565
+ for (const l of loops) {
566
+ const type = loopTypeLetter(l);
469
567
  let note;
470
568
  for (const f of files) {
471
569
  if (matched.has(f))
@@ -480,7 +578,7 @@ export class NativeEngine {
480
578
  if (!note)
481
579
  continue;
482
580
  if (note.body.trim().length > 0) {
483
- out[loopKey(l.nodeIds.map(nameOf), type)] = note.body;
581
+ out[resolvedLoopNoteKey(l, nameOf)] = note.body;
484
582
  }
485
583
  }
486
584
  return out;
@@ -496,23 +594,21 @@ export class NativeEngine {
496
594
  */
497
595
  async loopNotePath(folder, key) {
498
596
  await this.migrateLoopNotesIfNeeded(folder);
499
- const graph = new LoopGraph(await this.loadNotes(folder));
500
- const nameOf = (id) => graph.node(id)?.label ?? id;
501
- for (const l of graph.detectLoops()) {
502
- const type = l.type === LoopType.reinforcing ? "R" : "B";
503
- const memberLabels = l.nodeIds.map(nameOf);
504
- if (loopKey(memberLabels, type) !== key)
505
- continue;
506
- for (const f of await this.listLoopNoteFiles(folder)) {
507
- const parsed = parseLoopNote((await this.readLoopNoteFile(folder, f)) ?? "", this.yaml);
508
- if (loopMatchesNote(type, l.nodeIds, parsed)) {
509
- return joinPath(this.loopsDir(folder), f);
510
- }
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);
511
608
  }
512
- const file = await this.writeLoopFile(folder, l.nodeIds, type, memberLabels, {});
513
- return joinPath(this.loopsDir(folder), file);
514
609
  }
515
- return null;
610
+ const file = await this.writeLoopFile(folder, l.nodeIds, type, memberLabels, {});
611
+ return joinPath(this.loopsDir(folder), file);
516
612
  }
517
613
  async ensureSystemNote(folder) {
518
614
  const path = joinPath(folder, "System.md");
@@ -532,6 +628,33 @@ export class NativeEngine {
532
628
  return [];
533
629
  return deriveParentAnchors({ folder: current.folder, name: current.name }, models.filter((m) => m.folder !== folder).map((m) => ({ folder: m.folder, name: m.name })), (f) => this.loadNotes(f));
534
630
  }
631
+ async childInterface(folder, varId) {
632
+ const nodes = await this.loadNotes(folder);
633
+ const anchor = nodes.find((n) => n.id === varId);
634
+ const raw = (anchor?.subsystem ?? "").trim();
635
+ if (!raw)
636
+ return null;
637
+ const { alias } = parseSubsystemLink(raw);
638
+ const models = await this.listModels();
639
+ const match = models.find((m) => linkPointsToModel(raw, { folder: m.folder, name: m.name }));
640
+ if (!match)
641
+ return null;
642
+ try {
643
+ const childNodes = await this.loadNotes(match.folder);
644
+ const outputs = [];
645
+ const inputs = [];
646
+ for (const v of childNodes) {
647
+ if (isPublicOutput(v))
648
+ outputs.push(v.label || v.id);
649
+ if (isPublicInput(v))
650
+ inputs.push(v.label || v.id);
651
+ }
652
+ return { qualifier: alias ?? match.name, outputs, inputs };
653
+ }
654
+ catch {
655
+ return null;
656
+ }
657
+ }
535
658
  // ---- export --------------------------------------------------------------
536
659
  async export(folder, format) {
537
660
  const view = await this.loadGraph(folder);
@@ -557,6 +680,45 @@ export class NativeEngine {
557
680
  const path = (await this.noteFilesById(folder)).get(id) ?? this.notePath(folder, id);
558
681
  return parseNote(await this.storage.read(path), this.yaml, id);
559
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
+ }
560
722
  async writeNote(folder, prev, next) {
561
723
  const stamped = stampMeta(prev, next, WRITE_SOURCE_PLUGIN);
562
724
  // The file is named after the label (so the vault reads like the diagram);
@@ -711,7 +873,7 @@ export class NativeEngine {
711
873
  * One-way, idempotent migration of legacy loop annotations from the
712
874
  * `model.json` maps into `Loops/*.md` files. Guard: if any `Loops/` file
713
875
  * exists the model is treated as migrated and this returns immediately. Each
714
- * legacy key is matched to a live loop by its `loopKey`; a matched key keeps
876
+ * legacy key is matched to a live loop by its resolved note key; a match keeps
715
877
  * the loop's canonical member ids, an unmatched key becomes a pre-flagged
716
878
  * orphan (`members: []`, original key kept in `loop:`) so nothing is lost.
717
879
  * After writing, the four legacy keys are stripped from the manifest.
@@ -743,16 +905,15 @@ export class NativeEngine {
743
905
  ...Object.keys(valence),
744
906
  ...Object.keys(archetypes),
745
907
  ]);
746
- const graph = new LoopGraph(await this.loadNotes(folder));
747
- const nameOf = (id) => graph.node(id)?.label ?? id;
908
+ const { loops, nameOf } = await this.liveLoopContext(folder);
748
909
  const liveByKey = new Map();
749
- for (const l of graph.detectLoops()) {
750
- const type = l.type === LoopType.reinforcing ? "R" : "B";
751
- liveByKey.set(loopKey(l.nodeIds.map(nameOf), type), l);
910
+ for (const l of loops) {
911
+ const key = resolvedLoopNoteKey(l, nameOf);
912
+ liveByKey.set(key, liveByKey.has(key) ? null : l);
752
913
  }
753
914
  for (const key of allKeys) {
754
915
  const type = key.startsWith("B") ? "B" : "R";
755
- const live = liveByKey.get(key);
916
+ const live = liveByKey.get(key) ?? undefined;
756
917
  const members = live ? canonicalLoopMembers(live.nodeIds) : [];
757
918
  const labels = live
758
919
  ? live.nodeIds.map(nameOf)
@@ -782,20 +943,16 @@ export class NativeEngine {
782
943
  modified: new Date().toISOString(),
783
944
  });
784
945
  }
785
- /** Find the live loop whose `loopKey` == `key`, then upsert its file. No-op
946
+ /** Find the live loop whose resolved note key == `key`, then upsert its file. No-op
786
947
  * (and no file) when the graph carries no such loop. */
787
948
  async upsertLoopFileByKey(folder, key, fields) {
788
949
  await this.migrateLoopNotesIfNeeded(folder);
789
- const graph = new LoopGraph(await this.loadNotes(folder));
790
- const nameOf = (id) => graph.node(id)?.label ?? id;
791
- for (const l of graph.detectLoops()) {
792
- const type = l.type === LoopType.reinforcing ? "R" : "B";
793
- const memberLabels = l.nodeIds.map(nameOf);
794
- if (loopKey(memberLabels, type) !== key)
795
- continue;
796
- 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)
797
953
  return;
798
- }
954
+ const loop = matches[0];
955
+ await this.writeLoopFile(folder, loop.nodeIds, loopTypeLetter(loop), loop.nodeIds.map(nameOf), fields);
799
956
  }
800
957
  /** Find-or-create the identity-matched `Loops/` file and apply `fields`
801
958
  * (undefined leaves a field unchanged; `archetype: ""` clears it). Returns the
@@ -979,10 +1136,18 @@ export class NativeEngine {
979
1136
  // ---- pure helpers ----------------------------------------------------------
980
1137
  /** A copy of `v` with its id and every link target re-pointed through `idMap`. */
981
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;
982
1146
  return {
983
1147
  ...v,
984
1148
  id: idMap.get(v.id) ?? v.id,
985
1149
  links: v.links.map((l) => ({ ...l, to: idMap.get(l.to) ?? l.to })),
1150
+ extra,
986
1151
  };
987
1152
  }
988
1153
  function makeLink(to, init) {
@@ -1046,7 +1211,7 @@ function toExportGraph(view) {
1046
1211
  id: `${n.id}__${l.to}`,
1047
1212
  source: n.id,
1048
1213
  target: l.to,
1049
- polarity: l.polarity === "-" ? -1 : 1,
1214
+ polarity: l.polarity === "-" ? -1 : l.polarity === "+" ? 1 : "?",
1050
1215
  delay: l.delay,
1051
1216
  curvature: l.curvature,
1052
1217
  dashed: l.indirect,
@@ -1091,18 +1256,11 @@ function slug(s) {
1091
1256
  }
1092
1257
  function randomHex(bytes) {
1093
1258
  const arr = new Uint8Array(bytes);
1094
- // Web Crypto is exposed as the global `crypto` in both Obsidian (Electron)
1095
- // and Node (this pure engine's vitest tests). Referenced bare — not via
1096
- // `window`/`globalThis` — so it resolves under Node too; `typeof`-guarded so
1097
- // it falls back to Math.random anywhere it is unavailable.
1098
1259
  const c = typeof crypto !== "undefined" ? crypto : undefined;
1099
- if (c && typeof c.getRandomValues === "function") {
1100
- c.getRandomValues(arr);
1101
- }
1102
- else {
1103
- for (let i = 0; i < bytes; i++)
1104
- arr[i] = Math.floor(Math.random() * 256);
1260
+ if (!c || typeof c.getRandomValues !== "function") {
1261
+ throw new Error("Web Crypto is required to generate neoloopy ids.");
1105
1262
  }
1263
+ c.getRandomValues(arr);
1106
1264
  let s = "";
1107
1265
  for (const b of arr)
1108
1266
  s += b.toString(16).padStart(2, "0");
@@ -1112,9 +1270,7 @@ function genVarId() {
1112
1270
  return `var_${randomHex(4)}`;
1113
1271
  }
1114
1272
  function genModelId() {
1115
- const n = Date.now() * 1000 + Math.floor(Math.random() * 1000);
1116
- const hex = n.toString(16);
1117
- return `mdl_${hex.slice(-6)}`;
1273
+ return `mdl_${randomHex(4)}`;
1118
1274
  }
1119
1275
  /** Re-export so callers don't need a second import for the signature helper. */
1120
1276
  export { contentSignature };
@@ -79,10 +79,18 @@ function tagsFrom(v) {
79
79
  function rawScalar(frontmatter, key) {
80
80
  if (frontmatter === null)
81
81
  return undefined;
82
- const m = new RegExp(`^${key}:[ \\t]*(.+?)[ \\t]*$`, "m").exec(frontmatter);
83
- if (!m)
82
+ let s;
83
+ for (const line of frontmatter.split("\n")) {
84
+ const colon = line.indexOf(":");
85
+ if (colon < 0)
86
+ continue;
87
+ if (line.slice(0, colon).trim() !== key)
88
+ continue;
89
+ s = line.slice(colon + 1).trim();
90
+ break;
91
+ }
92
+ if (s === undefined)
84
93
  return undefined;
85
- let s = m[1];
86
94
  if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
87
95
  s = s.slice(1, -1);
88
96
  }
@@ -198,6 +206,44 @@ export function emitExtra(lines, key, value, indent) {
198
206
  lines.push(`${pad}${key}: ${scalar(value)}`);
199
207
  }
200
208
  }
209
+ function emitFlowExtra(lines, value) {
210
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
211
+ emitExtra(lines, "flow", value, 0);
212
+ return;
213
+ }
214
+ const m = value;
215
+ lines.push("flow:");
216
+ if (m["from"] !== undefined)
217
+ lines.push(` from: ${scalar(m["from"])}`);
218
+ if (m["to"] !== undefined)
219
+ lines.push(` to: ${scalar(m["to"])}`);
220
+ for (const [k, v] of Object.entries(m)) {
221
+ if (k === "from" || k === "to")
222
+ continue;
223
+ emitExtra(lines, k, v, 1);
224
+ }
225
+ }
226
+ function emitSfdExtra(lines, value) {
227
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
228
+ emitExtra(lines, "sfd", value, 0);
229
+ return;
230
+ }
231
+ const m = value;
232
+ lines.push("sfd:");
233
+ if (m["x"] !== undefined) {
234
+ const x = m["x"];
235
+ lines.push(` x: ${typeof x === "number" ? numToYaml(x) : scalar(x)}`);
236
+ }
237
+ if (m["y"] !== undefined) {
238
+ const y = m["y"];
239
+ lines.push(` y: ${typeof y === "number" ? numToYaml(y) : scalar(y)}`);
240
+ }
241
+ for (const [k, v] of Object.entries(m)) {
242
+ if (k === "x" || k === "y")
243
+ continue;
244
+ emitExtra(lines, k, v, 1);
245
+ }
246
+ }
201
247
  export function serializeNote(v) {
202
248
  const lines = [FENCE];
203
249
  lines.push(`id: ${scalar(v.id)}`);
@@ -244,8 +290,14 @@ export function serializeNote(v) {
244
290
  }
245
291
  }
246
292
  }
247
- for (const [k, val] of Object.entries(v.extra))
248
- emitExtra(lines, k, val, 0);
293
+ for (const [k, val] of Object.entries(v.extra)) {
294
+ if (k === "flow")
295
+ emitFlowExtra(lines, val);
296
+ else if (k === "sfd")
297
+ emitSfdExtra(lines, val);
298
+ else
299
+ emitExtra(lines, k, val, 0);
300
+ }
249
301
  lines.push(FENCE);
250
302
  let out = lines.join("\n") + "\n";
251
303
  if (v.body.trim().length > 0)
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Public-interface readers — the TypeScript mirror of the READ side of
3
+ * `core/lib/quant/public_interface.dart`. In a subsystem-composed model a
4
+ * variable is exposed to its parent by tagging it under `extra.quant.visibility`
5
+ * (`'input'` | `'output'`; absent ⇒ private); a parent drives a child's public
6
+ * input via `inputBindings` declared on the anchor node (the node carrying the
7
+ * `subsystem` link). The qualitative plugin only READS these — publishing and
8
+ * binding are quant authoring, which lives in the app/CLI/MCP. Both fields are
9
+ * preserved verbatim on write (unknown-key carry, format rule §3), so reading
10
+ * them never risks the data.
11
+ */
12
+ import { VariableFile } from "./types";
13
+ export type Visibility = "input" | "output";
14
+ /** `'input'` | `'output'` | `null` (private — the default). */
15
+ export declare function quantVisibility(v: VariableFile): Visibility | null;
16
+ export declare function isPublicInput(v: VariableFile): boolean;
17
+ export declare function isPublicOutput(v: VariableFile): boolean;
18
+ /** A parent-side wiring that drives a linked child's public input. */
19
+ export interface InputBinding {
20
+ /** Qualifier of the linked child (link alias or model name). */
21
+ child: string;
22
+ /** Child public-input label or id. */
23
+ target: string;
24
+ /** Parent-scope expression that drives the input. */
25
+ expr: string;
26
+ }
27
+ /** Input bindings declared on this anchor node (empty when none). */
28
+ export declare function quantInputBindings(v: VariableFile): InputBinding[];
29
+ /** A child model's public interface as resolved through a parent's subsystem link. */
30
+ export interface ChildInterface {
31
+ /** Display qualifier — the link alias, else the child model name. */
32
+ qualifier: string;
33
+ /** Labels of the child's public outputs (offered to the parent). */
34
+ outputs: string[];
35
+ /** Labels of the child's public inputs (the parent may drive). */
36
+ inputs: string[];
37
+ }
38
+ /**
39
+ * A child output as a qualified reference: bracket form when the label has a
40
+ * space (`ReworkCycle.[Defect Rate]`), bare otherwise. Mirrors the app's `_ref`
41
+ * in `subsystem_interface_section.dart`.
42
+ */
43
+ export declare function qualifiedRef(qualifier: string, label: string): string;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Public-interface readers — the TypeScript mirror of the READ side of
3
+ * `core/lib/quant/public_interface.dart`. In a subsystem-composed model a
4
+ * variable is exposed to its parent by tagging it under `extra.quant.visibility`
5
+ * (`'input'` | `'output'`; absent ⇒ private); a parent drives a child's public
6
+ * input via `inputBindings` declared on the anchor node (the node carrying the
7
+ * `subsystem` link). The qualitative plugin only READS these — publishing and
8
+ * binding are quant authoring, which lives in the app/CLI/MCP. Both fields are
9
+ * preserved verbatim on write (unknown-key carry, format rule §3), so reading
10
+ * them never risks the data.
11
+ */
12
+ function quantBlock(v) {
13
+ const q = v.extra["quant"];
14
+ return q && typeof q === "object" ? q : {};
15
+ }
16
+ /** `'input'` | `'output'` | `null` (private — the default). */
17
+ export function quantVisibility(v) {
18
+ const s = String(quantBlock(v)["visibility"] ?? "").trim();
19
+ return s === "input" || s === "output" ? s : null;
20
+ }
21
+ export function isPublicInput(v) {
22
+ return quantVisibility(v) === "input";
23
+ }
24
+ export function isPublicOutput(v) {
25
+ return quantVisibility(v) === "output";
26
+ }
27
+ /** Input bindings declared on this anchor node (empty when none). */
28
+ export function quantInputBindings(v) {
29
+ const raw = quantBlock(v)["inputBindings"];
30
+ if (!Array.isArray(raw))
31
+ return [];
32
+ const out = [];
33
+ for (const e of raw) {
34
+ if (e && typeof e === "object") {
35
+ const m = e;
36
+ out.push({
37
+ child: String(m["child"] ?? ""),
38
+ target: String(m["target"] ?? ""),
39
+ expr: String(m["expr"] ?? ""),
40
+ });
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+ /**
46
+ * A child output as a qualified reference: bracket form when the label has a
47
+ * space (`ReworkCycle.[Defect Rate]`), bare otherwise. Mirrors the app's `_ref`
48
+ * in `subsystem_interface_section.dart`.
49
+ */
50
+ export function qualifiedRef(qualifier, label) {
51
+ return `${qualifier}.${label.includes(" ") ? `[${label}]` : label}`;
52
+ }
@@ -0,0 +1,36 @@
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 { DetectedLoop, ModelManifest, VariableFile } from "./types";
12
+ export interface CanvasLoopDiscoveryLimits {
13
+ readonly maxLoops: number;
14
+ readonly maxEdgeVisits: number;
15
+ readonly maxDepth: number;
16
+ }
17
+ export declare const USER_FACING_CANVAS_LOOP_LIMITS: CanvasLoopDiscoveryLimits;
18
+ export interface CanvasLoopDiscoveryOptions {
19
+ readonly manifest?: ModelManifest;
20
+ readonly limits?: CanvasLoopDiscoveryLimits;
21
+ }
22
+ export interface CanvasLoopDiscoveryResult {
23
+ readonly loops: DetectedLoop[];
24
+ readonly analysisError: string | null;
25
+ }
26
+ /** Stable first-class SFD pipe-leg identity. */
27
+ export declare function materialPipeLegId(flowId: string, stockId: string): string;
28
+ /** Stable base id for a non-persistent CLD projection of a material effect. */
29
+ export declare function materialProjectionEdgeId(flowId: string, stockId: string): string;
30
+ /**
31
+ * Discover complete canvas-resolvable quantitative cycles and merge them with
32
+ * exact qualitative counterparts. Declared loops are first resolved from their
33
+ * own authored topology, so a quantitative error never suppresses their honest
34
+ * CLD/SFD representation.
35
+ */
36
+ export declare function discoverCanvasLoops(nodes: readonly VariableFile[], qualitativeLoops: readonly DetectedLoop[], options?: CanvasLoopDiscoveryOptions): CanvasLoopDiscoveryResult;