skynet-graph 1.2.0

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.
Files changed (213) hide show
  1. package/CONTRIBUTING.md +22 -0
  2. package/LICENSE +661 -0
  3. package/README.md +245 -0
  4. package/bin/sg +20 -0
  5. package/concepts/README.md +23 -0
  6. package/concepts/_substrate/Claim/Confidence/Trusted.json +6 -0
  7. package/concepts/_substrate/Claim/Confidence.json +10 -0
  8. package/concepts/_substrate/Claim/Freshness/Stale.json +7 -0
  9. package/concepts/_substrate/Claim/Freshness.json +5 -0
  10. package/concepts/_substrate/Claim/Verification/Refuted.json +6 -0
  11. package/concepts/_substrate/Claim/Verification.json +6 -0
  12. package/concepts/_substrate/Claim.json +4 -0
  13. package/concepts/_substrate/Frontier/InBeam.json +6 -0
  14. package/concepts/_substrate/Frontier/Stuck.json +6 -0
  15. package/concepts/_substrate/Frontier.json +5 -0
  16. package/concepts/_substrate/Intake/ToTask.json +22 -0
  17. package/concepts/_substrate/Intake/Typed.json +10 -0
  18. package/concepts/_substrate/Intake.json +27 -0
  19. package/concepts/_substrate/Task/Answer.json +12 -0
  20. package/concepts/_substrate/Task/Complexity/Atomic.json +5 -0
  21. package/concepts/_substrate/Task/Complexity/Compound/Expansion.json +7 -0
  22. package/concepts/_substrate/Task/Complexity/Compound.json +6 -0
  23. package/concepts/_substrate/Task/Complexity.json +6 -0
  24. package/concepts/_substrate/Task/EvalComplexity.json +13 -0
  25. package/concepts/_substrate/Task/ReportUp.json +6 -0
  26. package/concepts/_substrate/Task/Rollup.json +10 -0
  27. package/concepts/_substrate/Task.json +5 -0
  28. package/concepts/clinical/Diagnosis/Medication.json +6 -0
  29. package/concepts/clinical/Diagnosis.json +15 -0
  30. package/concepts/clinical/Observation/LabValue/OutOfRange.json +6 -0
  31. package/concepts/clinical/Observation/LabValue.json +5 -0
  32. package/concepts/clinical/Observation.json +6 -0
  33. package/concepts/common/Document/pathBasket.json +6 -0
  34. package/concepts/common/Document.json +3 -0
  35. package/concepts/common/Edge/Distance.json +13 -0
  36. package/concepts/common/Edge/Stay/LongStay.json +8 -0
  37. package/concepts/common/Edge/Stay.json +8 -0
  38. package/concepts/common/Edge/Travel/LongTravel.json +16 -0
  39. package/concepts/common/Edge/Travel/ShortTravel.json +11 -0
  40. package/concepts/common/Edge/Travel/targetNode.json +7 -0
  41. package/concepts/common/Edge/Travel.json +7 -0
  42. package/concepts/common/Edge.json +3 -0
  43. package/concepts/common/Vertice.json +3 -0
  44. package/concepts/supply/Fulfillment.json +10 -0
  45. package/concepts/supply/Inventory/Reorder.json +7 -0
  46. package/concepts/supply/Inventory.json +5 -0
  47. package/concepts/supply/Procurement/SupplierConfirm.json +6 -0
  48. package/concepts/supply/Procurement.json +8 -0
  49. package/concepts/supply/Transport.json +5 -0
  50. package/doc/API.md +556 -0
  51. package/doc/CAPABILITIES.md +373 -0
  52. package/doc/MODELISATION.md +511 -0
  53. package/doc/architecture.md +267 -0
  54. package/doc/concept-as-graph.md +379 -0
  55. package/doc/concept-learning.md +168 -0
  56. package/doc/creative-loop-map.md +202 -0
  57. package/doc/original-2016-doc.md +148 -0
  58. package/doc/usage.md +501 -0
  59. package/lib/authoring/abstract.js +401 -0
  60. package/lib/authoring/abstraction.js +190 -0
  61. package/lib/authoring/adapt.js +393 -0
  62. package/lib/authoring/ancestry.js +216 -0
  63. package/lib/authoring/author.js +147 -0
  64. package/lib/authoring/bounded-merge.js +57 -0
  65. package/lib/authoring/canon.js +158 -0
  66. package/lib/authoring/clock.js +123 -0
  67. package/lib/authoring/combinator.js +92 -0
  68. package/lib/authoring/compete.js +76 -0
  69. package/lib/authoring/compose-hotspot.js +314 -0
  70. package/lib/authoring/compress.js +279 -0
  71. package/lib/authoring/concept-net.js +254 -0
  72. package/lib/authoring/concepts.js +66 -0
  73. package/lib/authoring/context-project.js +277 -0
  74. package/lib/authoring/contract.js +476 -0
  75. package/lib/authoring/corpus-pack.js +125 -0
  76. package/lib/authoring/cost-probe.js +86 -0
  77. package/lib/authoring/crystallize.js +555 -0
  78. package/lib/authoring/dag-decompose.js +256 -0
  79. package/lib/authoring/dataset-adapter.js +253 -0
  80. package/lib/authoring/debug-provider.js +92 -0
  81. package/lib/authoring/decompose.js +343 -0
  82. package/lib/authoring/emittability.js +236 -0
  83. package/lib/authoring/equilibrium.js +160 -0
  84. package/lib/authoring/extract.js +153 -0
  85. package/lib/authoring/forest.js +55 -0
  86. package/lib/authoring/forge-fallback.js +116 -0
  87. package/lib/authoring/givens.js +121 -0
  88. package/lib/authoring/glossary.js +177 -0
  89. package/lib/authoring/grammar-graph.js +191 -0
  90. package/lib/authoring/granularity.js +80 -0
  91. package/lib/authoring/graph-net.js +108 -0
  92. package/lib/authoring/ground.js +148 -0
  93. package/lib/authoring/higher-order.js +85 -0
  94. package/lib/authoring/hotspot.js +132 -0
  95. package/lib/authoring/hysteresis.js +85 -0
  96. package/lib/authoring/index.js +106 -0
  97. package/lib/authoring/lattice-morphism.js +61 -0
  98. package/lib/authoring/lattice-pack.js +148 -0
  99. package/lib/authoring/leaf-io.js +77 -0
  100. package/lib/authoring/library.js +156 -0
  101. package/lib/authoring/lifecycle.js +92 -0
  102. package/lib/authoring/loop.js +193 -0
  103. package/lib/authoring/master-loop.js +132 -0
  104. package/lib/authoring/mdl.js +209 -0
  105. package/lib/authoring/memo-stability.js +122 -0
  106. package/lib/authoring/method-explorer.js +138 -0
  107. package/lib/authoring/method-pack.js +181 -0
  108. package/lib/authoring/method.js +202 -0
  109. package/lib/authoring/mine.js +510 -0
  110. package/lib/authoring/mount.js +119 -0
  111. package/lib/authoring/negotiate.js +100 -0
  112. package/lib/authoring/parametric.js +139 -0
  113. package/lib/authoring/reaggregate.js +75 -0
  114. package/lib/authoring/rebalance.js +157 -0
  115. package/lib/authoring/recall.js +135 -0
  116. package/lib/authoring/registry.js +350 -0
  117. package/lib/authoring/relearn.js +147 -0
  118. package/lib/authoring/retention.js +89 -0
  119. package/lib/authoring/segment-proxy.js +114 -0
  120. package/lib/authoring/serve-leaf.js +64 -0
  121. package/lib/authoring/slot-aware-serve.js +77 -0
  122. package/lib/authoring/sound-invoke.js +59 -0
  123. package/lib/authoring/split-serve.js +74 -0
  124. package/lib/authoring/ste.js +239 -0
  125. package/lib/authoring/stock.js +101 -0
  126. package/lib/authoring/store.js +94 -0
  127. package/lib/authoring/supervise.js +138 -0
  128. package/lib/authoring/support.js +131 -0
  129. package/lib/authoring/task-mirror.js +51 -0
  130. package/lib/authoring/typed-loop.js +160 -0
  131. package/lib/authoring/validate.js +569 -0
  132. package/lib/authoring/widen.js +88 -0
  133. package/lib/combos/appliance.js +189 -0
  134. package/lib/combos/critique.js +377 -0
  135. package/lib/combos/defaults.js +86 -0
  136. package/lib/combos/durable-runner.js +115 -0
  137. package/lib/combos/forge.js +209 -0
  138. package/lib/combos/index.js +86 -0
  139. package/lib/combos/learning-library.js +253 -0
  140. package/lib/combos/mixture-serve.js +193 -0
  141. package/lib/combos/plan-loop.js +192 -0
  142. package/lib/combos/proxy-cache.js +272 -0
  143. package/lib/combos/self-mod.js +94 -0
  144. package/lib/durable/audit.js +102 -0
  145. package/lib/durable/checkpoint-store.js +550 -0
  146. package/lib/durable/fold.js +62 -0
  147. package/lib/durable/interpreter.js +242 -0
  148. package/lib/durable/xlate.js +185 -0
  149. package/lib/graph/Graph.js +2486 -0
  150. package/lib/graph/expr.js +307 -0
  151. package/lib/graph/index.js +26 -0
  152. package/lib/graph/log.js +134 -0
  153. package/lib/graph/objects/Concept.js +382 -0
  154. package/lib/graph/objects/Entity.js +503 -0
  155. package/lib/graph/objects/Node.js +57 -0
  156. package/lib/graph/objects/PathMap.js +557 -0
  157. package/lib/graph/objects/Segment.js +100 -0
  158. package/lib/graph/tasks/stabilize.js +59 -0
  159. package/lib/graph/tasks/taskflow.js +247 -0
  160. package/lib/index.js +122 -0
  161. package/lib/load.js +132 -0
  162. package/lib/providers/backends.js +117 -0
  163. package/lib/providers/borderline.js +129 -0
  164. package/lib/providers/cache.js +226 -0
  165. package/lib/providers/canonicalize.js +198 -0
  166. package/lib/providers/constat.js +107 -0
  167. package/lib/providers/geo.js +53 -0
  168. package/lib/providers/index.js +116 -0
  169. package/lib/providers/intake.js +263 -0
  170. package/lib/providers/llm-local.js +88 -0
  171. package/lib/providers/llm.js +236 -0
  172. package/lib/providers/local-host.js +197 -0
  173. package/lib/providers/merge-consistency.js +162 -0
  174. package/lib/providers/nogood.js +149 -0
  175. package/lib/providers/reason-loop.js +223 -0
  176. package/lib/providers/semiring.js +285 -0
  177. package/lib/providers/solver-fork.js +163 -0
  178. package/lib/providers/stats.js +228 -0
  179. package/lib/providers/verify.js +120 -0
  180. package/lib/runtime/index.js +135 -0
  181. package/lib/runtime/invoke-pool.js +92 -0
  182. package/lib/runtime/protocol.js +210 -0
  183. package/lib/runtime/transport-socket.js +77 -0
  184. package/lib/runtime/worker-entry.js +31 -0
  185. package/lib/sg/cli.js +601 -0
  186. package/lib/sg/log-sinks.js +214 -0
  187. package/lib/sg/mcp.js +395 -0
  188. package/lib/sg/proxy-run.js +66 -0
  189. package/lib/sg/serve-demo.js +185 -0
  190. package/lib/sg/serve.js +205 -0
  191. package/lib/sg/trace.js +118 -0
  192. package/lib/studio/protocol.js +45 -0
  193. package/lib/studio/public/app.js +310 -0
  194. package/lib/studio/public/components/ConceptEditor.js +43 -0
  195. package/lib/studio/public/components/ConceptTree.js +43 -0
  196. package/lib/studio/public/components/CorpusPanel.js +68 -0
  197. package/lib/studio/public/components/ForkTree.js +36 -0
  198. package/lib/studio/public/components/GrammarGraph.js +99 -0
  199. package/lib/studio/public/components/GraphCanvas.js +156 -0
  200. package/lib/studio/public/components/Inspector.js +48 -0
  201. package/lib/studio/public/components/LearningPanel.js +85 -0
  202. package/lib/studio/public/components/PromptConsole.js +46 -0
  203. package/lib/studio/public/components/ProviderTrace.js +39 -0
  204. package/lib/studio/public/components/SessionSplit.js +47 -0
  205. package/lib/studio/public/components/TilingOverlay.js +40 -0
  206. package/lib/studio/public/components/Timeline.js +57 -0
  207. package/lib/studio/public/index.html +26 -0
  208. package/lib/studio/public/styles.css +185 -0
  209. package/lib/studio/public/ws.js +58 -0
  210. package/lib/studio/server.js +130 -0
  211. package/lib/studio/session.js +397 -0
  212. package/lib/studio/studio.js +149 -0
  213. package/package.json +42 -0
@@ -0,0 +1,193 @@
1
+ /*
2
+ * Copyright 2026 Nathanael Braun
3
+ * @author : Nathanael BRAUN <pp9ping@gmail.com>
4
+ *
5
+ * This program is free software: you can redistribute it and/or modify
6
+ * it under the terms of the GNU Affero General Public License as published by
7
+ * the Free Software Foundation, either version 3 of the License, or
8
+ * (at your option) any later version.
9
+ *
10
+ * This program is distributed in the hope that it will be useful,
11
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ * GNU Affero General Public License for more details.
14
+ *
15
+ * You should have received a copy of the GNU Affero General Public License
16
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ */
18
+ 'use strict';
19
+ /**
20
+ * The "answer a (huge) prompt" loop, as a reusable concept set + helpers.
21
+ *
22
+ * DECOMPOSE (reactive concepts): a root segment (start->goal) carrying the prompt
23
+ * Task require Segment every segment is a Task
24
+ * EvalComplexity require Task -> Atomic | NeedsSplit (depth floor forces Atomic)
25
+ * Expand require NeedsSplit -> child sub-step segments + expandedInto
26
+ * Answer require Atomic -> a leaf answer
27
+ *
28
+ * SYNTHESIZE — two regimes, same bounded rollup:
29
+ *
30
+ * (a) DETERMINISTIC post-pass (`synthesize`, the one-shot default): a post-order
31
+ * walk leaf->root. Race-free, simple, O(V+E); reactivity buys nothing when
32
+ * everything is cold. Use with `loopConceptTree`.
33
+ *
34
+ * (b) REACTIVE concepts (`reactiveLoopConceptTree`, the live/standing regime): each
35
+ * answered segment appends its id to its parent's grow-only `answeredBy` array via
36
+ * the race-free `{__push}` primitive; a `Rollup` concept gated on
37
+ * `ensure:["$answeredBy.length == $expandedInto.length"]` (a monotone G-Set
38
+ * cardinality predicate — §5.2) fires EXACTLY ONCE when the last child reports,
39
+ * reads the children's bounded answers (ordered by `expandedInto`) and writes the
40
+ * parent's answer. Synthesis happens IN stabilization, bottom-up, with ZERO core
41
+ * change (the earlier `+=1` counter would have raced — Entity arrays REPLACE on
42
+ * update; `{__push}` appends at serialized apply-time). The completion gate keys on
43
+ * a discrete `.length`, never prose (the #1 typed-fact spine in action).
44
+ * KNOWN LIMIT: this is reactive on COMPLETION; re-rolling on a live leaf-answer
45
+ * CONTENT change needs per-child answer-following (the aggregation gap — roadmap #5).
46
+ *
47
+ * Content (eval/expand/answer/rollup) is INJECTED so the same loop runs with
48
+ * deterministic functions (tests) or an LLM (run-prompt.js).
49
+ */
50
+
51
+ // ---- the concept set (the "library of concept<->prompt pairs", content-agnostic) ----
52
+ const loopConceptTree = {
53
+ childConcepts: {
54
+ Task: {
55
+ _id: 'Task', _name: 'Task', require: 'Segment',
56
+ childConcepts: {
57
+ EvalComplexity: { _id: 'EvalComplexity', _name: 'EvalComplexity', require: ['Task'], provider: ['AI::evalComplexity'] },
58
+ Expand : { _id: 'Expand', _name: 'Expand', require: ['Task', 'NeedsSplit'], provider: ['AI::expand'] },
59
+ Answer : { _id: 'Answer', _name: 'Answer', require: ['Task', 'Atomic'], provider: ['AI::answer'] }
60
+ }
61
+ }
62
+ }
63
+ };
64
+
65
+ // The reactive variant: the same decompose concepts PLUS the two reactive-synthesis
66
+ // concepts. `ReportUp` (every answered non-root segment) appends its id to its parent's
67
+ // `answeredBy`; `Rollup` (every expanded segment) fires once its children all reported.
68
+ var reactiveLoopConceptTree = (function () {
69
+ var t = JSON.parse(JSON.stringify(loopConceptTree));
70
+ Object.assign(t.childConcepts.Task.childConcepts, {
71
+ ReportUp: { _id: 'ReportUp', _name: 'ReportUp', require: ['Task', 'Answered', 'parentSeg'], provider: ['AI::reportUp'] },
72
+ Rollup : { _id: 'Rollup', _name: 'Rollup', require: ['Task', 'expandedInto'], ensure: ['$answeredBy.length == $expandedInto.length'], provider: ['AI::rollup'] }
73
+ });
74
+ return t;
75
+ })();
76
+
77
+ /**
78
+ * The decomposition providers. Inject content functions:
79
+ * evalFn(scope) -> { atomic: bool } (depth floor still applies)
80
+ * expandFn(scope) -> [{ name, description? }, ...] ordered sub-steps
81
+ * answerFn(scope) -> string a leaf answer
82
+ * rollupFn(seg, childAnswers[]) -> string bounded parent answer (reactive regime)
83
+ * Options: maxDepth (depth floor), maxBranch (cap sub-steps).
84
+ */
85
+ function makeDecomposeProviders( opts ) {
86
+ opts = opts || {};
87
+ var maxDepth = opts.maxDepth == null ? 2 : opts.maxDepth,
88
+ maxBranch = opts.maxBranch || 4,
89
+ evalFn = opts.evalFn || function () { return { atomic: true }; },
90
+ expandFn = opts.expandFn || function () { return []; },
91
+ answerFn = opts.answerFn || function () { return ''; },
92
+ rollupFn = opts.rollupFn || function ( seg, kids ) { return kids.join(''); };
93
+
94
+ // content fns may be sync OR async (LLM) — Promise.resolve handles both
95
+ return {
96
+ AI: {
97
+ evalComplexity: function ( graph, concept, scope, argz, cb ) {
98
+ var depth = scope._.depth || 0;
99
+ if ( depth >= maxDepth ) return cb(null, { $_id: '_parent', EvalComplexity: true, Atomic: true });// depth floor
100
+ Promise.resolve(evalFn(scope)).then(function ( r ) {
101
+ var facts = { $_id: '_parent', EvalComplexity: true };
102
+ facts[(r && r.atomic) ? 'Atomic' : 'NeedsSplit'] = true;
103
+ cb(null, facts);
104
+ }).catch(function ( e ) { cb(null, { $_id: '_parent', EvalComplexity: true, Atomic: true, llmError: e.message }); });
105
+ },
106
+ expand: function ( graph, concept, scope, argz, cb ) {
107
+ Promise.resolve(expandFn(scope)).then(function ( raw ) {
108
+ var steps = (raw || []).slice(0, maxBranch);
109
+ if ( !steps.length ) return cb(null, { $_id: '_parent', Expand: true, Atomic: true });// nothing to split -> leaf
110
+ var base = scope._._id,
111
+ origin = scope._.originNode,
112
+ target = scope._.targetNode,
113
+ depth = (scope._.depth || 0) + 1,
114
+ childIds = steps.map(function ( _, i ) { return base + '_s' + i; }),
115
+ tpl = [{ $_id: '_parent', Expand: true, expandedInto: childIds }],
116
+ prev = origin;
117
+ steps.forEach(function ( st, i ) {
118
+ var last = i === steps.length - 1,
119
+ tnode = last ? target : base + '_m' + i;
120
+ if ( !last ) tpl.push({ _id: tnode, Node: true, label: st.name });
121
+ tpl.push({
122
+ _id: childIds[i], Segment: true, originNode: prev, targetNode: tnode,
123
+ depth: depth, parentSeg: base, label: st.name, description: st.description
124
+ });
125
+ prev = tnode;
126
+ });
127
+ cb(null, tpl);
128
+ }).catch(function ( e ) { cb(null, { $_id: '_parent', Expand: true, Atomic: true, llmError: e.message }); });
129
+ },
130
+ answer: function ( graph, concept, scope, argz, cb ) {
131
+ // MUST set its own name (Answer:true) as the self-flag, else the engine
132
+ // keeps seeing the concept as applicable and re-fires it forever.
133
+ Promise.resolve(answerFn(scope)).then(function ( a ) {
134
+ cb(null, { $_id: '_parent', Answer: true, Answered: true, answer: a });
135
+ }).catch(function ( e ) { cb(null, { $_id: '_parent', Answer: true, answer: '(error)', llmError: e.message }); });
136
+ },
137
+ // reactive synthesis: an answered child appends its OWN id to the parent's
138
+ // grow-only `answeredBy` (race-free `{__push}` — distinct ids, no lost update).
139
+ reportUp: function ( graph, concept, scope, argz, cb ) {
140
+ var selfId = scope._._id, parentId = scope._.parentSeg;
141
+ cb(null, [
142
+ { $$_id: parentId, answeredBy: { __push: selfId } },
143
+ { $_id: '_parent', ReportUp: true }
144
+ ]);
145
+ },
146
+ // fires once the completion gate holds; reads children's answers in
147
+ // `expandedInto` order (deterministic) and writes the BOUNDED parent answer.
148
+ rollup: function ( graph, concept, scope, argz, cb ) {
149
+ var kids = (scope._.expandedInto || []).map(function ( id ) {
150
+ var e = graph.getEtty(id);
151
+ return e && e._.answer;
152
+ });
153
+ Promise.resolve(rollupFn(scope._, kids)).then(function ( ans ) {
154
+ cb(null, { $_id: '_parent', Rollup: true, Answered: true, answer: ans });
155
+ }).catch(function ( e ) { cb(null, { $_id: '_parent', Rollup: true, Answered: true, answer: '(error)', llmError: e.message }); });
156
+ }
157
+ }
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Bottom-up synthesis: post-order walk from rootId; each non-leaf segment's answer
163
+ * is rollupFn(segmentFacts, childAnswers) (which MUST be bounded — a summary, not a
164
+ * concatenation, in real use). Writes the answer back onto each segment (so it's in
165
+ * the graph + traced) and returns the root answer. Race-free (deterministic walk).
166
+ *
167
+ * @param graph
168
+ * @param rootId the root segment id
169
+ * @param rollupFn (segFacts, childAnswers[]) -> answer string
170
+ */
171
+ function synthesize( graph, rootId, rollupFn ) {
172
+ // async: rollupFn may be an LLM call. Post-order, sequential (leaves first).
173
+ async function answerOf( segId ) {
174
+ var o = graph.getEtty(segId);
175
+ if ( !o ) return undefined;
176
+ var e = o._,
177
+ kids = e.expandedInto || [];
178
+ if ( !kids.length ) return e.answer;// leaf: already answered by the Answer concept
179
+ var childAnswers = [];
180
+ for ( var i = 0; i < kids.length; i++ ) childAnswers.push(await answerOf(kids[i]));
181
+ var ans = await rollupFn(e, childAnswers);
182
+ graph.pushMutation({ $$_id: segId, answer: ans, Synthesized: true }, segId);
183
+ return ans;
184
+ }
185
+ return answerOf(rootId);
186
+ }
187
+
188
+ module.exports = {
189
+ loopConceptTree : loopConceptTree,
190
+ reactiveLoopConceptTree: reactiveLoopConceptTree,
191
+ makeDecomposeProviders : makeDecomposeProviders,
192
+ synthesize : synthesize
193
+ };
@@ -0,0 +1,132 @@
1
+ /*
2
+ * Copyright 2026 Nathanael Braun
3
+ * @author : Nathanael BRAUN <pp9ping@gmail.com>
4
+ *
5
+ * AGPL-3.0-or-later. See <https://www.gnu.org/licenses/>.
6
+ */
7
+ 'use strict';
8
+ /**
9
+ * master-loop — the ALWAYS-ON MASTER LOOP (host-side controller, ZERO-CORE). M1 of the productization
10
+ * campaign; the standing controller that wires the master-graph supervisor ARMS into ONE cost-ordered loop
11
+ * (master-graph study §2.2 — the value-of-computation ladder). Per incoming problem it climbs the ladder
12
+ * and takes the FIRST arm that resolves at acceptable cost:
13
+ *
14
+ * MATCH — exact cache hit on the K1 signature → 0 model calls (the warm library).
15
+ * RETRIEVE — fuzzy recall (U5) → typed VERIFY: full → replay (0); → 0 (full) or partial cost
16
+ * partial → mount the shared skeleton + RE-FORGE only the diff.
17
+ * FORGE — the expensive path (fork + LLM + crystallize into the → full cost; warms the library.
18
+ * library); the mount policy (U2) picks the regime.
19
+ * ESCALATE — a method pinned to the K1 floor (deopted K times) always → full cost, never cached.
20
+ * re-forges / stays in the LLM.
21
+ *
22
+ * DRIFT: when a premise changes, `drift(problem)` invalidates the method's cache entry and records a deopt
23
+ * (U2 mount-rank descends toward the ESCALATE floor → the adapt loop terminates). Re-aggregation of a
24
+ * derived summary is the caller's `reaggregate` arm (U3). The controller is DOMAIN-AGNOSTIC: the caller
25
+ * injects `signature`, `forge`, and (optionally) `reForge`; everything else is the built library machinery.
26
+ *
27
+ * const loop = createMasterLoop({ signature, forge, reForge, mount: createMountController() });
28
+ * const r = await loop.solve(problem); // { result, arm, regime, cost }
29
+ * loop.drift(problem); // a premise changed → invalidate + deopt
30
+ */
31
+ const { digest } = require('../providers/canonicalize.js');
32
+ const { createRecallIndex, recallAndVerify } = require('./recall.js');
33
+ const { createMountController } = require('./mount.js');
34
+
35
+ function canon( x ) {
36
+ if ( x === undefined ) return 'null';
37
+ if ( x === null || typeof x !== 'object' ) return JSON.stringify(x);
38
+ if ( Array.isArray(x) ) return '[' + x.map(canon).join(',') + ']';
39
+ return '{' + Object.keys(x).sort().map(( k ) => JSON.stringify(k) + ':' + canon(x[k])).join(',') + '}';
40
+ }
41
+
42
+ /**
43
+ * @param opts.signature (problem) => { structure, content } the typed K1 signature (required).
44
+ * STRUCTURE defines the method class (mount/deopt key); CONTENT is what's derived.
45
+ * @param opts.forge async (problem, ctx) => { result, cost, signals? } the expensive path (required).
46
+ * @param opts.reForge async (problem, candidate, reForgeKeys, ctx) => { result, cost } partial re-forge
47
+ * of only the differing content on a recalled skeleton (default: falls back to forge).
48
+ * @param opts.cache a Map-like { has,get,set,delete } (default: in-memory Map). Pass a disk-backed
49
+ * store (M2) to make the library survive restarts.
50
+ * @param opts.index a recall index (default: createRecallIndex()).
51
+ * @param opts.mount a mount controller (default: createMountController()).
52
+ * @param opts.recallK how many candidates recall proposes (default 3).
53
+ * @param opts.signals (problem, methodId) => mount signals (reliability/hitRate/depth/readOnlyFrontier).
54
+ */
55
+ function createMasterLoop( opts ) {
56
+ opts = opts || {};
57
+ const signature = opts.signature || (( p ) => ({ structure: p, content: {} }));
58
+ const forge = opts.forge;
59
+ const reForge = opts.reForge || ((( p, c, ks, ctx ) => forge(p, ctx))); // no partial path → forge fully
60
+ const cache = opts.cache || new Map();
61
+ const index = opts.index || createRecallIndex();
62
+ const mount = opts.mount || createMountController();
63
+ const recallK = opts.recallK || 3;
64
+ const signalsOf = opts.signals || (() => ({}));
65
+ if ( typeof forge !== 'function' ) throw new Error('master-loop: opts.forge is required');
66
+
67
+ const stats = { match: 0, recallFull: 0, recallPartial: 0, forge: 0, escalate: 0, cost: 0, calls: 0 };
68
+ const keyOf = ( sig ) => digest({ s: canon(sig.structure), c: canon(sig.content) });
69
+ const idOf = ( sig ) => digest({ s: canon(sig.structure) }); // method CLASS id (mount/deopt key)
70
+ const hits = {}; // idOf -> {hit, total} (a cheap hit-rate signal for the mount policy)
71
+
72
+ function bump( id, hit ) { const h = hits[id] || (hits[id] = { hit: 0, total: 0 }); h.total++; if ( hit ) h.hit++; }
73
+ function sig2signals( problem, id ) { const h = hits[id] || { hit: 0, total: 0 }; return Object.assign({ hitRate: h.total ? h.hit / h.total : 0 }, signalsOf(problem, id)); }
74
+
75
+ async function solve( problem ) {
76
+ stats.calls++;
77
+ const sig = signature(problem), key = keyOf(sig), id = idOf(sig);
78
+
79
+ // ── ESCALATE floor: a method deopted to the floor never replays — always re-forge / stay in the LLM.
80
+ if ( mount.regimeOf(id) === 'escalate' ) {
81
+ stats.escalate++; bump(id, false);
82
+ const r = await forge(problem, { id, sig, regime: 'escalate' });
83
+ stats.cost += (r.cost || 0);
84
+ return { result: r.result, arm: 'escalate', regime: 'escalate', cost: r.cost || 0 };
85
+ }
86
+
87
+ // ── MATCH: exact cache hit on the K1 signature → 0 model calls.
88
+ if ( cache.has(key) ) {
89
+ stats.match++; bump(id, true);
90
+ const regime = mount.decide(id, sig2signals(problem, id)).regime;
91
+ return { result: cache.get(key), arm: 'match', regime, cost: 0 };
92
+ }
93
+
94
+ // ── RETRIEVE: fuzzy recall → typed verify.
95
+ const cand = recallAndVerify(index, sig, recallK);
96
+ if ( cand && cand.verdict.mode === 'full' ) {
97
+ stats.recallFull++; bump(id, true); cache.set(key, cand.method);
98
+ const regime = mount.decide(id, sig2signals(problem, id)).regime;
99
+ return { result: cand.method, arm: 'recall-full', regime, cost: 0 };
100
+ }
101
+ if ( cand && cand.verdict.mode === 'partial' ) {
102
+ stats.recallPartial++; bump(id, false);
103
+ const r = await reForge(problem, cand, cand.verdict.reForge, { id, sig });
104
+ stats.cost += (r.cost || 0);
105
+ cache.set(key, r.result); index.add(sig, r.result);
106
+ const regime = mount.decide(id, sig2signals(problem, id)).regime;
107
+ return { result: r.result, arm: 'recall-partial', regime, cost: r.cost || 0, reForged: cand.verdict.reForge };
108
+ }
109
+
110
+ // ── FORGE: the expensive path; warms the library + the recall index.
111
+ stats.forge++; bump(id, false);
112
+ const r = await forge(problem, { id, sig, regime: 'instance' });
113
+ stats.cost += (r.cost || 0);
114
+ cache.set(key, r.result); index.add(sig, r.result);
115
+ const regime = mount.decide(id, Object.assign(sig2signals(problem, id), r.signals)).regime;
116
+ return { result: r.result, arm: 'forge', regime, cost: r.cost || 0 };
117
+ }
118
+
119
+ /** a premise drifted → invalidate this method (cache AND recall index) + record a deopt (U2 mount-rank
120
+ * descends toward the floor). The next solve RE-DERIVES — it must never recall the stale method. */
121
+ function drift( problem ) {
122
+ const sig = signature(problem), key = keyOf(sig), id = idOf(sig);
123
+ cache.delete(key);
124
+ if ( index.remove ) index.remove(sig); // a drifted premise → re-derive, never replay the invalidated method
125
+ const deopts = mount.recordDeopt(id);
126
+ return { id, deopts, regime: mount.regimeOf(id) };
127
+ }
128
+
129
+ return { solve, drift, stats, cache, index, mount, keyOf, idOf };
130
+ }
131
+
132
+ module.exports = { createMasterLoop };
@@ -0,0 +1,209 @@
1
+ /*
2
+ * Copyright 2026 Nathanael Braun
3
+ * @author : Nathanael BRAUN <pp9ping@gmail.com>
4
+ *
5
+ * This program is free software: you can redistribute it and/or modify
6
+ * it under the terms of the GNU Affero General Public License as published by
7
+ * the Free Software Foundation, either version 3 of the License, or
8
+ * (at your option) any later version.
9
+ *
10
+ * This program is distributed in the hope that it will be useful,
11
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ * GNU Affero General Public License for more details.
14
+ *
15
+ * You should have received a copy of the GNU Affero General Public License
16
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ */
18
+ 'use strict';
19
+ /**
20
+ * mdl — a STATIC, corpus-level, bits-based MDL admission objective for grammar induction
21
+ * (#12, the §4.2-A front-end). The cheap pre-filter/ranker IN FRONT OF the empirical gate
22
+ * (`abstraction.evaluate`), NEVER a replacement.
23
+ *
24
+ * Where `evaluate` BOOTS the engine twice per candidate on ONE seed (grounded but expensive,
25
+ * single-episode, can over-fit a seed), `mdl` is O(corpus) arithmetic with NO boot: it
26
+ * aggregates the co-firing `count` mined across EVERY episode, so it ranks GENERALITY and
27
+ * prunes the many `mineChains` candidates BEFORE any boot. The one-criterion unification
28
+ * (Minton speedup ≡ SEQUITUR rule-utility ≡ smallest-grammar MDL ≡ Lari-Young weights).
29
+ *
30
+ * Two-part code length L = L(library) + L(corpus | library):
31
+ * - L(library) = Σ_c schemaBits(c) — bits to DESCRIBE the grammar
32
+ * - L(corpus|library) = R · log2(N) — bits to PARSE the corpus (R records,
33
+ * each names one of N concepts)
34
+ * Promote a chain A→…→B (length ℓ, co-firing `count`) iff it shrinks total L:
35
+ * savedBits = count·(ℓ−1)·log2(N) each co-firing now names 1 abstract, not ℓ constituents
36
+ * encodeBits = schemaBits(M) the new production added to the library
37
+ * taxBits = R·(log2(N+1) − log2(N)) the Minton/Lari-Young MATCH-COST tax: +1 symbol raises
38
+ * EVERY record's parse cost
39
+ * ΔL = encodeBits + taxBits − savedBits ; admit iff ΔL < 0 (SEQUITUR "used ≥2×" in bits)
40
+ *
41
+ * SOUNDNESS DISCIPLINE: MDL only RANKS / PRE-PRUNES. It cannot verify fixpoint-equivalence or
42
+ * real model-cost, so it must never be the sole admit authority (false-admit risk). Compose it
43
+ * with `abstraction.makeAbstractionGate` as the authority via `composeGates` (MDL rejects
44
+ * cheaply; survivors boot once through the empirical gate).
45
+ *
46
+ * const { rankCandidates, mdlGain, makeMdlGate, composeGates } = require('./mdl');
47
+ * const ranked = rankCandidates(mineChains(records, tree), { tree, records, alphabet });
48
+ * const gate = composeGates(makeMdlGate(ctx), makeAbstractionGate(ctx)); // cheap → authority
49
+ */
50
+ const { eachConcept, refsOf, refKeyOf, templateKeys } = require('./validate.js');
51
+
52
+ var LOG2 = Math.log(2);
53
+ function log2( x ) { return Math.log(x) / LOG2; }
54
+
55
+ // The discrete SYMBOLS a concept's schema spends (the same auditable spine the validator reads:
56
+ // require refs + ensure/assert atoms + applyMutations template keys + the provider name).
57
+ function symbolsOf( schema ) {
58
+ schema = (schema && schema._schema) || schema || {};
59
+ var reqs = refsOf(schema.require, false).map(function ( r ) { return refKeyOf(r).key; });
60
+ var ens = refsOf(schema.ensure, true).concat(refsOf(schema.assert, true));
61
+ var tpl = templateKeys(schema.applyMutations);
62
+ var prov = schema.provider ? [Array.isArray(schema.provider) ? schema.provider[0] : schema.provider] : [];
63
+ return reqs.length + ens.length + tpl.length + prov.length;
64
+ }
65
+
66
+ // |Σ| from a number, or a {knownFacts, conceptNames, palette} alphabet (deduped union).
67
+ function alphabetSize( alphabet ) {
68
+ if ( typeof alphabet === 'number' ) return Math.max(2, alphabet);
69
+ var s = new Set();
70
+ (alphabet && alphabet.knownFacts || []).forEach(function ( x ) { s.add(x); });
71
+ (alphabet && alphabet.conceptNames || []).forEach(function ( x ) { s.add(x); });
72
+ (alphabet && alphabet.palette || []).forEach(function ( x ) { s.add(x); });
73
+ return Math.max(2, s.size);
74
+ }
75
+
76
+ /**
77
+ * Description length (bits) of ONE concept's schema: its symbol count encoded in the alphabet
78
+ * + a small ordinal structural constant (the fixed _id/_name/wrapper overhead). Per the plan,
79
+ * keep `structConst` ORDINAL — it places the rarity boundary, not an absolute fitted threshold.
80
+ */
81
+ function schemaBits( concept, alphabet, opts ) {
82
+ opts = opts || {};
83
+ var structConst = opts.structConst != null ? opts.structConst : 4;
84
+ return symbolsOf(concept) * log2(alphabetSize(alphabet)) + structConst;
85
+ }
86
+
87
+ // L(corpus | library) = R · log2(N): R records each costing log2(N) bits to name a concept.
88
+ function corpusBits( records, conceptCount ) {
89
+ var R = (records && records.length) || 0;
90
+ var N = Math.max(2, conceptCount || 0);
91
+ return R * log2(N);
92
+ }
93
+
94
+ function conceptCountOf( tree ) {
95
+ var n = 0;
96
+ eachConcept(tree, function ( c ) { if ( c && c._name ) n++; });
97
+ return n;
98
+ }
99
+
100
+ function conceptByName( tree, name ) {
101
+ var found = null;
102
+ eachConcept(tree, function ( c ) { if ( c && c._name === name ) found = c; });
103
+ return found;
104
+ }
105
+
106
+ /**
107
+ * The MDL gain of promoting one mined chain to an abstract production.
108
+ * @param opts.chain a mineChains edge { from, to, via, count } (+ optional `length` ℓ, default 2)
109
+ * @param opts.tree the concept tree (for N and the constituents' schema symbols)
110
+ * @param opts.records the trace corpus (for R, the parse-tax base)
111
+ * @param opts.alphabet Σ — a number, or { knownFacts, conceptNames, palette }
112
+ * @param opts.abstractSchema (optional) the composed method's schema; else estimated from the
113
+ * constituents (sum of from+to symbols — the inlined chain's spine)
114
+ * @returns { deltaL, encodeBits, savedBits, taxBits, admit, count, ell, N, R }
115
+ */
116
+ function mdlGain( opts ) {
117
+ opts = opts || {};
118
+ var chain = opts.chain || {};
119
+ var tree = opts.tree;
120
+ var count = chain.count || 0;
121
+ var ell = chain.length || 2;
122
+ var N = conceptCountOf(tree);
123
+ var R = (opts.records && opts.records.length) || 0;
124
+ // conceptNames default into the alphabet when not given explicitly.
125
+ var alphabet = opts.alphabet;
126
+ if ( alphabet && typeof alphabet === 'object' && !alphabet.conceptNames ) {
127
+ var names = [];
128
+ eachConcept(tree, function ( c ) { if ( c && c._name ) names.push(c._name); });
129
+ alphabet = Object.assign({}, alphabet, { conceptNames: names });
130
+ }
131
+
132
+ var encodeBits;
133
+ if ( opts.abstractSchema ) {
134
+ encodeBits = schemaBits(opts.abstractSchema, alphabet, opts);
135
+ } else {
136
+ // estimate the abstract method's description from its constituents (inlined spine).
137
+ var fromC = conceptByName(tree, chain.from), toC = conceptByName(tree, chain.to);
138
+ var syms = symbolsOf(fromC) + symbolsOf(toC);
139
+ var structConst = opts.structConst != null ? opts.structConst : 4;
140
+ encodeBits = syms * log2(alphabetSize(alphabet)) + structConst;
141
+ }
142
+
143
+ var savedBits = count * (ell - 1) * log2(Math.max(2, N));
144
+ var taxBits = R * (log2(N + 1) - log2(Math.max(2, N)));
145
+ var deltaL = encodeBits + taxBits - savedBits;
146
+
147
+ return {
148
+ deltaL: deltaL, encodeBits: encodeBits, savedBits: savedBits, taxBits: taxBits,
149
+ admit: deltaL < 0, count: count, ell: ell, N: N, R: R,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Rank mined candidates by MDL gain (most-negative ΔL = best refactor first). Each is annotated
155
+ * with its `mdl` record. A cheap O(corpus) ordering to apply BEFORE any expensive boot.
156
+ * @returns the chains sorted ascending by ΔL, each `{ ...chain, mdl }`.
157
+ */
158
+ function rankCandidates( chains, ctx ) {
159
+ ctx = ctx || {};
160
+ return (chains || [])
161
+ .map(function ( chain ) {
162
+ return Object.assign({}, chain, { mdl: mdlGain(Object.assign({}, ctx, { chain: chain })) });
163
+ })
164
+ .sort(function ( a, b ) { return a.mdl.deltaL - b.mdl.deltaL; });
165
+ }
166
+
167
+ /**
168
+ * The MDL gate in the shape `authorConcept`'s `spec.gate` expects. A cheap PRE-FILTER: it only
169
+ * judges 'add' proposals that carry their mined `chain` (so the corpus stats exist); without
170
+ * one it ABSTAINS (admit:true → defer to the authority gate). Reject = ΔL ≥ 0.
171
+ * @param ctx { tree, records, alphabet, structConst } the corpus context (as for mdlGain)
172
+ * @param ctx.chainOf (proposal)->chain how to read the mined chain off a proposal
173
+ * (default: `proposal.chain`)
174
+ */
175
+ function makeMdlGate( ctx ) {
176
+ ctx = ctx || {};
177
+ var chainOf = ctx.chainOf || function ( proposal ) { return proposal && proposal.chain; };
178
+ return function gate( graph, proposal ) {
179
+ if ( (proposal.op || 'add') !== 'add' ) return { admit: true };
180
+ var chain = chainOf(proposal);
181
+ if ( !chain ) return { admit: true, reason: 'mdl-abstain (no mined chain on proposal)' };
182
+ var schema = proposal.schema;
183
+ var g = mdlGain(Object.assign({}, ctx, { chain: chain, abstractSchema: schema }));
184
+ return { admit: g.admit, reason: g.admit ? null : 'mdl-reject (ΔL=' + g.deltaL.toFixed(2) + ' ≥ 0)', mdl: g };
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Compose gates as a short-circuiting AND: run them in order, the FIRST reject wins (cheap
190
+ * gates first prune before the expensive authority boots). All-admit → the last gate's verdict
191
+ * (so the authority's `eval` rides through). Each gate is sync or async; the composite awaits.
192
+ */
193
+ function composeGates() {
194
+ var gates = Array.prototype.slice.call(arguments);
195
+ return async function gate( graph, proposal ) {
196
+ var last = { admit: true };
197
+ for ( var i = 0; i < gates.length; i++ ) {
198
+ last = await gates[i](graph, proposal);
199
+ if ( !last || !last.admit ) return last; // short-circuit on the first reject
200
+ }
201
+ return last;
202
+ };
203
+ }
204
+
205
+ module.exports = {
206
+ symbolsOf: symbolsOf, alphabetSize: alphabetSize, schemaBits: schemaBits, corpusBits: corpusBits,
207
+ conceptCountOf: conceptCountOf, mdlGain: mdlGain, rankCandidates: rankCandidates,
208
+ makeMdlGate: makeMdlGate, composeGates: composeGates,
209
+ };
@@ -0,0 +1,122 @@
1
+ /*
2
+ * Copyright 2026 Nathanael Braun
3
+ * @author : Nathanael BRAUN <pp9ping@gmail.com>
4
+ *
5
+ * This program is free software: you can redistribute it and/or modify
6
+ * it under the terms of the GNU Affero General Public License as published by
7
+ * the Free Software Foundation, either version 3 of the License, or
8
+ * (at your option) any later version.
9
+ *
10
+ * This program is distributed in the hope that it will be useful,
11
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ * GNU Affero General Public License for more details.
14
+ *
15
+ * You should have received a copy of the GNU Affero General Public License
16
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ */
18
+ 'use strict';
19
+ /**
20
+ * memo-stability — the safety instrument for structure-learning (host-side, ZERO-CORE;
21
+ * study doc/WIP/studies/2026-06-26-…, promotes experiment F4).
22
+ *
23
+ * WHY. The engine's incrementality rides on the canonicalization memo: identical typed
24
+ * inputs to a concept ⇒ identical `<name>FactsDigest` ⇒ memo hit, no re-derivation. A
25
+ * structural change (addConcept/patchConcept, or a mined abstraction) is SAFE iff it is
26
+ * **memo-surface-preserving** — it must not alter the canonical facts that incumbent
27
+ * concepts' `require`/`ensure`/`assert` read. Violating this collapses the memo silently
28
+ * (the only "quiet failure" the adversarial lens identified). This module makes the F4
29
+ * boundary a reusable, gateable check.
30
+ *
31
+ * const { memoSnapshot, memoDiff } = require('./memo-stability');
32
+ * const before = memoSnapshot(graph, ['Consume']); // incumbents to protect
33
+ * graph.addConcept(null, candidateSchema, () => {
34
+ * const { stable, changed } = memoDiff(before, memoSnapshot(graph, ['Consume']));
35
+ * // stable === false ⇒ the candidate perturbed an incumbent's memo key ⇒ reject/rollback
36
+ * });
37
+ *
38
+ * A concept's MEMO SURFACE = the fact keys its require/ensure/assert depend on (the same
39
+ * ref extraction `validate.js` uses, single source of truth). A snapshot digests, per
40
+ * (object × concept), the projection of the object's facts onto that surface — so a drift
41
+ * in any depended-on key shows up as a changed digest, while an isolated new fact does not.
42
+ */
43
+ const canon = require('../providers/canonicalize.js');
44
+ const { refsOf, refKeyOf } = require('./validate.js');
45
+
46
+ const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
47
+
48
+ /** The fact keys a concept's require/ensure/assert read — its incremental memo surface. */
49
+ function memoSurfaceKeys( graph, conceptName ) {
50
+ const c = graph.getConceptByName ? graph.getConceptByName(conceptName) : null;
51
+ const schema = (c && (c._schema || c)) || {};
52
+ const keys = new Set();
53
+ for ( const r of refsOf(schema.require, false) ) keys.add(refKeyOf(r).key);
54
+ for ( const fld of ['ensure', 'assert'] )
55
+ for ( const e of asArray(schema[fld]) )
56
+ if ( typeof e === 'string' ) for ( const r of refsOf(e, true) ) keys.add(refKeyOf(r).key);
57
+ return [...keys];
58
+ }
59
+
60
+ /**
61
+ * Capture the memo keys of the named incumbents across the live graph.
62
+ * @param graph a live Graph
63
+ * @param conceptNames incumbents to protect (default: every concept in the lib)
64
+ * @returns {{[key:string]: string}} "<objId>|<conceptName>" -> digest of the depended-on facts
65
+ */
66
+ function memoSnapshot( graph, conceptNames ) {
67
+ const out = {};
68
+ const names = conceptNames || Object.keys(graph._conceptLib || {});
69
+ const objs = graph._objById || {};
70
+ for ( const name of names ) {
71
+ const keys = memoSurfaceKeys(graph, name);
72
+ if ( !keys.length ) continue;
73
+ for ( const id of Object.keys(objs) ) {
74
+ const etty = objs[id] && objs[id]._etty;
75
+ const facts = etty && etty._;
76
+ if ( !facts ) continue;
77
+ const proj = {};
78
+ let has = false;
79
+ for ( const k of keys ) if ( k in facts ) { proj[k] = facts[k]; has = true; }
80
+ if ( has ) out[id + '|' + name] = canon.digest(proj);
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * Compare two snapshots. `stable` iff no depended-on memo key changed or disappeared.
88
+ * @returns {{ stable:boolean, changed:Array<{key,before,after}>, removed:string[], added:string[] }}
89
+ */
90
+ function memoDiff( before, after ) {
91
+ const changed = [], removed = [];
92
+ for ( const k of Object.keys(before) ) {
93
+ if ( !(k in after) ) removed.push(k);
94
+ else if ( after[k] !== before[k] ) changed.push({ key: k, before: before[k], after: after[k] });
95
+ }
96
+ const added = Object.keys(after).filter((k) => !(k in before));
97
+ return { stable: changed.length === 0 && removed.length === 0, changed, removed, added };
98
+ }
99
+
100
+ /**
101
+ * Fail-closed CI gate around a structural change: snapshot the incumbents' memo keys, run
102
+ * `applyFn` (e.g. an addConcept/patchConcept install or a mined-abstraction adoption), and
103
+ * THROW if the change perturbed any of them. Wrap any structure-learning mutation in this so
104
+ * a memo-surface regression fails the build instead of silently collapsing the incremental memo.
105
+ * @returns the memoDiff on success (stable); throws an Error (with `.diff`) on instability.
106
+ */
107
+ async function assertMemoStable( graph, incumbents, applyFn ) {
108
+ const before = memoSnapshot(graph, incumbents);
109
+ await applyFn();
110
+ const after = memoSnapshot(graph, incumbents);
111
+ const d = memoDiff(before, after);
112
+ if ( !d.stable ) {
113
+ const names = d.changed.map((c) => c.key).concat(d.removed).join(', ');
114
+ const e = new Error('memo-stability violation: ' + (names || 'incumbent memo keys changed') +
115
+ ' — a structural change must be memo-surface-preserving (snapped, incumbents\' digests unchanged)');
116
+ e.diff = d;
117
+ throw e;
118
+ }
119
+ return d;
120
+ }
121
+
122
+ module.exports = { memoSnapshot, memoDiff, memoSurfaceKeys, assertMemoStable };