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,393 @@
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
+ * adapt — the adapt-or-forge CONTROLLER: the creative loop's drive (host-side, ZERO-CORE; study
10
+ * doc/WIP/studies/2026-06-30-creative-loop-two-level-grammar.md, brick C).
11
+ *
12
+ * The structuring grammar names a target mechanism; this drives retrieve-or-forge over the concept-DLL library:
13
+ * RETRIEVE dispatch (library.js) → a candidate with a template for THIS signature (K1-sound by construction) → HIT, 0 model calls.
14
+ * FORGE no hit → the model builds the method, REUSING the dispatched neighbours where it can (adapt = structural
15
+ * reuse + content forge; forge = fresh — the host's forge labels which) → VERIFIER GATE (it must carry a
16
+ * sound contract — the refuse-no-contract gate is HERE, not in crystallizeStructural, which admits a
17
+ * contractless method) → index back so the next encounter HITS
18
+ * (amortise). The measured master-loop retrieve-or-forge; the verifier is the born-defeasible contract.
19
+ *
20
+ * The contract's POST is an OUTPUT invariant (true after the method runs) — so it is NOT a pre-dispatch gate (that is
21
+ * the app-conditions, refined in dispatch). The gate here is "the forged method comes with a sound post" + an optional
22
+ * host `verify` hook (run it + `assertPost`); a drift that breaks the post is caught by the runtime monitor (the moat).
23
+ */
24
+ const { dispatch, indexMethod, dispatchInterface, appConditionsHold, frontierOf } = require('./library.js');
25
+ const { generalizeContent, fillContentHoles, blendAtSegment, combineAtFork, BASE } = require('./abstract.js');
26
+ const { digest } = require('../providers/canonicalize.js');
27
+ const { checkCompose } = require('./contract.js');
28
+
29
+ const idOf = ( o ) => o && (o.$$_id != null ? o.$$_id : (o.$_id != null ? o.$_id : o._id));
30
+ // the child-segment SLOTS of a parameterized method (base-derived segments — the graftable positions, never the
31
+ // outer `_parent`/frontier). The blend's snapped-separator candidates.
32
+ function segmentSlots( tpl ) {
33
+ return (Array.isArray(tpl) ? tpl : [tpl]).filter(( o ) => o && o.originNode != null && o.targetNode != null && typeof idOf(o) === 'string' && idOf(o).indexOf(BASE + '_') === 0).map(idOf);
34
+ }
35
+
36
+ const projectFacts = ( facts, keys ) => { const o = {}; for ( const k of (keys || []) ) if ( facts && k in facts ) o[k] = facts[k]; return o; };
37
+
38
+ /* ── the antiUnify CONTENT-FORGE adapt operator (study §"conceptual blending"; brick C, the richer adapt). ──
39
+ * The crude adapt re-forged the whole method (or hard-coded "swap field X"). This DISCOVERS the content holes by
40
+ * generalizing the neighbour's own templates (Plotkin LGG over its `templatesBySig`), forges ONLY those holes for
41
+ * the new signature, and reuses the skeleton + structural holes verbatim. Domain-agnostic: the host supplies a
42
+ * `contentFor(contentVars, scopeFacts) -> { <holePath>: value }` (the model filling exactly the discovered holes),
43
+ * never the template surgery. */
44
+
45
+ /** The content holes of a learned method (its `templatesBySig`): { stable, skeleton, contentVars:[{path}] }. */
46
+ function methodContentHoles( neighbour ) {
47
+ return generalizeContent(Object.values((neighbour && neighbour.templatesBySig) || {}));
48
+ }
49
+
50
+ /** Build an adapted candidate = the neighbour + a NEW signature template (its skeleton with the forged content
51
+ * filled). Pure + sync (the async model call is the caller's, before this). Returns null if a hole went unforged. */
52
+ function buildAdaptedCandidate( neighbour, scopeFacts, skeleton, valuesByPath, signatureKeys ) {
53
+ const filled = fillContentHoles(skeleton, valuesByPath);
54
+ if ( !filled ) return null; // a content hole was not forged → refuse (no undefined leaf)
55
+ const keys = signatureKeys || neighbour.signatureKeys || [];
56
+ const sig = digest(projectFacts(scopeFacts, keys));
57
+ const templatesBySig = Object.assign({}, neighbour.templatesBySig, { [sig]: filled });
58
+ return { candidate: Object.assign({}, neighbour, { templatesBySig }), template: filled, sig };
59
+ }
60
+
61
+ /** The controller's adapt operator: discover the neighbour's content holes → `contentFor` fills them (the model)
62
+ * → build the adapted candidate. Returns null if the holes can't be auto-discovered (need ≥2 differing templates)
63
+ * or a hole went unforged → the caller falls back to a fresh forge. `contentFor` is sync here (the controller is
64
+ * sync); an async (live) host pre-forges and calls `buildAdaptedCandidate` directly. */
65
+ function antiUnifyAdapt( opts ) {
66
+ const gen = methodContentHoles(opts.neighbour);
67
+ if ( !gen.stable || !gen.contentVars.length ) return null; // no auto-discoverable content holes
68
+ const vals = opts.contentFor(gen.contentVars, opts.scopeFacts);
69
+ const built = buildAdaptedCandidate(opts.neighbour, opts.scopeFacts, gen.skeleton, vals, opts.signatureKeys);
70
+ return built && Object.assign(built, { outcome: 'adapt', contentVars: gen.contentVars });
71
+ }
72
+
73
+ /* ── CONCEPTUAL BLENDING at the candidate level (Boden COMBINATIONAL creativity; study §"conceptual blending"). ──
74
+ * Graft a DONOR method's body into a HOST method's segment SLOT → a NEW method candidate that is neither (e.g. a
75
+ * 1-level decompose host + a decompose donor → a 2-level decompose). The blend's OUTER interface (frontier +
76
+ * signature) is the HOST's, unchanged — so the blended method dispatches + mounts exactly like the host, but with a
77
+ * deeper, recombined body. v0 INHERITS the host contract (the donor's writes are a superset; the runtime monitor
78
+ * `assertPost` catches drift — a COMPOSED contract over both write-sets is the follow-up). Returns null if the slot
79
+ * is absent / does not blend in every signature class. */
80
+ function blendMethods( hostCand, donorCand, opts ) {
81
+ opts = opts || {};
82
+ const donorTpl = Object.values((donorCand && donorCand.templatesBySig) || {})[0];
83
+ const entries = Object.entries((hostCand && hostCand.templatesBySig) || {});
84
+ if ( !donorTpl || !entries.length ) return null;
85
+ const slot = opts.atSegment || segmentSlots(entries[0][1])[0]; // default = the host's first child-segment slot
86
+ if ( !slot ) return null; // no graftable segment slot
87
+ const templatesBySig = {};
88
+ for ( const [sig, tpl] of entries ) { const b = blendAtSegment(tpl, slot, donorTpl); if ( !b ) return null; templatesBySig[sig] = b; }
89
+ const hid = hostCand.schema && hostCand.schema._id, did = donorCand.schema && donorCand.schema._id;
90
+ // The graft is CONTRACT-CHECKED at the interface (H2 — `checkCompose` was dead-wired; the compose path used to
91
+ // blindly union an UNVERIFIED contract that `hasSoundContract` then accepted). checkCompose(host, donor) discharges
92
+ // the donor's PRE against the host's POST on the shared write→read keys. DEFEASIBLE — "a formal system is a GROUND,
93
+ // not a ceiling": a provably-`unsound` graft (the donor requires what the host contradicts) is REFUSED; an
94
+ // under-determined (`escalate`) one is ADMITTED and FLAGGED (`composeVerdict`) so the runtime `assertPost` monitor —
95
+ // the moat — checks it at mount rather than the synthesis over-refusing what it cannot statically decide.
96
+ const verdict = checkCompose(hostCand.schema, donorCand.schema).verdict;
97
+ if ( verdict === 'unsound' ) return null; // donor.pre provably contradicts host.post → refuse the graft
98
+ // the blend's contract is DERIVED (composed) from both parents, not merely inherited — the donor's body adds
99
+ // writes/posts the host omits (closes the v0 "inherited contract" hole). Conservative union; the interface is now
100
+ // discharged above (the composed post is still runtime-monitored by `assertPost` for the `escalate` residue).
101
+ const composed = composeContract(hostCand.schema && hostCand.schema.contract, donorCand.schema && donorCand.schema.contract);
102
+ const schema = Object.assign({}, hostCand.schema, composed ? { contract: composed } : {});
103
+ return Object.assign({}, hostCand, { schema, templatesBySig, blendedFrom: [hid, did], blendSlot: slot, contractDerived: !!composed, composeVerdict: verdict });
104
+ }
105
+
106
+ /* ── BINARY COMBINATION — the SECOND composition operator (sibling to blendMethods). Where a blend NESTS a donor
107
+ * into a host slot (series → a deeper single query, with an interface obligation the donor.pre ⊨ host.post check
108
+ * discharges), a combine runs TWO complete methods in PARALLEL from the same origin and joins their results with a
109
+ * SET OPERATION (∩/∪/−) → a NEW method whose result = op(left,right). This is a SQL set-op query (`A INTERSECT B`):
110
+ * two INDEPENDENT sub-queries over the same source. Because the branches are independent (no data flows left→right),
111
+ * there is NO interface to discharge — so no checkCompose (unlike blend); the combined contract is just the derived
112
+ * UNION of both. Provenance in `combinedFrom`. The combined method's BODY is the LEFT's frontier (origin→target),
113
+ * but it COVERS the set-op class, not LEFT's operand class — so `opts.outerClass` RE-KEYS it (frontier /
114
+ * signatureKeys / libraryKey from the class the combine is invoked for) so it dispatches on the set-op signature and
115
+ * never false-hits a plain operand task (which stays in its own bucket). Without `outerClass` it inherits LEFT's key
116
+ * (back-compat: the direct-mount path in combine-methods-setop.test.js keys it by hand and never dispatches).
117
+ * Returns null if either method is empty. 0 model calls (pure structural recombination of two distilled grammars). */
118
+ function combineMethods( leftCand, rightCand, op, outerClass ) {
119
+ const rEntries = Object.values((rightCand && rightCand.templatesBySig) || {});
120
+ const lEntries = Object.entries((leftCand && leftCand.templatesBySig) || {});
121
+ if ( !rEntries.length || !lEntries.length ) return null;
122
+ const rightTpl = rEntries[0];
123
+ const templatesBySig = {};
124
+ for ( const [sig, ltpl] of lEntries ) { const c = combineAtFork(ltpl, rightTpl, op); if ( !c ) return null; templatesBySig[sig] = c; }
125
+ const lid = leftCand.schema && leftCand.schema._id, rid = rightCand.schema && rightCand.schema._id;
126
+ const composed = composeContract(leftCand.schema && leftCand.schema.contract, rightCand.schema && rightCand.schema.contract);
127
+ const schema = Object.assign({}, leftCand.schema, composed ? { contract: composed } : {});
128
+ const combined = Object.assign({}, leftCand, { schema, templatesBySig, combinedFrom: [lid, rid], setop: op, contractDerived: !!composed });
129
+ // RE-KEY to the OUTER set-op class (opt-in) — so indexMethod/dispatch bucket the combined method by the set-op
130
+ // signature, not LEFT's operand class. The stale inherited libraryKey is dropped so keyOf recomputes from the
131
+ // new frontier+signatureKeys (unless outerClass carries an explicit libraryKey).
132
+ if ( outerClass ) {
133
+ if ( outerClass.frontier ) { combined.frontier = outerClass.frontier; schema.frontier = outerClass.frontier; }
134
+ if ( outerClass.signatureKeys ) combined.signatureKeys = outerClass.signatureKeys.slice();
135
+ if ( outerClass.libraryKey ) { combined.libraryKey = outerClass.libraryKey; schema.libraryKey = outerClass.libraryKey; }
136
+ else { delete combined.libraryKey; delete schema.libraryKey; }
137
+ }
138
+ return combined;
139
+ }
140
+
141
+ /** The CANONICAL atom key — `$x==1` ≡ `x==1` ≡ `x == 1` (the Laurie-G3 atom-form fix: synthesizeContract emits
142
+ * bare atoms, reviseOnBlame/widenOnVerified emit `$`-prefixed ones; provenance matching must not care). Kept a
143
+ * READABLE atom string: leading ref-prefix stripped + whitespace removed — the same surface both the contract
144
+ * emitters produce, so a postFrom key stays greppable/debuggable (never an opaque serialization). */
145
+ const canonAtom = ( s ) => String(s).replace(/^\$\$?/, '').replace(/\s+/g, '');
146
+
147
+ /** Compose two born-defeasible contracts (crystallize.js#synthesizeContract shape) into the blend's contract:
148
+ * read/write/pre/post = the de-duplicated UNION; effect = pure iff BOTH pure. Conservative (over-approximates the
149
+ * write footprint, conjoins both posts). Returns null only if BOTH are absent.
150
+ * H3: the composed contract carries `postFrom` — which PARENT's post contributed each (canonical) post atom
151
+ * ('host' | 'donor' | 'both') — the graft-provenance the blame rule needs to avoid corrupting the standalone
152
+ * library (a=host, b=donor, matching the blendMethods call). */
153
+ function composeContract( a, b ) {
154
+ if ( !a && !b ) return null;
155
+ const postFrom = {};
156
+ for ( const p of ((a && a.post) || []) ) postFrom[canonAtom(p)] = 'host';
157
+ for ( const p of ((b && b.post) || []) ) { const k = canonAtom(p); postFrom[k] = postFrom[k] === 'host' ? 'both' : 'donor'; }
158
+ if ( !a || !b ) return Object.assign({}, a || b, { postFrom });
159
+ const uniq = ( xs ) => [...new Set(xs)];
160
+ return {
161
+ read: uniq([...(a.read || []), ...(b.read || [])]),
162
+ write: uniq([...(a.write || []), ...(b.write || [])]),
163
+ pre: uniq([...(a.pre || []), ...(b.pre || [])]),
164
+ post: uniq([...(a.post || []), ...(b.post || [])]),
165
+ effect: (a.effect === 'pure' && b.effect === 'pure') ? 'pure' : (a.effect || b.effect || 'pure'),
166
+ postFrom,
167
+ };
168
+ }
169
+
170
+ /** H3 — graft-provenance BLAME (the Laurie-G rule; the naive "carry blendedFrom into reviseOnBlame and blame
171
+ * the donor" OVER-PENALIZES a good donor for a bad composition and corrupts the standalone library). Given the
172
+ * post atoms `assertPost` reported violated on a mounted COMPOSITE, attribute each to an axis:
173
+ * 'donor' the atom is donor-only provenance on a `sound` graft → the donor's OWN post failed where used →
174
+ * the fix is revising the DONOR's pre (reviseOnBlame on the donor);
175
+ * 'host' symmetric;
176
+ * 'graft' provenance 'both' / unknown atom / `composeVerdict === 'escalate'` (the statically-undischarged
177
+ * interface — the prime suspect is the graft itself) → revise/retract the COMPOSITE only, parents
178
+ * stay intact.
179
+ * Overall axis = 'donor' (resp. 'host') iff EVERY failed atom reads donor (resp. host); anything mixed or
180
+ * uncertain → 'graft' (conservative: when in doubt, narrow the composite, never a parent).
181
+ * @returns { perAtom:[{atom,axis}], axis, host, donor, slot } — host/donor/slot from blendedFrom/blendSlot. */
182
+ function attributeCompositeBlame( opts ) {
183
+ const composite = (opts && opts.composite) || {};
184
+ const contract = (composite.schema && composite.schema.contract) || composite.contract || {};
185
+ const postFrom = contract.postFrom || {};
186
+ const escalate = composite.composeVerdict === 'escalate';
187
+ const perAtom = ((opts && opts.failedAtoms) || []).map(( atom ) => {
188
+ const prov = postFrom[canonAtom(atom)];
189
+ const axis = escalate ? 'graft' : (prov === 'donor' ? 'donor' : (prov === 'host' ? 'host' : 'graft'));
190
+ return { atom, axis };
191
+ });
192
+ const axes = new Set(perAtom.map(( x ) => x.axis));
193
+ const axis = (axes.size === 1 && !axes.has('graft')) ? [...axes][0] : 'graft';
194
+ const bf = composite.blendedFrom || [];
195
+ return { perAtom, axis, host: bf[0], donor: bf[1], slot: composite.blendSlot };
196
+ }
197
+
198
+ /** the signature digest a candidate would key its replay on at this site (null if it has no template for it = a miss). */
199
+ function hitTemplate( cand, scopeFacts ) {
200
+ const sig = digest(projectFacts(scopeFacts, cand.signatureKeys || []));
201
+ return (cand.templatesBySig || {})[sig] ? sig : null;
202
+ }
203
+
204
+ const hasSoundContract = ( cand ) => !!(cand && cand.schema && cand.schema.contract);
205
+
206
+ /**
207
+ * Drive retrieve-or-forge for a target mechanism at a site.
208
+ * @param opts.lib/target/scopeFacts the library + the abstract target FrontierSignature + the call-site facts.
209
+ * @param opts.forge (scopeFacts, neighbours) → { candidate, outcome?:'adapt'|'forge', calls?:number } | null
210
+ * the model: builds a new method (may REUSE the dispatched `neighbours` = adapt). Returns null = give up.
211
+ * @param opts.verify optional (candidate, scopeFacts) → boolean a stronger gate than "has a contract" (e.g. run + assertPost).
212
+ * @param opts.onForge optional (candidate) → void index the forged method (default: indexMethod into opts.lib).
213
+ * @param opts.requireContract default true — refuse a forged method with no sound post (the verifier gate).
214
+ * @returns { outcome:'hit'|'adapt'|'forge'|'reject', candidate?, sig?, calls, neighbours, reason? }
215
+ */
216
+ function adaptOrForge( opts ) {
217
+ const pre = _retrieveOrPrepare(opts);
218
+ if ( pre.done ) return pre.done;
219
+ const { neighbours, donors } = pre;
220
+
221
+ // ADAPT (antiUnify content-forge — the principled path, built INTO the controller): if a content-forger is given
222
+ // and the top neighbour has auto-discoverable content holes, forge ONLY those holes + reuse the skeleton + the
223
+ // structural holes verbatim (the neighbour's contract is inherited). Falls back to `opts.forge` (fresh) otherwise.
224
+ let f = null;
225
+ if ( opts.adaptContent && neighbours.length ) {
226
+ const a = antiUnifyAdapt({ neighbour: neighbours[0], scopeFacts: opts.scopeFacts, signatureKeys: neighbours[0].signatureKeys, contentFor: opts.adaptContent });
227
+ if ( a && a.candidate ) f = { candidate: a.candidate, outcome: 'adapt', calls: 1 }; // one content-forge model call
228
+ }
229
+ // BLEND (B.4 — compositional reuse, Boden combinational; opt-in `opts.blend`): before paying for a fresh
230
+ // FORGE, GRAFT a donor method into the host neighbour's segment slot — a STRUCTURAL recombination of two
231
+ // existing methods (0 model calls, contract-checked in blendMethods). If it does not graft (no slot /
232
+ // provably unsound) it falls through to forge; the blended candidate is then verifier-gated like any forge.
233
+ if ( !f && opts.blend && neighbours.length ) {
234
+ const donor = donors[0] || opts.donor;
235
+ const b = donor && blendMethods(neighbours[0], donor, typeof opts.blend === 'object' ? opts.blend : {});
236
+ if ( b ) f = { candidate: b, outcome: 'blend', calls: 0 };
237
+ }
238
+ // COMBINE (the 2nd composition operator — set-op; opt-in `opts.combine`, sibling to blend): before a fresh FORGE,
239
+ // if the host resolves this task as a set-op over two library methods, run them in PARALLEL and join with the op
240
+ // → a NEW method at 0 model calls (combineMethods). The host callback OWNS the decomposition (which two operands +
241
+ // which op), like target/dispatchFacts; the controller supplies the dispatched lib + facts and re-keys the result
242
+ // to the set-op class (spec.outerClass, default opts.target) so it never false-hits a plain operand task.
243
+ if ( !f && opts.combine ) {
244
+ const spec = opts.combine(opts.scopeFacts, opts.lib, neighbours, donors);
245
+ const c = spec && spec.left && spec.right && spec.op && combineMethods(spec.left, spec.right, spec.op, spec.outerClass || opts.target);
246
+ if ( c ) f = { candidate: c, outcome: 'combine', calls: 0 };
247
+ }
248
+ // FORGE / ADAPT — the model builds it, reusing the neighbours (+ §6.2 donor skeletons) where it can. Verifier-gated, indexed.
249
+ if ( !f ) f = opts.forge ? opts.forge(opts.scopeFacts, neighbours, donors) : null;
250
+ const rej = _rejectForged(opts, f, neighbours);
251
+ if ( rej ) return rej;
252
+ if ( opts.verify && !opts.verify(f.candidate, opts.scopeFacts) )
253
+ return { outcome: 'reject', reason: 'forged method failed the host verifier', calls: f.calls == null ? 1 : f.calls, neighbours };
254
+ return _acceptForged(opts, f, neighbours);
255
+ }
256
+
257
+ /**
258
+ * The ASYNC twin of `adaptOrForge` — the SAME retrieve/adapt/forge ladder and the SAME gates (shared
259
+ * helpers below, single source), for a LIVE host whose `forge` / `adaptContent` / `verify` are model
260
+ * calls (async). This is the brick a thin assembly (lib/combos/learning-library.js P3) wires as the
261
+ * master-loop's FORGE arm — without it, an async forge's Promise would fail `f.candidate` and every
262
+ * forge would silently `reject` (the sync controller cannot await).
263
+ * Same params as `adaptOrForge`; `forge`/`adaptContent`/`verify` may return promises.
264
+ * @returns Promise<{ outcome:'hit'|'adapt'|'forge'|'reject', candidate?, sig?, calls, neighbours, reason? }>
265
+ */
266
+ async function adaptOrForgeAsync( opts ) {
267
+ const pre = _retrieveOrPrepare(opts);
268
+ if ( pre.done ) return pre.done;
269
+ const { neighbours, donors } = pre;
270
+
271
+ // ADAPT — the awaited variant of `antiUnifyAdapt` (its docstring's "an async host pre-forges" path,
272
+ // packaged): discover the holes, AWAIT the content-forge, rebuild via the same pure builder.
273
+ let f = null;
274
+ if ( opts.adaptContent && neighbours.length ) {
275
+ const gen = methodContentHoles(neighbours[0]);
276
+ if ( gen.stable && gen.contentVars.length ) {
277
+ const vals = await opts.adaptContent(gen.contentVars, opts.scopeFacts);
278
+ const built = vals && buildAdaptedCandidate(neighbours[0], opts.scopeFacts, gen.skeleton, vals, neighbours[0].signatureKeys);
279
+ if ( built && built.candidate ) f = { candidate: built.candidate, outcome: 'adapt', calls: 1 };
280
+ }
281
+ }
282
+ // BLEND (B.4) — the async twin's compositional-reuse rung (opt-in `opts.blend`): graft a donor into the host
283
+ // neighbour before a fresh FORGE (0 model calls, contract-checked); the async verifier below gates it.
284
+ if ( !f && opts.blend && neighbours.length ) {
285
+ const donor = donors[0] || opts.donor;
286
+ const b = donor && blendMethods(neighbours[0], donor, typeof opts.blend === 'object' ? opts.blend : {});
287
+ if ( b ) f = { candidate: b, outcome: 'blend', calls: 0 };
288
+ }
289
+ // COMBINE (opt-in `opts.combine`) — the async twin's set-op composition rung (may resolve operands async).
290
+ if ( !f && opts.combine ) {
291
+ const spec = await opts.combine(opts.scopeFacts, opts.lib, neighbours, donors);
292
+ const c = spec && spec.left && spec.right && spec.op && combineMethods(spec.left, spec.right, spec.op, spec.outerClass || opts.target);
293
+ if ( c ) f = { candidate: c, outcome: 'combine', calls: 0 };
294
+ }
295
+ if ( !f ) f = opts.forge ? await opts.forge(opts.scopeFacts, neighbours, donors) : null;
296
+ const rej = _rejectForged(opts, f, neighbours);
297
+ if ( rej ) return rej;
298
+ if ( opts.verify && !(await opts.verify(f.candidate, opts.scopeFacts)) )
299
+ return { outcome: 'reject', reason: 'forged method failed the host verifier', calls: f.calls == null ? 1 : f.calls, neighbours };
300
+ return _acceptForged(opts, f, neighbours);
301
+ }
302
+
303
+ /** RETRIEVE phase, shared by both controllers: dispatch → a template hit is K1-sound by construction
304
+ * (0 model calls, `done`); else the neighbours + the §6.2 interface-recall DONOR skeletons (the
305
+ * NAC-failing in-bucket methods exact dispatch dropped — skeleton sources ONLY, never replayed:
306
+ * `_rejectForged` re-asserts the FORGED method's OWN appConditions; Laurie confront). */
307
+ function _retrieveOrPrepare( opts ) {
308
+ const r = dispatch(opts.lib, opts.target, opts.scopeFacts);
309
+ const neighbours = r.candidates.map(( e ) => e.candidate );
310
+ for ( const cand of neighbours ) {
311
+ const sig = hitTemplate(cand, opts.scopeFacts);
312
+ if ( sig ) return { done: { outcome: 'hit', candidate: cand, sig, calls: 0, neighbours } };
313
+ }
314
+ const donors = opts.interfaceRecall
315
+ ? dispatchInterface(opts.lib, opts.target, opts.scopeFacts, { k: opts.recallK || 3 }).proposals.map(( p ) => p.candidate)
316
+ : [];
317
+ return { neighbours, donors };
318
+ }
319
+
320
+ /** The forged-method REJECT gates, shared by both controllers (single source — the two paths must never
321
+ * disagree): no candidate → reject; no sound contract (unless `requireContract:false`) → reject; under
322
+ * interface-recall, the forged method's OWN appConditions must HOLD at the site — a donor replayed
323
+ * verbatim (its dropped NAC still failing here) is REJECTED, never mounted (the #29 false-hit;
324
+ * `appConditionsHold` = PRESENCE for require, never `satisfies` truthiness). Returns null = pass. */
325
+ function _rejectForged( opts, f, neighbours ) {
326
+ if ( !f || !f.candidate ) return { outcome: 'reject', reason: 'no forge / forge failed', calls: f && f.calls || 0, neighbours };
327
+ if ( opts.requireContract !== false && !hasSoundContract(f.candidate) )
328
+ return { outcome: 'reject', reason: 'forged method has no sound contract (verifier gate)', calls: f.calls == null ? 1 : f.calls, neighbours };
329
+ if ( opts.interfaceRecall && !appConditionsHold(frontierOf(f.candidate), opts.scopeFacts || {}) )
330
+ return { outcome: 'reject', reason: 'forged method appConditions fail at the site (donor not replayed)', calls: f.calls == null ? 1 : f.calls, neighbours };
331
+ return null;
332
+ }
333
+
334
+ /** Accept a gated forged/adapted method: index it (default into `opts.lib`) + the outcome record. */
335
+ function _acceptForged( opts, f, neighbours ) {
336
+ (opts.onForge || (( c ) => indexMethod(opts.lib, c)))(f.candidate);
337
+ const outcome = f.outcome === 'adapt' ? 'adapt' : f.outcome === 'blend' ? 'blend' : f.outcome === 'combine' ? 'combine' : 'forge';
338
+ return { outcome, candidate: f.candidate, calls: f.calls == null ? 1 : f.calls, neighbours, blendedFrom: f.candidate && f.candidate.blendedFrom, combinedFrom: f.candidate && f.candidate.combinedFrom };
339
+ }
340
+
341
+ /** The structural DEPTH of a method = the deepest nesting of its created mid nodes (`⟦@base⟧_m0` = 1,
342
+ * `⟦@base⟧_a0_m0` = 2, …). The μ-measure the blend search descends on. */
343
+ function methodDepth( cand ) {
344
+ let max = 0;
345
+ for ( const tpl of Object.values((cand && cand.templatesBySig) || {}) ) {
346
+ for ( const o of (Array.isArray(tpl) ? tpl : [tpl]) ) {
347
+ const id = o && (o.$$_id != null ? o.$$_id : (o.$_id != null ? o.$_id : o._id));
348
+ if ( typeof id === 'string' && id.indexOf(BASE + '_') === 0 && /_m0$/.test(id) ) {
349
+ const d = (id.slice(BASE.length).match(/_/g) || []).length; // underscores in the suffix = nesting level
350
+ if ( d > max ) max = d;
351
+ }
352
+ }
353
+ }
354
+ return max;
355
+ }
356
+
357
+ /* ── the BLEND STRATEGY (the creativity AUTOMATION; study §"well-foundedness"): when no single dispatched method
358
+ * SATISFIES the goal, BLEND neighbours to DEEPEN — bounded compositional search with a TERMINATION guarantee.
359
+ * Greedy + μ-descent: host = top neighbour, donor = `opts.donor` (default the top neighbour, i.e. self-deepen);
360
+ * each blend strictly INCREASES `methodDepth` (the μ-measure), so the loop terminates at `maxDepth` (no `divergent`
361
+ * runaway — the AO* admissible-heuristic discipline the study demanded). 0 model calls: the deeper method is
362
+ * SYNTHESIZED from library parts, not forged. A cache cannot (it replays); crystallize cannot without ≥2 traces.
363
+ * @param opts.lib/target/scopeFacts as adaptOrForge (the dispatch inputs).
364
+ * @param opts.satisfies (candidate, scopeFacts) → bool the goal test (host runs/mounts, or a template predicate).
365
+ * @param opts.donor optional candidate to graft (default = the top dispatched neighbour).
366
+ * @param opts.maxDepth the blend-depth cap (default 3) — the well-foundedness bound.
367
+ * @returns { outcome:'retrieve'|'blend'|'reject', candidate, depth, calls, reason? }
368
+ */
369
+ function synthesizeByBlend( opts ) {
370
+ const r = dispatch(opts.lib, opts.target, opts.scopeFacts);
371
+ const neighbours = r.candidates.map(( e ) => e.candidate );
372
+ if ( !neighbours.length ) return { outcome: 'reject', reason: 'no neighbour to compose', candidate: null, calls: 0 };
373
+ let cand = neighbours[0];
374
+ if ( opts.satisfies(cand, opts.scopeFacts) ) return { outcome: 'retrieve', candidate: cand, depth: methodDepth(cand), calls: 0 };
375
+ const donor = opts.donor || neighbours[0];
376
+ const maxDepth = opts.maxDepth || 3;
377
+ const underscores = ( s ) => (s.match(/_/g) || []).length;
378
+ let last = methodDepth(cand);
379
+ for ( let i = 0; i < maxDepth; i++ ) {
380
+ // graft at the DEEPEST current slot so iterated blends DESCEND (each step deepens that branch by one level).
381
+ const deepest = segmentSlots(Object.values(cand.templatesBySig)[0]).sort(( a, b ) => underscores(b) - underscores(a))[0];
382
+ if ( !deepest ) break;
383
+ const blended = blendMethods(cand, donor, { atSegment: deepest });
384
+ if ( !blended ) break; // no graftable slot → cannot deepen further
385
+ const d = methodDepth(blended);
386
+ if ( d <= last ) break; // μ-descent guard: no strict progress → stop (no spin)
387
+ last = d; cand = blended;
388
+ if ( opts.satisfies(cand, opts.scopeFacts) ) return { outcome: 'blend', candidate: cand, depth: d, calls: 0 };
389
+ }
390
+ return { outcome: 'reject', reason: 'bounded blend (maxDepth/μ-descent) did not satisfy the goal', candidate: null, depth: last, calls: 0 };
391
+ }
392
+
393
+ module.exports = { adaptOrForge, adaptOrForgeAsync, hitTemplate, hasSoundContract, antiUnifyAdapt, methodContentHoles, buildAdaptedCandidate, blendMethods, combineMethods, segmentSlots, composeContract, attributeCompositeBlame, synthesizeByBlend, methodDepth, canonAtom };
@@ -0,0 +1,216 @@
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
+ * ancestry — §6.3(b) the ANCESTRY ORACLE: content→param PROMOTION (host-side, ZERO-CORE; spec §3.2, refined by the
10
+ * 2026-06-30 Laurie confront). Relative LGG / bounded ij-determinacy (Plotkin 1971; GOLEM, Muggleton-Feng 1990).
11
+ *
12
+ * A crystallized method's VARYING content leaf is one of three bins (NEVER dropped — "ignorable" is unsound):
13
+ * • BAKE (baked+keyed) — constant across ALL instances AND digests (`signatureDetermined` is within-digest only;
14
+ * `generalizeContent` folds across digests → a within-digest constant could bake a premise-dependent value
15
+ * above the memo key). The mount stays gated on a digest hit.
16
+ * • PROMOTE (rebound+verified) — the identity FD `value(f)=N(s).g` (or a deterministic field-projection `g.field`)
17
+ * holds, g is BELOW the separator horizon (g ∈ Σ_sep), and it SURVIVES a held-out strict-=== check. The leaf
18
+ * becomes a frontier ref bound from the ancestor at the call site, with an EXACT relational post `$leaf==$g`.
19
+ * • FORGE (forged+verified) — everything else (the always-sound catch-all: the model re-derives, assertPost verifies).
20
+ *
21
+ * THE SOUNDNESS SPINE (Laurie confront — the SAME §6.4 hazard): the promote bin is BY CONSTRUCTION the varying-leaf
22
+ * case, so `crystallize.js#invariantAtom` would give it an over-approximating BAND → a spurious g whose wrong value
23
+ * lands in the band passes `assertPost` silently. PAC (Džeroski-Muggleton-Russell 1992) does NOT save it (workload
24
+ * firings are non-iid; the engine deploys under drift). The fix:
25
+ * (1) minK floor ≥ 3 (the union-bound over |Σ_sep| is advisory — `opts.minK` raises it; k bounds the in-sample
26
+ * false-discovery rate, NOT soundness);
27
+ * (2) a MANDATORY held-out (≥1 withheld instance) verified by strict `===` — the negative-based reduction; the only
28
+ * in-sample test that kills an in-distribution spurious g;
29
+ * (3) the promoted post is the EXACT relation `$leaf==$g`, NOT the band → a future divergence is caught at mount.
30
+ * Ambiguity (≥2 survivors) → FORGE (the version-space join ⊤ = a fresh variable = forge IS the Plotkin/Mitchell answer).
31
+ */
32
+ const { REF } = require('./abstract.js');
33
+
34
+ function canon( x ) {
35
+ if ( x === undefined ) return 'u';
36
+ if ( x === null || typeof x !== 'object' ) return JSON.stringify(x);
37
+ if ( Array.isArray(x) ) return '[' + x.map(canon).join(',') + ']';
38
+ return '{' + Object.keys(x).sort().map(( k ) => JSON.stringify(k) + ':' + canon(x[k])).join(',') + '}';
39
+ }
40
+ const eq = ( a, b ) => canon(a) === canon(b);
41
+
42
+ // the ancestor value a hypothesis reads: identity `N(s).g` or field-projection `N(s).g.field` (the ONLY two φ — a
43
+ // lookup-table φ is a memo, affine/multi-arg fit a free parameter → spurious-rate explosion, deferred).
44
+ function ancestorVal( ancestry, cand ) {
45
+ const g = (ancestry || {})[cand.g];
46
+ if ( cand.field == null ) return g;
47
+ return (g && typeof g === 'object') ? g[cand.field] : undefined;
48
+ }
49
+
50
+ /**
51
+ * Decide the bin for ONE content leaf from its per-instance observations `[{ value, ancestry:{factName:value}, digest? }]`.
52
+ * @param opts.sigmaSep the separator horizon (decompose.js#separatorGate / bagInterface) — g must be ∈ this.
53
+ * @param opts.minK the determinacy floor (≥3; the union-bound over |Σ_sep| is the host's to raise).
54
+ * @param opts.fieldProjection allow the `g.field` φ (default false).
55
+ * @param opts.leafKey the fact key the leaf writes (for the exact post `$<leafKey>==$<ref>`; default 'leaf').
56
+ * @returns { bin:'bake'|'promote'|'forge', promotion?, post?, value?, reason, fitCandidates, survivors }
57
+ */
58
+ function decideLeaf( opts ) {
59
+ opts = opts || {};
60
+ const obs = (opts.observations || []).filter(Boolean);
61
+ const sigma = new Set(opts.sigmaSep || []);
62
+ const minFit = Math.max(3, opts.minK || 3);
63
+ const leafKey = opts.leafKey || 'leaf';
64
+
65
+ // need ≥ minFit instances to FIT + ≥1 HELD-OUT (the negative-based reduction). Else FORGE (the safe catch-all).
66
+ if ( obs.length < minFit + 1 ) return { bin: 'forge', reason: 'insufficient-instances (need minK+1; got ' + obs.length + ')', fitCandidates: [], survivors: [] };
67
+ const fit = obs.slice(0, obs.length - 1), held = obs[obs.length - 1];
68
+ const vals = fit.map(( o ) => o.value );
69
+
70
+ // (4) BAKE — constant across ALL observations (incl. held-out). Cross-digest constancy is the soundness condition.
71
+ if ( obs.every(( o ) => eq(o.value, obs[0].value)) ) {
72
+ const digests = new Set(obs.map(( o ) => o.digest).filter(( d ) => d != null));
73
+ return { bin: 'bake', reason: digests.size > 1 ? 'constant-cross-digest' : 'constant', value: obs[0].value, crossDigest: digests.size > 1, fitCandidates: [], survivors: [] };
74
+ }
75
+
76
+ // the hypothesis space H = { identity g } ∪ { field-projection g.field }, g ∈ Σ_sep (the horizon).
77
+ const anc0 = fit[0].ancestry || {};
78
+ const H = [];
79
+ for ( const g of Object.keys(anc0) ) {
80
+ if ( !sigma.has(g) ) continue;
81
+ H.push({ g });
82
+ if ( opts.fieldProjection && anc0[g] && typeof anc0[g] === 'object' )
83
+ for ( const field of Object.keys(anc0[g]) ) H.push({ g, field });
84
+ }
85
+
86
+ // fit candidates: the FD holds on EVERY fit instance.
87
+ const fitCandidates = H.filter(( c ) => fit.every(( o, k ) => o.ancestry && eq(vals[k], ancestorVal(o.ancestry, c))));
88
+ // survivors: ALSO predict the held-out leaf by strict === (eliminate an in-distribution spurious g).
89
+ const survivors = [];
90
+ const seen = new Set();
91
+ for ( const c of fitCandidates ) {
92
+ if ( !eq(held.value, ancestorVal(held.ancestry, c)) ) continue;
93
+ const k = c.g + '|' + (c.field || '');
94
+ if ( !seen.has(k) ) { seen.add(k); survivors.push(c); }
95
+ }
96
+
97
+ if ( survivors.length === 1 ) {
98
+ // a colon-free ref name (a colon is the cross-walk syntax → would mis-resolve in `expr.js`/`assertPost`).
99
+ const c = survivors[0], refName = 'anc_' + c.g + (c.field ? '_' + c.field : '');
100
+ return { bin: 'promote', promotion: { ref: REF(refName), refName, ancestorFact: c.g, field: c.field || null, leafKey },
101
+ post: '$' + leafKey + '==$' + refName, fitCandidates, survivors };
102
+ }
103
+ return { bin: 'forge', fitCandidates, survivors,
104
+ reason: survivors.length ? 'ambiguous-version-space-join (' + survivors.length + ' survivors)' : 'no-determinate-ancestor' };
105
+ }
106
+
107
+ // the leaf's fact key from a generalizeContent path (`[1].state` → `state`; `state` → `state`).
108
+ function leafKeyOf( path ) { const parts = String(path).split('.'); return parts[parts.length - 1].replace(/\[\d+\]/g, '') || 'leaf'; }
109
+
110
+ // rewrite a generalizeContent skeleton: a promoted `{'§var':p}` hole → its frontier REF; a baked hole → its literal;
111
+ // a forged hole → left as the content hole (the model fills it).
112
+ function rewriteSkeleton( skel, byPath ) {
113
+ if ( Array.isArray(skel) ) return skel.map(( x ) => rewriteSkeleton(x, byPath));
114
+ if ( skel && typeof skel === 'object' ) {
115
+ if ( '§var' in skel && Object.keys(skel).length === 1 ) { const p = skel['§var']; return (p in byPath) ? byPath[p] : skel; }
116
+ const o = {}; for ( const k in skel ) o[k] = rewriteSkeleton(skel[k], byPath); return o;
117
+ }
118
+ return skel;
119
+ }
120
+
121
+ /**
122
+ * Batch the promotion over a method's content vars + rewrite the skeleton.
123
+ * @param opts.skeleton the `generalizeContent` skeleton (with `{'§var':path}` holes).
124
+ * @param opts.leaves [{ path, observations:[{value,ancestry,digest?}] }] — one per content var.
125
+ * @returns { promoted:[{path,ancestorFact,field,refName}], baked:[path], forged:[path], posts:[atom], bins:{path:bin}, skeleton }
126
+ */
127
+ function promoteContentVars( opts ) {
128
+ opts = opts || {};
129
+ const out = { promoted: [], baked: [], forged: [], posts: [], bins: {} };
130
+ const byPath = {};
131
+ for ( const leaf of (opts.leaves || []) ) {
132
+ const d = decideLeaf({ observations: leaf.observations, sigmaSep: opts.sigmaSep, minK: opts.minK, fieldProjection: opts.fieldProjection, leafKey: leafKeyOf(leaf.path) });
133
+ out.bins[leaf.path] = d.bin;
134
+ if ( d.bin === 'promote' ) { out.promoted.push(Object.assign({ path: leaf.path }, d.promotion)); out.posts.push(d.post); byPath[leaf.path] = d.promotion.ref; }
135
+ else if ( d.bin === 'bake' ) { out.baked.push(leaf.path); byPath[leaf.path] = d.value; }
136
+ else out.forged.push(leaf.path);
137
+ }
138
+ out.skeleton = rewriteSkeleton(opts.skeleton, byPath);
139
+ return out;
140
+ }
141
+
142
+ // parse a generalizeContent path (`[1].state`) into navigation tokens; set the value at it (immutably).
143
+ function parsePath( path ) {
144
+ const toks = [], re = /\[(\d+)\]|\.?([A-Za-z_]\w*)/g; let m;
145
+ while ( (m = re.exec(path)) ) { if ( m[1] != null ) toks.push({ i: +m[1] }); else if ( m[2] != null ) toks.push({ k: m[2] }); }
146
+ return toks;
147
+ }
148
+ function setAtPath( tpl, path, value ) {
149
+ const toks = parsePath(path), clone = JSON.parse(JSON.stringify(tpl));
150
+ let cur = clone;
151
+ for ( let i = 0; i < toks.length - 1; i++ ) { const t = toks[i]; cur = t.i != null ? cur[t.i] : cur[t.k]; if ( cur == null ) return clone; }
152
+ const last = toks[toks.length - 1];
153
+ if ( last.i != null ) cur[last.i] = value; else cur[last.k] = value;
154
+ return clone;
155
+ }
156
+ const projectFacts = ( facts, keys ) => { const o = {}; for ( const k of (keys || []) ) if ( facts && k in facts ) o[k] = facts[k]; return o; };
157
+
158
+ /**
159
+ * §6.3(b) WIRING — enhance a crystallized candidate with ancestry promotions. A content leaf the ancestry oracle
160
+ * promotes (`decideLeaf` → promote) becomes a frontier REF bound from the ancestor at the call site; the EXACT post
161
+ * `$leaf==$g` is added; and (opt-in `dropKeys`) the now-redundant signature key is DROPPED — so when promotion removes
162
+ * the SOLE differentiator the templates collapse to one and the method GENERALIZES to UNSEEN signature classes (it
163
+ * rebinds the leaf from the ancestor) instead of bypassing. Sound by the held-out FD + the exact post (the runtime monitor).
164
+ * @param candidate a crystallizeStructural candidate `{ schema, providerName, signatureKeys, frontierFields, templatesBySig }`.
165
+ * @param opts.leaves [{ path, observations }] — the content leaves + their per-instance (value, ancestry).
166
+ * @param opts.sigmaSep/minK/fieldProjection forwarded to `decideLeaf` (the horizon + the determinacy discipline).
167
+ * @param opts.ancestorField (g) => the scope field the ancestor fact lives on (default `g` — the cast object's own fact;
168
+ * pass e.g. (g)=>'originNode:'+g for a predecessor cross-walk, which `ctxFromScope` resolves).
169
+ * @param opts.dropKeys drop a promoted ancestor from `signatureKeys` (+ collapse templates) when it leaves an
170
+ * empty reduced signature and all rewritten templates coincide (the generalization). Default false.
171
+ * @returns the ENHANCED candidate (templates rewritten, frontier params + posts added, provider rebuilt) + `promotions`.
172
+ */
173
+ function enhanceCandidateWithAncestry( candidate, opts ) {
174
+ opts = opts || {};
175
+ const { buildStructuralProvider } = require('./crystallize.js'); // lazy (avoid any load-order coupling)
176
+ const ancestorField = opts.ancestorField || (( g ) => g);
177
+ const cand = JSON.parse(JSON.stringify({ schema: candidate.schema, signatureKeys: candidate.signatureKeys || [], frontierFields: candidate.frontierFields || {}, templatesBySig: candidate.templatesBySig || {} }));
178
+
179
+ const promotions = [];
180
+ for ( const leaf of (opts.leaves || []) ) {
181
+ const d = decideLeaf({ observations: leaf.observations, sigmaSep: opts.sigmaSep, minK: opts.minK, fieldProjection: opts.fieldProjection, leafKey: leafKeyOf(leaf.path) });
182
+ if ( d.bin === 'promote' ) promotions.push({ path: leaf.path, refName: d.promotion.refName, ancestorFact: d.promotion.ancestorFact, field: d.promotion.field, post: d.post });
183
+ }
184
+ if ( !promotions.length ) return Object.assign({}, candidate, { promotions: [] });
185
+
186
+ // rewrite every template: the promoted leaf → its frontier REF.
187
+ for ( const sig of Object.keys(cand.templatesBySig) )
188
+ for ( const pr of promotions ) cand.templatesBySig[sig] = setAtPath(cand.templatesBySig[sig], pr.path, REF(pr.refName));
189
+
190
+ // add the frontier params + frontierFields + the EXACT posts; (opt-in) drop the redundant signature key.
191
+ cand.schema.frontier = cand.schema.frontier || { params: [], appConditions: { require: [], assert: [] } };
192
+ cand.schema.contract = cand.schema.contract || { read: [], write: [], pre: [], post: [], effect: 'pure' };
193
+ for ( const pr of promotions ) {
194
+ const fieldPath = ancestorField(pr.ancestorFact) + (pr.field ? '.' + pr.field : '');
195
+ cand.schema.frontier.params.push({ name: pr.refName, sort: 'node-ref', role: 'endpoint', field: fieldPath, requiredFacts: [] });
196
+ cand.frontierFields[pr.refName] = fieldPath;
197
+ if ( cand.schema.contract.post.indexOf(pr.post) < 0 ) cand.schema.contract.post.push(pr.post);
198
+ if ( opts.dropKeys ) cand.signatureKeys = cand.signatureKeys.filter(( k ) => k !== pr.ancestorFact);
199
+ }
200
+
201
+ // COLLAPSE: if dropping left an empty reduced signature and all rewritten templates now coincide (the promoted
202
+ // leaf was the SOLE differentiator), re-key to ONE template under the reduced-signature digest → generalizes.
203
+ if ( opts.dropKeys && !cand.signatureKeys.length ) {
204
+ const tpls = Object.values(cand.templatesBySig);
205
+ const allSame = tpls.every(( t ) => canon(t) === canon(tpls[0]));
206
+ if ( allSame && tpls.length ) cand.templatesBySig = { [digestOf(projectFacts({}, cand.signatureKeys))]: tpls[0] };
207
+ }
208
+
209
+ const provider = buildStructuralProvider({ cryId: cand.schema._name, frontier: cand.schema.frontier, frontierFields: cand.frontierFields, templatesBySig: cand.templatesBySig, signatureKeys: cand.signatureKeys });
210
+ return { schema: cand.schema, providerName: candidate.providerName, provider, signatureKeys: cand.signatureKeys, frontierFields: cand.frontierFields, templatesBySig: cand.templatesBySig, promotions };
211
+ }
212
+
213
+ // the digest the structural provider keys on (must match buildStructuralProvider's `digest(projectFacts(...))`).
214
+ const digestOf = require('../providers/canonicalize.js').digest;
215
+
216
+ module.exports = { decideLeaf, promoteContentVars, rewriteSkeleton, leafKeyOf, enhanceCandidateWithAncestry, setAtPath };