@neoloopy/cld-canvas 0.1.3 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +30 -3
- package/dist/engine/engine.d.ts +26 -4
- package/dist/engine/exporters.d.ts +1 -1
- package/dist/engine/exporters.js +1 -1
- package/dist/engine/loopGraph.d.ts +8 -4
- package/dist/engine/loopGraph.js +0 -0
- package/dist/engine/loopKey.d.ts +9 -0
- package/dist/engine/loopKey.js +14 -0
- package/dist/engine/nativeEngine.d.ts +10 -2
- package/dist/engine/nativeEngine.js +226 -70
- package/dist/engine/noteCodec.js +57 -5
- package/dist/engine/publicInterface.d.ts +43 -0
- package/dist/engine/publicInterface.js +52 -0
- package/dist/engine/quantCanvasLoops.d.ts +36 -0
- package/dist/engine/quantCanvasLoops.js +694 -0
- package/dist/engine/sfd.d.ts +47 -0
- package/dist/engine/sfd.js +320 -0
- package/dist/engine/specHash.js +4 -0
- package/dist/engine/types.d.ts +54 -3
- package/dist/engine/types.js +66 -5
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/view/geometry.d.ts +35 -1
- package/dist/view/geometry.js +174 -7
- package/dist/view/painter.d.ts +10 -1
- package/dist/view/painter.js +243 -15
- package/dist/view/sceneCache.d.ts +2 -1
- package/dist/view/sceneCache.js +35 -13
- package/package.json +12 -2
|
@@ -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,
|
|
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
|
|
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 {
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
387
|
+
const p = pos.get(n.id);
|
|
388
|
+
if (p) {
|
|
367
389
|
await this.writeNote(folder, n, {
|
|
368
390
|
...n,
|
|
369
|
-
|
|
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.
|
|
449
|
-
//
|
|
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
|
|
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
|
|
468
|
-
const type = l
|
|
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[
|
|
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
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
750
|
-
const
|
|
751
|
-
liveByKey.set(
|
|
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
|
|
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
|
|
790
|
-
const
|
|
791
|
-
|
|
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
|
|
1100
|
-
|
|
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
|
-
|
|
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 };
|
package/dist/engine/noteCodec.js
CHANGED
|
@@ -79,10 +79,18 @@ function tagsFrom(v) {
|
|
|
79
79
|
function rawScalar(frontmatter, key) {
|
|
80
80
|
if (frontmatter === null)
|
|
81
81
|
return undefined;
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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;
|