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,476 @@
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
+ * contract — C-contract: the DEFEASIBLE SEPARATION TRIPLE checker (design doc §2 / §9.1 / §11.6, the "central
10
+ * hole"). A learned METHOD carries a typed contract `{ read, write, pre, post, effect }` (the shape `lintMethod`
11
+ * already lints for the static frame); this module adds the two things that make COMPOSITION-WITHOUT-OPENING-THE-
12
+ * BOX sound:
13
+ *
14
+ * 1. COMPOSE-TIME ⊨ (`checkCompose`) — for every shared fact f ∈ write(M1) ∩ read(M2), is post(M1) ⊨ pre(M2)
15
+ * over f? Decided by PER-KEY ABSTRACT-DOMAIN entailment (interval + finite-domain, [Cousot-Cousot 1977];
16
+ * symbolic-heap-style precise footprints, [Berdine-Calcagno-O'Hearn 2004]) — NOT atom-by-atom (which misses
17
+ * `x≥5 ∧ x≤5 ⊨ x==5` and the integer gap `x>3 ∧ x<5 ⊨ x==4`). SOUND + DECIDABLE on the monadic, ground
18
+ * fragment; everything outside it (disjunction → QF-coNP; two-key/relational → difference logic; non-ground/
19
+ * ref-walked footprints → aliasing) is REFUSED → 'escalate' (a micro-LLM open-box step, the §0.1 cost
20
+ * gradient). The checker NEVER false-accepts: in doubt it escalates.
21
+ *
22
+ * 2. RUNTIME post-assert + BLAME (`assertPost`) — the post is an INDUCED hypothesis (sound on observed cases,
23
+ * maybe wrong on the next), so it is ASSERTED at settle (contracts-with-blame, [Findler-Felleisen 2002;
24
+ * Wadler-Findler 2009]; gradual verification, [Bader-Aldrich-Tanter 2018]) ⇒ EVENTUAL, not static,
25
+ * soundness. The adversary's three real holes are closed here, not by the ⊨ check:
26
+ * · G1 FRAME-COMPLETENESS — the keys the body actually TOUCHED ⊆ the declared write (an under-declared
27
+ * frame is the silent unsoundness; the only check that closes it is the runtime touched-vs-declared diff);
28
+ * · G2 EFFECT-TAG DISCIPLINE — an `external`/`irreversible` method's post may NOT be discharged by an
29
+ * internal recorded fact; it must be confirmed by a ground-truth ORACLE (a clean-but-incomplete post on
30
+ * an irreversible effect is the most dangerous failure — it caches as fully-typed, elides the oracle,
31
+ * commits real damage, and is BLESSED sound);
32
+ * · G3 FOOTPRINT-CYCLE rejection (`footprintCycles`) — two retractable methods that are each other's
33
+ * premise through coupled facts OSCILLATE to the apply-cap (`divergent`); reject the cycle at compose-time.
34
+ *
35
+ * Blame on a violated INDUCED post = contract REVISION (specialize the pre with the counterexample's discriminating
36
+ * atom — CEGIS/ICE, [Solar-Lezama 2006; Garg et al. 2014]), not method removal (which oscillates). `reviseOnBlame`.
37
+ *
38
+ * HONEST CLAIM (the only one the literature supports): eventual soundness via a runtime monitor over a SOUND-but-
39
+ * INCOMPLETE compose gate; the typed-coverage fraction is MEASURED (`acceptRate`), not proven — deciding whether an
40
+ * arbitrary learned method's true contract lands in ANY finite typed alphabet is undecidable (Rice). So the runtime
41
+ * assert is LOAD-BEARING; "compose without opening the box" is never an unconditional claim.
42
+ *
43
+ * ZERO-CORE: pure host logic over the typed contract; reuses `frameKeys` (method.js) + `expr.js` for evaluation.
44
+ */
45
+
46
+ const { compileExpression } = require('../graph/expr.js');
47
+
48
+ // ───────────────────────────────────────────────────────── atom parsing → per-key abstract constraints ────────
49
+
50
+ const REL_OPS = ['==', '!=', '<=', '>=', '<', '>'];
51
+
52
+ // Parse ONE atom string into { key, op, value } or { refuse:<reason> }. An atom keys ONE fact on ONE constant
53
+ // (monadic, ground). `a && b` is split by the caller; `||` (disjunction), a two-key comparison (relational), and
54
+ // a function call leave the decidable fragment → refuse.
55
+ function parseAtom( s ) {
56
+ const t = String(s).trim();
57
+ if ( !t ) return null;
58
+ if ( t.indexOf('||') >= 0 ) return { refuse: 'disjunction' };
59
+ if ( /[a-zA-Z_]\w*\s*\(/.test(t) ) return { refuse: 'function-call' };
60
+ // `key in [..]`
61
+ let m = t.match(/^\$?\$?([A-Za-z_][\w.:]*)\s+in\s+\[(.*)\]$/);
62
+ if ( m ) {
63
+ const vals = m[2].split(',').map(( v ) => v.trim()).filter(( v ) => v.length).map(parseValue);
64
+ // a bare id INSIDE an in-list is an unambiguous enum literal (no two-key ambiguity) → accept its value.
65
+ if ( vals.some(( v ) => v.refuse && v.refuse !== 'bare-ref' ) ) return { refuse: 'in-list-value' };
66
+ return { key: m[1], op: 'in', value: vals.map(( v ) => v.value) };
67
+ }
68
+ for ( const op of REL_OPS ) { // order matters: <=/>= before </>
69
+ const i = t.indexOf(op);
70
+ if ( i < 0 ) continue;
71
+ const lhs = t.slice(0, i).trim().replace(/^\$\$?/, ''), rhs = t.slice(i + op.length).trim();
72
+ if ( !/^[A-Za-z_][\w.:]*$/.test(lhs) ) return { refuse: 'non-key-lhs' };
73
+ const v = parseValue(rhs);
74
+ if ( v.refuse === 'bare-ref' ) return { refuse: 'relational' }; // rhs is another key (two-key compare)
75
+ if ( v.refuse ) return { refuse: v.refuse };
76
+ return { key: lhs, op, value: v.value, int: v.int }; // carry int so integer-gap reasoning survives
77
+ }
78
+ return { refuse: 'unparseable' };
79
+ }
80
+
81
+ // Parse an atom RHS literal. A QUOTED string / number / bool / bracketed-list is a constant; a BARE identifier is
82
+ // treated as an enum/id constant (the K1 vocabulary is symbolic) UNLESS it would alias a fact key in a comparison
83
+ // — that ambiguity is handled by the caller (a bare id on the rhs of a comparison = relational → refuse).
84
+ function parseValue( s ) {
85
+ const t = String(s).trim();
86
+ if ( /^'.*'$/.test(t) || /^".*"$/.test(t) ) return { value: t.slice(1, -1), num: false };
87
+ if ( t === 'true' ) return { value: true, num: false };
88
+ if ( t === 'false' ) return { value: false, num: false };
89
+ if ( /^-?\d+$/.test(t) ) return { value: parseInt(t, 10), num: true, int: true };
90
+ if ( /^-?\d*\.\d+$/.test(t) ) return { value: parseFloat(t), num: true, int: false };
91
+ if ( /^\$\$?[A-Za-z_][\w.:]*$/.test(t) ) return { refuse: 'bare-ref', value: t.replace(/^\$\$?/, '') }; // a $ref rhs = two-key compare
92
+ if ( /^[A-Za-z_][\w-]*$/.test(t) ) return { refuse: 'bare-ref', value: t }; // ambiguous: a key or an enum literal
93
+ return { refuse: 'unparseable-value' };
94
+ }
95
+
96
+ // Fold a list of atom STRINGS (a pre/post; `&&` inside an entry is split) into per-key constraints + a refusal
97
+ // list. A constraint is { kind:'num', lo, loInc, hi, hiInc, int, ne:Set } or { kind:'cat', allow:Set|null, ne:Set }.
98
+ function normalize( entries ) {
99
+ const byKey = {}, refuse = [];
100
+ const atoms = [];
101
+ for ( const e of (entries || []) ) {
102
+ if ( typeof e !== 'string' ) { refuse.push({ reason: 'non-string-atom' }); continue; }
103
+ for ( const part of e.split('&&') ) { const a = parseAtom(part); if ( a ) atoms.push(a); }
104
+ }
105
+ for ( const a of atoms ) {
106
+ if ( a.refuse ) { refuse.push({ reason: a.refuse }); continue; }
107
+ const numeric = (a.op !== 'in' && a.op !== '==' && a.op !== '!=') || (typeof a.value === 'number');
108
+ let c = byKey[a.key];
109
+ if ( !c ) c = byKey[a.key] = numeric ? { kind: 'num', lo: -Infinity, loInc: true, hi: Infinity, hiInc: true, int: true, ne: new Set() }
110
+ : { kind: 'cat', allow: null, ne: new Set() };
111
+ // a key seen as both numeric and categorical → out of the monadic fragment for that key
112
+ if ( numeric && c.kind !== 'num' ) { refuse.push({ reason: 'mixed-domain', key: a.key }); continue; }
113
+ if ( !numeric && c.kind !== 'cat' ) { refuse.push({ reason: 'mixed-domain', key: a.key }); continue; }
114
+ applyAtom(c, a);
115
+ }
116
+ return { byKey, refuse };
117
+ }
118
+
119
+ function applyAtom( c, a ) {
120
+ if ( c.kind === 'num' ) {
121
+ if ( typeof a.value !== 'number' && a.op !== 'in' ) { c._broken = 'non-numeric-on-num-key'; return; }
122
+ if ( a.int === false ) c.int = false; // a rational literal demotes the key off integer reasoning
123
+ switch ( a.op ) {
124
+ case '==': c.lo = c.hi = a.value; c.loInc = c.hiInc = true; break;
125
+ case '!=': c.ne.add(a.value); break;
126
+ case '>': if ( a.value > c.lo || (a.value === c.lo && !c.loInc) ) { c.lo = a.value; c.loInc = false; } break;
127
+ case '>=': if ( a.value > c.lo ) { c.lo = a.value; c.loInc = true; } break;
128
+ case '<': if ( a.value < c.hi || (a.value === c.hi && !c.hiInc) ) { c.hi = a.value; c.hiInc = false; } break;
129
+ case '<=': if ( a.value < c.hi ) { c.hi = a.value; c.hiInc = true; } break;
130
+ case 'in': c.kind = 'cat'; c.allow = new Set(a.value); break; // an `in` over numbers → an allow-set
131
+ }
132
+ } else {
133
+ switch ( a.op ) {
134
+ case '==': c.allow = c.allow ? intersect(c.allow, [a.value]) : new Set([a.value]); break;
135
+ case '!=': c.ne.add(a.value); break;
136
+ case 'in': c.allow = c.allow ? intersect(c.allow, a.value) : new Set(a.value); break;
137
+ default: c._broken = 'order-op-on-cat-key';
138
+ }
139
+ }
140
+ }
141
+
142
+ function intersect( set, vals ) { const out = new Set(); for ( const v of vals ) if ( set.has(v) ) out.add(v); return out; }
143
+
144
+ // integer-normalise open bounds: x>3 ⇒ x>=4 ; x<5 ⇒ x<=4 (so [>3,<5] over ints == {4}).
145
+ function intNorm( c ) {
146
+ if ( c.kind !== 'num' || !c.int ) return c;
147
+ const d = { lo: c.lo, loInc: c.loInc, hi: c.hi, hiInc: c.hiInc, int: true, ne: c.ne, kind: 'num' };
148
+ if ( d.lo > -Infinity && !d.loInc ) { d.lo = d.lo + 1; d.loInc = true; }
149
+ if ( d.hi < Infinity && !d.hiInc ) { d.hi = d.hi - 1; d.hiInc = true; }
150
+ return d;
151
+ }
152
+
153
+ // ───────────────────────────────────────────────────────── per-key entailment: post ⊆ pre ────────────────────
154
+
155
+ // Does the POST constraint entail (⊆) the PRE constraint for one key? 'yes' (sound) | 'no' (a counterexample
156
+ // exists — post admits a value pre forbids) | 'unknown' (can't decide in-fragment → escalate). NEVER false-'yes'.
157
+ function entailsKey( postC, preC ) {
158
+ if ( !preC ) return 'yes'; // pre constrains nothing on this key → trivially ⊨
159
+ if ( !postC ) return 'unknown'; // the UNDER-DETERMINED gap (post leaves it free) → escalate
160
+ if ( postC._broken || preC._broken ) return 'unknown';
161
+ if ( postC.kind !== preC.kind ) return 'unknown'; // different domains → can't compare in-fragment
162
+ if ( postC.kind === 'num' ) {
163
+ const p = intNorm(postC), q = intNorm(preC);
164
+ if ( p.lo > p.hi || (p.lo === p.hi && !(p.loInc && p.hiInc)) ) return 'yes'; // post is EMPTY ⇒ vacuously ⊆
165
+ const loOk = p.lo > q.lo || (p.lo === q.lo && (q.loInc || !p.loInc));
166
+ const hiOk = p.hi < q.hi || (p.hi === q.hi && (q.hiInc || !p.hiInc));
167
+ if ( !loOk || !hiOk ) return 'no'; // post's interval pokes outside pre's
168
+ for ( const n of q.ne ) if ( admitsNum(p, n) ) return 'no'; // pre excludes n but post admits it
169
+ return 'yes';
170
+ }
171
+ // categorical
172
+ if ( postC.allow ) { // post admits a FINITE set → check each is pre-admitted
173
+ for ( const v of postC.allow ) if ( !catAdmits(preC, v) ) return 'no';
174
+ return 'yes';
175
+ }
176
+ // post is allow=any minus ne (an INFINITE admit set) → ⊆ pre only if pre is also any-minus-ne with ne ⊆ post.ne
177
+ if ( preC.allow ) return 'no'; // post infinite, pre finite → post ⊄ pre
178
+ for ( const n of preC.ne ) if ( !postC.ne.has(n) ) return 'no'; // pre excludes n that post admits
179
+ return 'yes';
180
+ }
181
+
182
+ function admitsNum( c, n ) {
183
+ if ( c.ne.has(n) ) return false;
184
+ const aboveLo = n > c.lo || (n === c.lo && c.loInc);
185
+ const belowHi = n < c.hi || (n === c.hi && c.hiInc);
186
+ return aboveLo && belowHi;
187
+ }
188
+ function catAdmits( c, v ) { if ( c.ne.has(v) ) return false; return c.allow ? c.allow.has(v) : true; }
189
+
190
+ // ───────────────────────────────────────────────────────── the COMPOSE-TIME gate ─────────────────────────────
191
+
192
+ const EFFECTING = new Set(['external', 'irreversible']);
193
+
194
+ /**
195
+ * checkCompose(m1, m2, opts) — is the composition M1→M2 sound on the typed contracts alone (box CLOSED)?
196
+ * @param m1,m2 { name?, contract:{ read, write, pre, post, effect } }
197
+ * @param opts { oracle?: (m1, key) => bool } — a ground-truth probe for an effecting post (G2)
198
+ * @returns { verdict:'sound'|'unsound'|'escalate', shared:[keys], perKey:{key:'yes'|'no'|'unknown'}, reasons:[],
199
+ * needsOracle:bool }
200
+ */
201
+ function checkCompose( m1, m2, opts ) {
202
+ opts = opts || {};
203
+ const c1 = (m1 && m1.contract) || {}, c2 = (m2 && m2.contract) || {};
204
+ const write1 = new Set(c1.write || []), read2 = new Set(c2.read || []);
205
+ const shared = [...write1].filter(( k ) => read2.has(k));
206
+ const reasons = [], perKey = {};
207
+ if ( !c1.post && !c1.write ) { reasons.push('m1 uncontracted'); return { verdict: 'escalate', shared, perKey, reasons, needsOracle: false }; }
208
+
209
+ const post1 = normalize(c1.post), pre2 = normalize(c2.pre);
210
+ for ( const r of post1.refuse ) reasons.push('post(m1) out-of-fragment: ' + r.reason);
211
+ for ( const r of pre2.refuse ) reasons.push('pre(m2) out-of-fragment: ' + r.reason);
212
+ const fragmentClean = post1.refuse.length === 0 && pre2.refuse.length === 0;
213
+
214
+ let anyNo = false, anyUnknown = !fragmentClean;
215
+ for ( const k of shared ) {
216
+ const v = entailsKey(post1.byKey[k], pre2.byKey[k]);
217
+ perKey[k] = v;
218
+ if ( v === 'no' ) anyNo = true; else if ( v === 'unknown' ) anyUnknown = true;
219
+ }
220
+ // a pre(m2) key that M1 WRITES but is not constrained by post(m1) = the under-determined gap (already 'unknown'
221
+ // above via entailsKey(undefined, preC)); a pre(m2) key M1 does NOT write is framed-through (M2's own concern).
222
+
223
+ // G2 — effect-tag discipline: an effecting M1's post is about a real-world effect the internal fact can't vouch
224
+ // for; it must be confirmed by a ground-truth oracle, else escalate (never silently bless).
225
+ let needsOracle = false;
226
+ if ( EFFECTING.has(c1.effect) && shared.length ) {
227
+ needsOracle = true;
228
+ const oracleOk = opts.oracle && shared.every(( k ) => opts.oracle(m1, k));
229
+ if ( !oracleOk ) { reasons.push('effecting M1 (' + c1.effect + ') post needs a ground-truth oracle (G2)'); anyUnknown = true; }
230
+ }
231
+
232
+ let verdict;
233
+ if ( anyNo ) verdict = 'unsound';
234
+ else if ( anyUnknown ) verdict = 'escalate';
235
+ else verdict = 'sound';
236
+ return { verdict, shared, perKey, reasons, needsOracle };
237
+ }
238
+
239
+ // ───────────────────────────────────────────────────────── G3 — footprint-cycle rejection ────────────────────
240
+
241
+ /**
242
+ * footprintCycles(methods) — a directed edge M_a → M_b iff write(M_a) ∩ read(M_b) ≠ ∅. A cycle of RETRACTABLE
243
+ * (non-`pure`) methods = mutual premises through coupled facts → JTMS oscillation to the apply-cap. Returns the
244
+ * cycles (arrays of method names) so the supervisor rejects / priority-orders them (Tarjan SCC, size > 1, or a self-loop).
245
+ */
246
+ function footprintCycles( methods ) {
247
+ const ms = methods || [], idx = {}, retract = {};
248
+ ms.forEach(( m, i ) => { idx[m.name == null ? i : m.name] = m; retract[m.name == null ? i : m.name] = (m.contract || {}).effect !== 'pure'; });
249
+ const adj = {};
250
+ for ( const a of ms ) {
251
+ const an = a.name, wa = new Set((a.contract || {}).write || []);
252
+ adj[an] = [];
253
+ for ( const b of ms ) {
254
+ const rb = (b.contract || {}).read || [];
255
+ if ( rb.some(( k ) => wa.has(k) ) ) adj[an].push(b.name);
256
+ }
257
+ }
258
+ // Tarjan SCC
259
+ let id = 0; const ids = {}, low = {}, onStack = {}, stack = [], sccs = [];
260
+ function dfs( at ) {
261
+ ids[at] = low[at] = id++; stack.push(at); onStack[at] = true;
262
+ for ( const to of adj[at] || [] ) {
263
+ if ( ids[to] === undefined ) { dfs(to); low[at] = Math.min(low[at], low[to]); }
264
+ else if ( onStack[to] ) low[at] = Math.min(low[at], ids[to]);
265
+ }
266
+ if ( low[at] === ids[at] ) {
267
+ const comp = []; let w;
268
+ do { w = stack.pop(); onStack[w] = false; comp.push(w); } while ( w !== at );
269
+ sccs.push(comp);
270
+ }
271
+ }
272
+ for ( const m of ms ) if ( ids[m.name] === undefined ) dfs(m.name);
273
+ const cycles = [];
274
+ for ( const comp of sccs ) {
275
+ const selfLoop = comp.length === 1 && (adj[comp[0]] || []).indexOf(comp[0]) >= 0;
276
+ if ( (comp.length > 1 || selfLoop) && comp.some(( n ) => retract[n]) ) cycles.push(comp);
277
+ }
278
+ return cycles;
279
+ }
280
+
281
+ // ───────────────────────────────────────────────────────── the RUNTIME post-assert + blame ───────────────────
282
+
283
+ /**
284
+ * assertPost(contract, factsAfter, touchedKeys, opts) — the runtime monitor (settle-time). Closes the holes the
285
+ * compose-time ⊨ structurally cannot: G1 frame-completeness + G2 effect-tag oracle.
286
+ * @param contract { write, post, effect }
287
+ * @param factsAfter the realized facts (a flat object) the body produced
288
+ * @param touchedKeys the keys the body ACTUALLY wrote (from the engine's sequenced mutation) — for G1
289
+ * @param opts { oracle?: (contract, factsAfter) => bool }
290
+ * @returns { ok, violations:[{kind,detail}], blame:{by,kind,...}|null }
291
+ */
292
+ function assertPost( contract, factsAfter, touchedKeys, opts ) {
293
+ opts = opts || {};
294
+ const violations = [];
295
+ const write = new Set(contract.write || []);
296
+
297
+ // G1 — FRAME COMPLETENESS: every key the body touched must be declared (an under-declared write is the silent
298
+ // frame hole the ⊨ check ranges right past — the only check that closes it is this touched-vs-declared diff).
299
+ for ( const k of (touchedKeys || []) ) if ( !write.has(k) )
300
+ violations.push({ kind: 'undeclared-write', detail: k });
301
+
302
+ // the post must actually HOLD on the realized facts (the induced hypothesis, asserted). Evaluated from the
303
+ // PARSED atoms (the same monadic fragment as the ⊨ check — no `$`-syntax dependence); an out-of-fragment post
304
+ // atom falls back to the engine's `expr.js` (the `$ref` convention).
305
+ for ( const atom of (contract.post || []) ) {
306
+ if ( typeof atom !== 'string' ) continue;
307
+ if ( !holdsAtoms(factsAfter, atom) ) violations.push({ kind: 'post-violated', detail: atom });
308
+ }
309
+
310
+ // G2 — EFFECTING post must be confirmed by a ground-truth oracle, not the internal fact (the most dangerous
311
+ // hole: a clean post HOLDS on what we recorded while the world disagrees).
312
+ if ( EFFECTING.has(contract.effect) ) {
313
+ if ( !opts.oracle ) violations.push({ kind: 'effecting-post-unverified', detail: contract.effect });
314
+ else if ( !opts.oracle(contract, factsAfter) ) violations.push({ kind: 'oracle-disagrees', detail: contract.effect });
315
+ }
316
+
317
+ const ok = violations.length === 0;
318
+ return { ok, violations, blame: ok ? null : { by: 'post', kind: violations[0].kind, violations } };
319
+ }
320
+
321
+ function resolve( facts, ref ) {
322
+ const r = String(ref).replace(/^\$\$?/, '');
323
+ if ( r.indexOf('.') < 0 ) return facts == null ? undefined : facts[r];
324
+ return r.split('.').reduce(( o, k ) => (o == null ? undefined : o[k]), facts);
325
+ }
326
+
327
+ // does a post atom-string (a `&&`-conjunction) HOLD on the realized facts? Simple monadic atoms eval directly;
328
+ // an out-of-fragment atom (`||`/relational/function) falls back to the engine's safe `expr.js`. The fallback must
329
+ // resolve BOTH `$ref` tokens (via `resolve`, 1st arg) AND BARE fact keys (via `names`, 2nd arg = `facts`) — the
330
+ // contract DSL writes bare keys (`decision=="approve"`, `a < b`), which expr.js resolves through `names`, not the
331
+ // `$ref` path. (BUG FIX: previously `names` was omitted → bare keys in a refused atom resolved to `undefined` →
332
+ // the post silently mis-evaluated, e.g. a `||` implication post passed when it should have failed.)
333
+ function holdsAtoms( facts, atomStr ) {
334
+ for ( const part of String(atomStr).split('&&') ) {
335
+ const a = parseAtom(part);
336
+ if ( !a ) continue; // an EMPTY conjunct (trailing `&&`) is vacuously true
337
+ if ( a.refuse ) { const fn = compileExpression(part, { empty: false }); if ( !fn(( ref ) => resolve(facts, ref), facts) ) return false; continue; }
338
+ if ( !evalAtom(facts, a) ) return false;
339
+ }
340
+ return true;
341
+ }
342
+
343
+ function evalAtom( facts, a ) {
344
+ const v = resolve(facts, a.key);
345
+ switch ( a.op ) {
346
+ case '==': return v === a.value;
347
+ case '!=': return v !== a.value;
348
+ case '<': return v < a.value;
349
+ case '<=': return v <= a.value;
350
+ case '>': return v > a.value;
351
+ case '>=': return v >= a.value;
352
+ case 'in': return (a.value || []).indexOf(v) >= 0;
353
+ }
354
+ return false;
355
+ }
356
+
357
+ /**
358
+ * reviseOnBlame(contract, counterexample) — a violated INDUCED post means the hypothesis OVER-GENERALIZED; specialize
359
+ * the PRECONDITION with the counterexample's discriminating atom (CEGIS/ICE) so the method no longer claims the case
360
+ * it failed — NOT remove the method (which oscillates). Returns a NEW contract (versioned by the caller / B8).
361
+ * @param contract the lying contract
362
+ * @param counterexample { key, value } a discrete fact that distinguishes the failing case
363
+ */
364
+ function reviseOnBlame( contract, counterexample ) {
365
+ const ce = counterexample || {};
366
+ const pre = (contract.pre || []).slice();
367
+ if ( ce.key != null && ce.value !== undefined ) {
368
+ const atom = '$' + ce.key + "!=" + (typeof ce.value === 'string' ? "'" + ce.value + "'" : ce.value);
369
+ if ( pre.indexOf(atom) < 0 ) pre.push(atom); // exclude the failing case from applicability
370
+ }
371
+ const read = (contract.read || []).slice();
372
+ if ( ce.key != null && read.indexOf(ce.key) < 0 ) read.push(ce.key);
373
+ return Object.assign({}, contract, { pre, read });
374
+ }
375
+
376
+ // Does a case satisfy a pre/atom list (is the method APPLICABLE to it)? The selection-side dual of assertPost —
377
+ // the supervisor uses it to admit/exclude a method per case (e.g. after `reviseOnBlame` specialized the pre).
378
+ function satisfies( atoms, facts ) {
379
+ for ( const a of (atoms || []) ) if ( typeof a === 'string' && !holdsAtoms(facts, a) ) return false;
380
+ return true;
381
+ }
382
+
383
+ // ───────────────────────────────────────────────────────── §6.4 — the WIDEN (S-boundary climb) ────────────────
384
+
385
+ const fmtVal = ( v ) => typeof v === 'string' ? "'" + v + "'" : String(v);
386
+ const unq = ( s ) => { const t = String(s).trim(); if ( /^'.*'$/.test(t) || /^".*"$/.test(t) ) return t.slice(1, -1);
387
+ if ( /^-?\d+$/.test(t) ) return parseInt(t, 10); if ( /^-?\d*\.\d+$/.test(t) ) return parseFloat(t); return t; };
388
+ // is `atom` the discriminator's POSITIVE-constraint — the ONE atom widen relaxes? Recognises `$disc==v`, `$disc in [..]`,
389
+ // AND a parenthesized membership disjunction `($disc==a || $disc==b)` (so a re-widen folds the prior widen). A `!=` is NOT.
390
+ function isDiscPositive( atom, disc ) {
391
+ const s = String(atom).trim();
392
+ if ( new RegExp('^\\$?\\$?' + disc + '\\s*==').test(s) ) return true;
393
+ if ( new RegExp('^\\$?\\$?' + disc + '\\s+in\\s*\\[').test(s) ) return true;
394
+ return /^\(/.test(s) && new RegExp('\\$?\\$?' + disc + '\\s*==').test(s) && !new RegExp('\\$?\\$?' + disc + '\\s*!=').test(s);
395
+ }
396
+ // the values a discriminator's positive-constraint atoms currently admit (`==v` → [v]; `in [a,b]` → [a,b]; the
397
+ // `(==a || ==b)` membership disjunction → [a,b]). Only reads disc-POSITIVE atoms (a `!=` nogood is never pulled in).
398
+ function priorAllowed( pre, disc ) {
399
+ const out = [];
400
+ for ( const a of (pre || []) ) {
401
+ const s = String(a);
402
+ if ( !isDiscPositive(s, disc) ) continue;
403
+ const ml = s.match(new RegExp('^\\$?\\$?' + disc + '\\s+in\\s*\\[(.*)\\]\\s*$'));
404
+ if ( ml ) { ml[1].split(',').map(( v ) => v.trim()).filter(( v ) => v.length).forEach(( v ) => out.push(unq(v))); continue; }
405
+ const re = new RegExp('\\$?\\$?' + disc + '\\s*==\\s*([^\\s|)]+)', 'g'); // every `disc == v` (covers == and the || group)
406
+ let mm; while ( (mm = re.exec(s)) ) out.push(unq(mm[1]));
407
+ }
408
+ return out;
409
+ }
410
+
411
+ /**
412
+ * widenOnVerified(contract, positives, opts) — the S-BOUNDARY CLIMB, the symmetric dual of `reviseOnBlame`
413
+ * (the G-descent). On ≥k VERIFIED POSITIVES, GENERALIZE the discriminator's pre to the LGG (Plotkin 1970) of the
414
+ * OBSERVED values (candidate elimination, Mitchell 1982). Refined by the 2026-06-30 confront — all ZERO-CORE:
415
+ * • ADDITIVE (pt2, the FREE clamp): relax ONLY the discriminator's positive-constraint (`==`/`in`) atom; PRESERVE
416
+ * every surviving `!=` blame nogood + every other atom. `region in [EU,JP] ∧ region!='EU'` mechanically admits
417
+ * only JP — "never cross G" is the existing `normalize` `allow∩ne` intersection, not a new check.
418
+ * • OBSERVED UNION (pt1): the new allow-set is the UNION of observed values (enum or numeric), NOT a hull — a hull
419
+ * admits the unobserved gap, sound only under `assertPost`, which FROZEN elides. `opts.numeric:'hull'` is opt-in
420
+ * and sets `forceInstance` (the caller must pin INSTANCE — never FROZEN).
421
+ * • METHOD-IDENTITY (pt5): a positive must carry a `methodId` GOVERNING the widened value — `∈ target ∪ siblings`
422
+ * (the sibling-merge / guarded-speculative-admit signals). A cross-method positive (a §6.2 `M_new` success fed for
423
+ * `M_donor`) is REJECTED (a positive must be an instance of the TARGET concept) — no borrowing across a contract.
424
+ * @param positives [{ value, methodId }] the verified-positive observations on the discriminator.
425
+ * @param opts { discriminator, target?, siblings?:[], numeric?:'union'|'hull' }
426
+ * @returns { contract, ensure, widenAtom, accepted:[values], rejected:[positives], clamped:[values], forceInstance }
427
+ */
428
+ function widenOnVerified( contract, positives, opts ) {
429
+ opts = opts || {};
430
+ const disc = opts.discriminator;
431
+ const govern = new Set([opts.target].concat(opts.siblings || []).filter(( x ) => x != null));
432
+ const pre = (contract.pre || []).slice();
433
+
434
+ // pt5 — accept only positives whose methodId GOVERNS the widen target (target ∪ declared siblings).
435
+ const accepted = [], rejected = [];
436
+ for ( const p of (positives || []) ) {
437
+ if ( opts.target != null && p && p.methodId != null && !govern.has(p.methodId) ) { rejected.push(p); continue; }
438
+ if ( p && p.value !== undefined ) accepted.push(p.value);
439
+ }
440
+
441
+ const kept = pre.filter(( a ) => !isDiscPositive(a, disc)); // pt2 — preserve `!=` nogoods + every other atom
442
+ const union = []; const seen = new Set();
443
+ for ( const v of priorAllowed(pre, disc).concat(accepted) ) { const k = fmtVal(v); if ( !seen.has(k) ) { seen.add(k); union.push(v); } }
444
+
445
+ // the membership is a PARENTHESIZED DISJUNCTION of `==` — NOT an `in`-list: the engine gate (`expr.js`, jsep) does
446
+ // NOT support `in [..]` (verified — it raises "Unclosed ["), but `||`/`==` compile, and `holdsAtoms` evaluates the
447
+ // disjunction via its `expr.js` fallback. (The contract `pre` thus leaves the monadic fragment → `checkCompose`
448
+ // ESCALATEs a widened method — the honest eventual-soundness line: assume-at-compose gives way to assert-at-runtime.)
449
+ let widenAtom, forceInstance = false;
450
+ if ( opts.numeric === 'hull' && union.length && union.every(( v ) => typeof v === 'number' ) ) {
451
+ const lo = Math.min.apply(null, union), hi = Math.max.apply(null, union);
452
+ widenAtom = '$' + disc + '>=' + lo + ' && $' + disc + '<=' + hi;
453
+ forceInstance = true; // pt1 — a hull admits the gap → assertPost must guard every case → never FROZEN
454
+ } else {
455
+ const sorted = union.slice().sort(( a, b ) => typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b)));
456
+ widenAtom = '(' + sorted.map(( v ) => '$' + disc + '==' + fmtVal(v)).join(' || ') + ')';
457
+ }
458
+
459
+ const newPre = kept.concat([widenAtom]);
460
+ const read = (contract.read || []).slice();
461
+ if ( disc != null && read.indexOf(disc) < 0 ) read.push(disc);
462
+ // pt2 — report any accepted value still EXCLUDED by a surviving `!=` nogood (G wins; the clamp is `normalize`'s `ne`).
463
+ const clamped = accepted.filter(( v ) => kept.some(( a ) => String(a).replace(/\s/g, '') === ('$' + disc + '!=' + fmtVal(v)).replace(/\s/g, '')));
464
+ return { contract: Object.assign({}, contract, { pre: newPre, read }), ensure: newPre, widenAtom, accepted, rejected, clamped, forceInstance };
465
+ }
466
+
467
+ // the typed-coverage fraction of a workload (the §11 #5/#6 currency — MEASURED, never proven).
468
+ function acceptRate( results ) {
469
+ const xs = results || []; if ( !xs.length ) return { sound: 0, escalate: 0, unsound: 0, n: 0, rate: 0 };
470
+ const tally = { sound: 0, escalate: 0, unsound: 0 };
471
+ for ( const v of xs ) tally[v] = (tally[v] || 0) + 1;
472
+ return Object.assign(tally, { n: xs.length, rate: tally.sound / xs.length });
473
+ }
474
+
475
+ module.exports = { parseAtom, parseValue, normalize, entailsKey, checkCompose, footprintCycles, assertPost,
476
+ reviseOnBlame, widenOnVerified, satisfies, acceptRate, holdsAtoms };
@@ -0,0 +1,125 @@
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
+ * Corpus exchange (host-side, fs-free) — a portable `.sgc` bundle for moving a concept corpus
21
+ * (a grammar) between hosts/instances, with a DERIVED manifest so the receiver knows what the
22
+ * corpus needs and crosses.
23
+ *
24
+ * The worker boundary already proves a JSON concept map is a viable wire form (lib/runtime ships
25
+ * exactly that). What was missing for real portability is the METADATA — what providers the
26
+ * corpus calls, what facts it produces vs. consumes (its narrow-waist alphabet = the assume-
27
+ * guarantee contract), and what other sets it links to. `deriveManifest` computes all of that
28
+ * from the tree (via grammar-graph.js), so nothing has to be hand-maintained.
29
+ *
30
+ * const { packCorpus, unpackCorpus } = require('./corpus-pack');
31
+ * const bundle = packCorpus({ common: tree }, { name: 'common', version: '1.0.0' }); // -> .sgc JSON
32
+ * const { conceptMap, manifest, validation } = unpackCorpus(bundle, { validate: true });
33
+ *
34
+ * The on-disk JSONC tree stays canonical for editing (lib/load.js#exportConceptsToDir is the
35
+ * disk round-trip); the `.sgc` bundle is the single-file exchange artifact.
36
+ */
37
+ const { conceptFactGraph } = require('./grammar-graph');
38
+ const { validateConceptTree, eachConcept } = require('./validate');
39
+
40
+ // Accept either a concept map ({ set: tree }) or a single bare tree (-> { name: tree }).
41
+ function asSetMap( input, name ) {
42
+ if ( input && Object.prototype.hasOwnProperty.call(input, 'childConcepts') )
43
+ return { [name || 'corpus']: input };
44
+ return input || {};
45
+ }
46
+
47
+ // Every distinct `Ns::fn` provider string the corpus calls.
48
+ function providersOf( conceptMap ) {
49
+ const provs = new Set();
50
+ for ( const set of Object.keys(conceptMap) )
51
+ eachConcept(conceptMap[set], ( c ) => {
52
+ const schema = (c && c._schema) || c || {};
53
+ const p = schema.provider;
54
+ if ( !p ) return;
55
+ const name = Array.isArray(p) ? p[0] : p;
56
+ if ( typeof name === 'string' ) provs.add(name);
57
+ });
58
+ return [...provs].sort();
59
+ }
60
+
61
+ /**
62
+ * Derive the corpus manifest from its concept map (or single tree). All fields are computed —
63
+ * the only inputs are the optional name/version/description.
64
+ * @returns {{name,version,description,conceptSets,conceptCount,providersRequired,
65
+ * alphabet:{produces,consumes},crossCorpus,collisions,extends}}
66
+ */
67
+ function deriveManifest( conceptMap, meta ) {
68
+ meta = meta || {};
69
+ const map = asSetMap(conceptMap, meta.name);
70
+ const g = conceptFactGraph(map);
71
+ return {
72
+ name : meta.name || Object.keys(map).join('+'),
73
+ version : meta.version || '0.0.0',
74
+ description : meta.description || '',
75
+ conceptSets : Object.keys(map).sort(),
76
+ conceptCount : g.concepts.length,
77
+ providersRequired: providersOf(map),
78
+ // the narrow-waist alphabet: facts the corpus WRITES vs. external facts it READS
79
+ alphabet : {
80
+ produces: g.facts.filter(( f ) => f.producedBy.length).map(( f ) => f.key).sort(),
81
+ consumes: g.entryPoints.slice().sort()
82
+ },
83
+ crossCorpus : g.crossCorpus,
84
+ collisions : g.collisions,
85
+ extends : [...new Set(g.crossCorpus.map(( l ) => l.fromSet))].sort()
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Pack a corpus into a portable `.sgc` bundle (plain JSON).
91
+ * @param input concept map ({ set: tree }) or a single tree
92
+ * @param opts { name, version, description, seed }
93
+ */
94
+ function packCorpus( input, opts ) {
95
+ opts = opts || {};
96
+ const map = asSetMap(input, opts.name);
97
+ const bundle = {
98
+ format : 'sgc',
99
+ sgcVersion: 1,
100
+ manifest : deriveManifest(map, opts),
101
+ conceptMap: JSON.parse(JSON.stringify(map))
102
+ };
103
+ if ( opts.seed !== undefined ) bundle.seed = opts.seed;
104
+ return bundle;
105
+ }
106
+
107
+ /**
108
+ * Unpack a `.sgc` bundle. With `{ validate: true }` it runs validateConceptTree per set and
109
+ * returns the records (it never throws on a grammar error — the caller decides severity).
110
+ * @returns {{ conceptMap, manifest, seed, validation }}
111
+ */
112
+ function unpackCorpus( bundle, opts ) {
113
+ opts = opts || {};
114
+ if ( !bundle || bundle.format !== 'sgc' ) throw new Error('not an .sgc bundle');
115
+ const conceptMap = bundle.conceptMap || {};
116
+ let validation = null;
117
+ if ( opts.validate )
118
+ validation = Object.keys(conceptMap).map(( set ) => {
119
+ const r = validateConceptTree(conceptMap[set], opts.validateOpts || {});
120
+ return { set, errors: r.errors, warnings: r.warnings };
121
+ });
122
+ return { conceptMap, manifest: bundle.manifest, seed: bundle.seed, validation };
123
+ }
124
+
125
+ module.exports = { deriveManifest, packCorpus, unpackCorpus };