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.
- package/CONTRIBUTING.md +22 -0
- package/LICENSE +661 -0
- package/README.md +245 -0
- package/bin/sg +20 -0
- package/concepts/README.md +23 -0
- package/concepts/_substrate/Claim/Confidence/Trusted.json +6 -0
- package/concepts/_substrate/Claim/Confidence.json +10 -0
- package/concepts/_substrate/Claim/Freshness/Stale.json +7 -0
- package/concepts/_substrate/Claim/Freshness.json +5 -0
- package/concepts/_substrate/Claim/Verification/Refuted.json +6 -0
- package/concepts/_substrate/Claim/Verification.json +6 -0
- package/concepts/_substrate/Claim.json +4 -0
- package/concepts/_substrate/Frontier/InBeam.json +6 -0
- package/concepts/_substrate/Frontier/Stuck.json +6 -0
- package/concepts/_substrate/Frontier.json +5 -0
- package/concepts/_substrate/Intake/ToTask.json +22 -0
- package/concepts/_substrate/Intake/Typed.json +10 -0
- package/concepts/_substrate/Intake.json +27 -0
- package/concepts/_substrate/Task/Answer.json +12 -0
- package/concepts/_substrate/Task/Complexity/Atomic.json +5 -0
- package/concepts/_substrate/Task/Complexity/Compound/Expansion.json +7 -0
- package/concepts/_substrate/Task/Complexity/Compound.json +6 -0
- package/concepts/_substrate/Task/Complexity.json +6 -0
- package/concepts/_substrate/Task/EvalComplexity.json +13 -0
- package/concepts/_substrate/Task/ReportUp.json +6 -0
- package/concepts/_substrate/Task/Rollup.json +10 -0
- package/concepts/_substrate/Task.json +5 -0
- package/concepts/clinical/Diagnosis/Medication.json +6 -0
- package/concepts/clinical/Diagnosis.json +15 -0
- package/concepts/clinical/Observation/LabValue/OutOfRange.json +6 -0
- package/concepts/clinical/Observation/LabValue.json +5 -0
- package/concepts/clinical/Observation.json +6 -0
- package/concepts/common/Document/pathBasket.json +6 -0
- package/concepts/common/Document.json +3 -0
- package/concepts/common/Edge/Distance.json +13 -0
- package/concepts/common/Edge/Stay/LongStay.json +8 -0
- package/concepts/common/Edge/Stay.json +8 -0
- package/concepts/common/Edge/Travel/LongTravel.json +16 -0
- package/concepts/common/Edge/Travel/ShortTravel.json +11 -0
- package/concepts/common/Edge/Travel/targetNode.json +7 -0
- package/concepts/common/Edge/Travel.json +7 -0
- package/concepts/common/Edge.json +3 -0
- package/concepts/common/Vertice.json +3 -0
- package/concepts/supply/Fulfillment.json +10 -0
- package/concepts/supply/Inventory/Reorder.json +7 -0
- package/concepts/supply/Inventory.json +5 -0
- package/concepts/supply/Procurement/SupplierConfirm.json +6 -0
- package/concepts/supply/Procurement.json +8 -0
- package/concepts/supply/Transport.json +5 -0
- package/doc/API.md +556 -0
- package/doc/CAPABILITIES.md +373 -0
- package/doc/MODELISATION.md +511 -0
- package/doc/architecture.md +267 -0
- package/doc/concept-as-graph.md +379 -0
- package/doc/concept-learning.md +168 -0
- package/doc/creative-loop-map.md +202 -0
- package/doc/original-2016-doc.md +148 -0
- package/doc/usage.md +501 -0
- package/lib/authoring/abstract.js +401 -0
- package/lib/authoring/abstraction.js +190 -0
- package/lib/authoring/adapt.js +393 -0
- package/lib/authoring/ancestry.js +216 -0
- package/lib/authoring/author.js +147 -0
- package/lib/authoring/bounded-merge.js +57 -0
- package/lib/authoring/canon.js +158 -0
- package/lib/authoring/clock.js +123 -0
- package/lib/authoring/combinator.js +92 -0
- package/lib/authoring/compete.js +76 -0
- package/lib/authoring/compose-hotspot.js +314 -0
- package/lib/authoring/compress.js +279 -0
- package/lib/authoring/concept-net.js +254 -0
- package/lib/authoring/concepts.js +66 -0
- package/lib/authoring/context-project.js +277 -0
- package/lib/authoring/contract.js +476 -0
- package/lib/authoring/corpus-pack.js +125 -0
- package/lib/authoring/cost-probe.js +86 -0
- package/lib/authoring/crystallize.js +555 -0
- package/lib/authoring/dag-decompose.js +256 -0
- package/lib/authoring/dataset-adapter.js +253 -0
- package/lib/authoring/debug-provider.js +92 -0
- package/lib/authoring/decompose.js +343 -0
- package/lib/authoring/emittability.js +236 -0
- package/lib/authoring/equilibrium.js +160 -0
- package/lib/authoring/extract.js +153 -0
- package/lib/authoring/forest.js +55 -0
- package/lib/authoring/forge-fallback.js +116 -0
- package/lib/authoring/givens.js +121 -0
- package/lib/authoring/glossary.js +177 -0
- package/lib/authoring/grammar-graph.js +191 -0
- package/lib/authoring/granularity.js +80 -0
- package/lib/authoring/graph-net.js +108 -0
- package/lib/authoring/ground.js +148 -0
- package/lib/authoring/higher-order.js +85 -0
- package/lib/authoring/hotspot.js +132 -0
- package/lib/authoring/hysteresis.js +85 -0
- package/lib/authoring/index.js +106 -0
- package/lib/authoring/lattice-morphism.js +61 -0
- package/lib/authoring/lattice-pack.js +148 -0
- package/lib/authoring/leaf-io.js +77 -0
- package/lib/authoring/library.js +156 -0
- package/lib/authoring/lifecycle.js +92 -0
- package/lib/authoring/loop.js +193 -0
- package/lib/authoring/master-loop.js +132 -0
- package/lib/authoring/mdl.js +209 -0
- package/lib/authoring/memo-stability.js +122 -0
- package/lib/authoring/method-explorer.js +138 -0
- package/lib/authoring/method-pack.js +181 -0
- package/lib/authoring/method.js +202 -0
- package/lib/authoring/mine.js +510 -0
- package/lib/authoring/mount.js +119 -0
- package/lib/authoring/negotiate.js +100 -0
- package/lib/authoring/parametric.js +139 -0
- package/lib/authoring/reaggregate.js +75 -0
- package/lib/authoring/rebalance.js +157 -0
- package/lib/authoring/recall.js +135 -0
- package/lib/authoring/registry.js +350 -0
- package/lib/authoring/relearn.js +147 -0
- package/lib/authoring/retention.js +89 -0
- package/lib/authoring/segment-proxy.js +114 -0
- package/lib/authoring/serve-leaf.js +64 -0
- package/lib/authoring/slot-aware-serve.js +77 -0
- package/lib/authoring/sound-invoke.js +59 -0
- package/lib/authoring/split-serve.js +74 -0
- package/lib/authoring/ste.js +239 -0
- package/lib/authoring/stock.js +101 -0
- package/lib/authoring/store.js +94 -0
- package/lib/authoring/supervise.js +138 -0
- package/lib/authoring/support.js +131 -0
- package/lib/authoring/task-mirror.js +51 -0
- package/lib/authoring/typed-loop.js +160 -0
- package/lib/authoring/validate.js +569 -0
- package/lib/authoring/widen.js +88 -0
- package/lib/combos/appliance.js +189 -0
- package/lib/combos/critique.js +377 -0
- package/lib/combos/defaults.js +86 -0
- package/lib/combos/durable-runner.js +115 -0
- package/lib/combos/forge.js +209 -0
- package/lib/combos/index.js +86 -0
- package/lib/combos/learning-library.js +253 -0
- package/lib/combos/mixture-serve.js +193 -0
- package/lib/combos/plan-loop.js +192 -0
- package/lib/combos/proxy-cache.js +272 -0
- package/lib/combos/self-mod.js +94 -0
- package/lib/durable/audit.js +102 -0
- package/lib/durable/checkpoint-store.js +550 -0
- package/lib/durable/fold.js +62 -0
- package/lib/durable/interpreter.js +242 -0
- package/lib/durable/xlate.js +185 -0
- package/lib/graph/Graph.js +2486 -0
- package/lib/graph/expr.js +307 -0
- package/lib/graph/index.js +26 -0
- package/lib/graph/log.js +134 -0
- package/lib/graph/objects/Concept.js +382 -0
- package/lib/graph/objects/Entity.js +503 -0
- package/lib/graph/objects/Node.js +57 -0
- package/lib/graph/objects/PathMap.js +557 -0
- package/lib/graph/objects/Segment.js +100 -0
- package/lib/graph/tasks/stabilize.js +59 -0
- package/lib/graph/tasks/taskflow.js +247 -0
- package/lib/index.js +122 -0
- package/lib/load.js +132 -0
- package/lib/providers/backends.js +117 -0
- package/lib/providers/borderline.js +129 -0
- package/lib/providers/cache.js +226 -0
- package/lib/providers/canonicalize.js +198 -0
- package/lib/providers/constat.js +107 -0
- package/lib/providers/geo.js +53 -0
- package/lib/providers/index.js +116 -0
- package/lib/providers/intake.js +263 -0
- package/lib/providers/llm-local.js +88 -0
- package/lib/providers/llm.js +236 -0
- package/lib/providers/local-host.js +197 -0
- package/lib/providers/merge-consistency.js +162 -0
- package/lib/providers/nogood.js +149 -0
- package/lib/providers/reason-loop.js +223 -0
- package/lib/providers/semiring.js +285 -0
- package/lib/providers/solver-fork.js +163 -0
- package/lib/providers/stats.js +228 -0
- package/lib/providers/verify.js +120 -0
- package/lib/runtime/index.js +135 -0
- package/lib/runtime/invoke-pool.js +92 -0
- package/lib/runtime/protocol.js +210 -0
- package/lib/runtime/transport-socket.js +77 -0
- package/lib/runtime/worker-entry.js +31 -0
- package/lib/sg/cli.js +601 -0
- package/lib/sg/log-sinks.js +214 -0
- package/lib/sg/mcp.js +395 -0
- package/lib/sg/proxy-run.js +66 -0
- package/lib/sg/serve-demo.js +185 -0
- package/lib/sg/serve.js +205 -0
- package/lib/sg/trace.js +118 -0
- package/lib/studio/protocol.js +45 -0
- package/lib/studio/public/app.js +310 -0
- package/lib/studio/public/components/ConceptEditor.js +43 -0
- package/lib/studio/public/components/ConceptTree.js +43 -0
- package/lib/studio/public/components/CorpusPanel.js +68 -0
- package/lib/studio/public/components/ForkTree.js +36 -0
- package/lib/studio/public/components/GrammarGraph.js +99 -0
- package/lib/studio/public/components/GraphCanvas.js +156 -0
- package/lib/studio/public/components/Inspector.js +48 -0
- package/lib/studio/public/components/LearningPanel.js +85 -0
- package/lib/studio/public/components/PromptConsole.js +46 -0
- package/lib/studio/public/components/ProviderTrace.js +39 -0
- package/lib/studio/public/components/SessionSplit.js +47 -0
- package/lib/studio/public/components/TilingOverlay.js +40 -0
- package/lib/studio/public/components/Timeline.js +57 -0
- package/lib/studio/public/index.html +26 -0
- package/lib/studio/public/styles.css +185 -0
- package/lib/studio/public/ws.js +58 -0
- package/lib/studio/server.js +130 -0
- package/lib/studio/session.js +397 -0
- package/lib/studio/studio.js +149 -0
- package/package.json +42 -0
|
@@ -0,0 +1,236 @@
|
|
|
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
|
+
* LLM provider — backend-agnostic.
|
|
21
|
+
*
|
|
22
|
+
* `createLLMProvider({ ask })` returns a provider-map fragment `{ LLM: { complete } }`
|
|
23
|
+
* where `complete` is a generic concept<->prompt runner: it builds a prompt from the
|
|
24
|
+
* concept's `_schema.prompt` (or argz[0]), calls the injected `ask`, optionally salvages
|
|
25
|
+
* JSON, and writes the result back as typed facts. A host plugs ANY backend by passing
|
|
26
|
+
* its own `ask` — the bundled Anthropic-style `ask` is only the default.
|
|
27
|
+
*
|
|
28
|
+
* Concept wiring:
|
|
29
|
+
* { "provider": ["LLM::complete"],
|
|
30
|
+
* "prompt": { "system": "...", "user": "Step: ${label}", "maxTokens": 500,
|
|
31
|
+
* "json": true, "as": "Classification" } }
|
|
32
|
+
* - `user`/`system` strings interpolate `${ref}` tokens, resolved against the scope
|
|
33
|
+
* via graph.getRef (objects are JSON-stringified).
|
|
34
|
+
* - `json:true` -> parse the reply; if it's a plain object (and no `as`), merge its
|
|
35
|
+
* keys as facts; with `as`, store the parsed/raw reply under that key.
|
|
36
|
+
* - `facts:{<key>:<spec>}` -> the CANONICALIZATION BARRIER (doc/MODELISATION.md §4.2):
|
|
37
|
+
* write ONLY those discrete keys (enum-snapped / grain-rounded / typed) as *tracked*
|
|
38
|
+
* facts, the free reply text under an *untracked* `prose` key, and a stable
|
|
39
|
+
* `<name>FactsDigest` memo key. This is what keeps downstream `require`/`ensure`
|
|
40
|
+
* edges keyed on stable discrete facts instead of fragmenting on prose (K1).
|
|
41
|
+
* - on backend error the concept is still flagged + `llmError` recorded, so the graph
|
|
42
|
+
* can settle instead of hanging.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
var canonicalize = require('./canonicalize');
|
|
46
|
+
|
|
47
|
+
// ---- backend clients (Node global fetch). Configurable via env / opts. `makeAsk` dispatches on
|
|
48
|
+
// the API flavour (opts.api / env LLM_API): 'anthropic' (default, /v1/messages) or 'openai'
|
|
49
|
+
// (/v1/chat/completions). A host can always inject its own `ask` instead. ----
|
|
50
|
+
function makeAnthropicAsk( opts ) {
|
|
51
|
+
opts = opts || {};
|
|
52
|
+
var BASE = opts.base || process.env.LLM_BASE || 'http://localhost:3000';
|
|
53
|
+
var MODEL = opts.model || process.env.LLM_MODEL || 'claude-3-5-sonnet-20241022';
|
|
54
|
+
var KEY = opts.key || process.env.LLM_KEY || 'sk-local';
|
|
55
|
+
return async function ask( { system, user, maxTokens = 1024, temperature } ) {
|
|
56
|
+
// `temperature` (undefined unless a host drives it, e.g. from the plasticity ledger) is dropped
|
|
57
|
+
// by JSON.stringify when undefined, so the request body is unchanged for callers that don't set it.
|
|
58
|
+
var res = await fetch(BASE.replace(/\/$/, '') + '/v1/messages', {
|
|
59
|
+
method : 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
'content-type' : 'application/json',
|
|
62
|
+
'x-api-key' : KEY,
|
|
63
|
+
'authorization' : 'Bearer ' + KEY,
|
|
64
|
+
'anthropic-version': '2023-06-01'
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify({ model: MODEL, max_tokens: maxTokens, system: system, temperature: temperature, messages: [{ role: 'user', content: user }] })
|
|
67
|
+
});
|
|
68
|
+
if ( !res.ok ) throw new Error('LLM HTTP ' + res.status + ': ' + (await res.text()).slice(0, 200));
|
|
69
|
+
var j = await res.json();
|
|
70
|
+
if ( j.error ) throw new Error('LLM error: ' + (j.error.message || JSON.stringify(j.error)).slice(0, 200));
|
|
71
|
+
return (j.content || []).map(function ( b ) { return b.text || ''; }).join('');
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// OpenAI-compatible chat-completions client (vLLM / llama.cpp / LM Studio / a local OpenAI shim).
|
|
76
|
+
// Reads `choices[0].message.content`; for REASONING models that spend the token budget on a separate
|
|
77
|
+
// `reasoning_content` and return empty `content`, it falls back to that text (cleaner than the
|
|
78
|
+
// Anthropic shim, which mixes the thinking into the text and relies on JSON salvage).
|
|
79
|
+
function makeOpenAIAsk( opts ) {
|
|
80
|
+
opts = opts || {};
|
|
81
|
+
var BASE = opts.base || process.env.LLM_BASE || 'http://localhost:5000';
|
|
82
|
+
var MODEL = opts.model || process.env.LLM_MODEL || 'default';
|
|
83
|
+
var KEY = opts.key || process.env.LLM_KEY || 'sk-local';
|
|
84
|
+
// extra request-body fields merged into every call — e.g. disable a reasoning model's "thinking":
|
|
85
|
+
// extraBody: { chat_template_kwargs: { enable_thinking: false } } (Qwen3 on vLLM/SGLang)
|
|
86
|
+
var EXTRA = opts.extraBody || (process.env.LLM_NO_THINK ? { chat_template_kwargs: { enable_thinking: false } } : null);
|
|
87
|
+
// ASSISTANT PREFILL (no-think for reasoning models that IGNORE chat_template_kwargs — e.g. Qwen3.x in LM
|
|
88
|
+
// Studio): seed a trailing assistant turn with CLOSED think tags so the model skips reasoning and emits the
|
|
89
|
+
// answer directly (the server returns only the continuation, not the prefill). Verified the kwargs path is a
|
|
90
|
+
// no-op there while this yields a clean one-word reply. Opt-in: opts.assistantPrefill, or LLM_NO_THINK env
|
|
91
|
+
// (which also sends the kwargs above — harmless if ignored). Set opts.assistantPrefill='' to disable.
|
|
92
|
+
var PREFILL = opts.assistantPrefill != null ? opts.assistantPrefill
|
|
93
|
+
: (process.env.LLM_NO_THINK ? '<think>\n\n</think>\n\n' : null);
|
|
94
|
+
return async function ask( { system, user, maxTokens = 1024, temperature } ) {
|
|
95
|
+
var messages = [];
|
|
96
|
+
if ( system ) messages.push({ role: 'system', content: system });
|
|
97
|
+
messages.push({ role: 'user', content: user });
|
|
98
|
+
if ( PREFILL ) messages.push({ role: 'assistant', content: PREFILL }); // continuation seed → skips thinking
|
|
99
|
+
var res = await fetch(BASE.replace(/\/$/, '') + '/v1/chat/completions', {
|
|
100
|
+
method : 'POST',
|
|
101
|
+
headers: { 'content-type': 'application/json', 'authorization': 'Bearer ' + KEY },
|
|
102
|
+
body : JSON.stringify(Object.assign({ model: MODEL, max_tokens: maxTokens, temperature: temperature, messages: messages }, EXTRA))
|
|
103
|
+
});
|
|
104
|
+
if ( !res.ok ) throw new Error('LLM HTTP ' + res.status + ': ' + (await res.text()).slice(0, 200));
|
|
105
|
+
var j = await res.json();
|
|
106
|
+
if ( j.error ) throw new Error('LLM error: ' + (j.error.message || JSON.stringify(j.error)).slice(0, 200));
|
|
107
|
+
var m = (j.choices && j.choices[0] && j.choices[0].message) || {};
|
|
108
|
+
var content = m.content || '';
|
|
109
|
+
if ( !String(content).trim() && m.reasoning_content ) content = m.reasoning_content;
|
|
110
|
+
return content;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function makeAsk( opts ) {
|
|
115
|
+
opts = opts || {};
|
|
116
|
+
var api = opts.api || process.env.LLM_API;
|
|
117
|
+
return api === 'openai' ? makeOpenAIAsk(opts) : makeAnthropicAsk(opts);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- Robust against "thinking" models: returns the LAST balanced {...}/[...] that JSON.parses. ----
|
|
121
|
+
function parseJSON( text ) {
|
|
122
|
+
// strict first: if the WHOLE reply is valid JSON, use it verbatim — this preserves a nested
|
|
123
|
+
// wrapper like `{"steps":[{…}]}` that the salvage heuristic below would mis-grab (it returns the
|
|
124
|
+
// LAST balanced object = an inner element, dropping the wrapper key). Common with no-think replies.
|
|
125
|
+
try { return JSON.parse(String(text).trim()); } catch ( _e ) { /* fall through to salvage */ }
|
|
126
|
+
var fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
127
|
+
if ( fence ) { try { return JSON.parse(fence[1]); } catch ( e ) { /* fall through */ } }
|
|
128
|
+
var candidates = [];
|
|
129
|
+
for ( var i = 0; i < text.length; i++ ) {
|
|
130
|
+
var open = text[i];
|
|
131
|
+
if ( open !== '{' && open !== '[' ) continue;
|
|
132
|
+
var close = open === '{' ? '}' : ']';
|
|
133
|
+
var depth = 0, inStr = false, esc = false;
|
|
134
|
+
for ( var j = i; j < text.length; j++ ) {
|
|
135
|
+
var ch = text[j];
|
|
136
|
+
if ( inStr ) { if ( esc ) esc = false; else if ( ch === '\\' ) esc = true; else if ( ch === '"' ) inStr = false; continue; }
|
|
137
|
+
if ( ch === '"' ) inStr = true;
|
|
138
|
+
else if ( ch === open ) depth++;
|
|
139
|
+
else if ( ch === close ) { depth--; if ( depth === 0 ) { candidates.push(text.slice(i, j + 1)); break; } }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for ( var k = candidates.length - 1; k >= 0; k-- ) {
|
|
143
|
+
try { return JSON.parse(candidates[k]); } catch ( e ) { /* try previous */ }
|
|
144
|
+
}
|
|
145
|
+
throw new Error('no parseable JSON in: ' + text.slice(0, 120));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function interpolate( str, graph, scope ) {
|
|
149
|
+
if ( typeof str !== 'string' ) return str;
|
|
150
|
+
return str.replace(/\$\{\s*([^}]+?)\s*\}/g, function ( _, ref ) {
|
|
151
|
+
var v = graph.getRef(ref, scope);
|
|
152
|
+
return v == null ? '' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build the LLM provider-map fragment.
|
|
158
|
+
* @param opts.ask async ({system,user,maxTokens}) -> string. Defaults to the bundled client.
|
|
159
|
+
* @param opts.parseJSON JSON-salvage fn. Defaults to the bundled one.
|
|
160
|
+
* @param opts.namespace provider namespace key. Default 'LLM'.
|
|
161
|
+
* @param opts.plasticity (conceptName)->p∈[0,1] the UNIFIED plasticity knob (e.g. a
|
|
162
|
+
* `lifecycle.plasticity` accessor). When wired, it drives the call temperature:
|
|
163
|
+
* p=1 plastic → full temperature (explore/learn), p=0 frozen → temperature 0
|
|
164
|
+
* (deterministic, memo-perfect). DISCIPLINE: this only modulates the call — it
|
|
165
|
+
* never gates applicability (K1). Unwired → temperature is left unset (no API change).
|
|
166
|
+
* @param opts.temperature (p)->number optional mapping from plasticity to temperature
|
|
167
|
+
* (default identity, since p and the common temperature range are both [0,1]).
|
|
168
|
+
* @param opts.base/model/key config for the default client (ignored if `ask` is given).
|
|
169
|
+
*/
|
|
170
|
+
function createLLMProvider( opts ) {
|
|
171
|
+
opts = opts || {};
|
|
172
|
+
var ask = opts.ask || makeAsk(opts),
|
|
173
|
+
_parseJSON = opts.parseJSON || parseJSON,
|
|
174
|
+
namespace = opts.namespace || 'LLM',
|
|
175
|
+
plasticity = opts.plasticity,
|
|
176
|
+
tempFn = typeof opts.temperature === 'function' ? opts.temperature : null;
|
|
177
|
+
|
|
178
|
+
var ns = {};
|
|
179
|
+
ns[namespace] = {
|
|
180
|
+
complete: function ( graph, concept, scope, argz, cb ) {
|
|
181
|
+
var cfg = Object.assign({}, concept._schema && concept._schema.prompt, argz && argz[0]),
|
|
182
|
+
name = concept._name,
|
|
183
|
+
sys = interpolate(cfg.system, graph, scope),
|
|
184
|
+
usr = interpolate(cfg.user, graph, scope),
|
|
185
|
+
// plasticity (if wired) drives the temperature; else a static cfg.temperature; else unset
|
|
186
|
+
temp = plasticity ? (tempFn ? tempFn(plasticity(name)) : plasticity(name)) : cfg.temperature,
|
|
187
|
+
askArgs = { system: sys, user: usr, maxTokens: cfg.maxTokens };
|
|
188
|
+
if ( temp != null ) askArgs.temperature = temp;
|
|
189
|
+
Promise.resolve()
|
|
190
|
+
.then(function () {
|
|
191
|
+
return ask(askArgs);
|
|
192
|
+
})
|
|
193
|
+
.then(function ( txt ) {
|
|
194
|
+
// report prompt+reply to the trace (no-op if no sink configured)
|
|
195
|
+
graph.traceProvider && graph.traceProvider(concept, scope, { prompt: { system: sys, user: usr }, temperature: temp, reply: txt });
|
|
196
|
+
var facts = { $_id: '_parent' };
|
|
197
|
+
facts[name] = true;
|
|
198
|
+
|
|
199
|
+
// --- canonicalization barrier: discrete facts tracked, prose untracked ---
|
|
200
|
+
if ( cfg.facts ) {
|
|
201
|
+
var raw = _parseJSON(txt), // strict structured extraction
|
|
202
|
+
cf = canonicalize.canonFacts(raw, cfg.facts);
|
|
203
|
+
Object.assign(facts, cf.facts); // ONLY declared discrete keys, snapped -> TRACKED
|
|
204
|
+
if ( cf.misses.length ) facts[name + 'CanonMiss'] = cf.misses; // visible, fail-closed (untracked)
|
|
205
|
+
var proseKey = cfg.prose || (name + 'Prose'), // the terminal, UNTRACKED free text
|
|
206
|
+
proseVal = cfg.proseFrom != null ? raw[cfg.proseFrom]
|
|
207
|
+
: (raw && raw.prose != null ? raw.prose : txt);
|
|
208
|
+
facts[proseKey] = proseVal;
|
|
209
|
+
if ( cfg.digest !== false ) facts[name + 'FactsDigest'] = canonicalize.digest(cf.facts);
|
|
210
|
+
return cb(null, facts);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- legacy path (no facts schema): merge object / `as` / `<name>Result` ---
|
|
214
|
+
var result = cfg.json ? _parseJSON(txt) : txt;
|
|
215
|
+
if ( cfg.as ) facts[cfg.as] = result;
|
|
216
|
+
else if ( cfg.json && result && typeof result === 'object' && !Array.isArray(result) ) Object.assign(facts, result);
|
|
217
|
+
else facts[name + 'Result'] = result;
|
|
218
|
+
cb(null, facts);
|
|
219
|
+
})
|
|
220
|
+
.catch(function ( e ) {
|
|
221
|
+
var facts = { $_id: '_parent', llmError: e.message };
|
|
222
|
+
facts[name] = true;
|
|
223
|
+
cb(null, facts);// flag + record error so the graph can still settle
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
return ns;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
createLLMProvider: createLLMProvider, makeAsk: makeAsk, parseJSON: parseJSON,
|
|
232
|
+
makeOpenAIAsk: makeOpenAIAsk, makeAnthropicAsk: makeAnthropicAsk,
|
|
233
|
+
// exported so the Intake front-door (providers/intake.js) reuses the SAME `${ref}` resolver
|
|
234
|
+
// instead of re-implementing it (one interpolation semantics across the prose-typed boundary).
|
|
235
|
+
interpolate: interpolate
|
|
236
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
* local-host — a CENTRALIZED in-process host for embedded inference (node-llama-cpp): ONE place that owns
|
|
10
|
+
* the GPU-resident model(s) + an in-memory prompt cache, so many `makeLocalAsk` handles / per-concept
|
|
11
|
+
* namespaces SHARE a single VRAM load and identical (deterministic) prompts don't re-run.
|
|
12
|
+
*
|
|
13
|
+
* WHY (the design gap the naive per-instance makeLocalAsk had): each `makeLocalAsk({...})` loaded its OWN
|
|
14
|
+
* model → N arms / N namespaces of the SAME gguf = N copies in VRAM (the 2026-07-01 signature-stability run
|
|
15
|
+
* loaded a 3.2GB model 4× per experiment), and nothing cached identical prompts. This host fixes both and
|
|
16
|
+
* manages VRAM under a budget (LRU eviction) — the in-process analogue of a grant-based GPU orchestrator.
|
|
17
|
+
*
|
|
18
|
+
* Reference: rocinante `GPUMaster.js` (a grant-based multi-GPU VRAM orchestrator). We LIFT its ideas — a
|
|
19
|
+
* load-once registry, a VRAM ledger + real-VRAM guard (here `llama.getVramState()` not nvidia-smi), LRU
|
|
20
|
+
* eviction — and DROP the machinery an in-process single-process node-llama-cpp host does not need (HTTP
|
|
21
|
+
* load/unload endpoints, the multi-GPU grant queue, the task DB, exclusive/shared cross-card placement).
|
|
22
|
+
*
|
|
23
|
+
* Grammar is applied PER CALL (cached by schema on the handle), NOT baked per model → several constrained
|
|
24
|
+
* grammars share ONE model load. Only temperature-0 (deterministic) completions are cached.
|
|
25
|
+
*
|
|
26
|
+
* ZERO-CORE, provider layer. The model loader is INJECTABLE (`opts.loadModel`) so the registry / cache /
|
|
27
|
+
* eviction logic is unit-testable without a GPU; the default loader is node-llama-cpp.
|
|
28
|
+
*
|
|
29
|
+
* A loader returns a HANDLE: { vramBytes?, async complete({system,user,maxTokens,temperature,seed,grammar,reasoningBudget})
|
|
30
|
+
* -> string, dispose() } — `grammar` is a spec {jsonSchema}|{gbnf}|null;
|
|
31
|
+
* `reasoningBudget` is the native thinking budget in tokens (0 = thinking OFF, undefined = default).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
function modelKey( spec ) {
|
|
35
|
+
return [spec.modelPath, 'ctx=' + (spec.contextSize || 4096), 'gpu=' + (spec.gpu === undefined ? 'auto' : spec.gpu),
|
|
36
|
+
'lora=' + (spec.lora ? JSON.stringify(spec.lora) : '')].join('|');
|
|
37
|
+
}
|
|
38
|
+
function grammarKey( g ) { return g ? (g.jsonSchema ? 'js:' + stable(g.jsonSchema) : g.gbnf ? 'gb:' + g.gbnf : '') : ''; }
|
|
39
|
+
// Finalize a completion under an optional COMPILED grammar. A JSON-SCHEMA grammar exposes `.parse()` (re-emit canonical
|
|
40
|
+
// JSON); a raw GBNF grammar does NOT — its constrained output is already the bare text (e.g. an enum member). Guarding on
|
|
41
|
+
// `typeof gr.parse === 'function'` (not merely `gr`) is the fix for the raw-GBNF path (`gr.parse is not a function`).
|
|
42
|
+
function finalizeGrammarOutput( gr, out ) {
|
|
43
|
+
// A JSON-schema grammar guarantees a valid PREFIX, but a completion truncated at maxTokens can end mid-string, and
|
|
44
|
+
// `gr.parse()` (which JSON.parses) then THROWS on the unterminated JSON — crashing the whole run on one long, noisy
|
|
45
|
+
// generation (seen on Gemma-4-31B-Q2 under the intake schema). Degrade to the raw constrained text (fail-closed):
|
|
46
|
+
// the caller's own parse/guard salvages or nulls it, so a verbose model costs a fail-closed episode, not a crash.
|
|
47
|
+
if ( gr && typeof gr.parse === 'function' ) {
|
|
48
|
+
try { return JSON.stringify(gr.parse(out)); } catch ( _e ) { return out; }
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function stable( o ) { return JSON.stringify(o, Object.keys(o || {}).sort()); } // shallow-stable (schemas are small)
|
|
53
|
+
function cacheKey( mk, gk, r ) {
|
|
54
|
+
var US = '\u001f'; // unit separator — collision-safe, NOT a NUL byte (the source-NUL gotcha)
|
|
55
|
+
return [mk, gk, r.maxTokens || '', r.temperature || 0, r.seed == null ? 0 : r.seed,
|
|
56
|
+
r.reasoningBudget == null ? '' : 'rb' + r.reasoningBudget, r.system || '', r.user || ''].join(US);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param opts.loadModel async (spec) -> handle. Default: node-llama-cpp.
|
|
61
|
+
* @param opts.vramBudgetGB soft VRAM budget; when the ledger (Σ handle.vramBytes) would exceed it, LRU-evict.
|
|
62
|
+
* @param opts.maxModels hard cap on resident models (LRU-evict beyond it). Default: unbounded.
|
|
63
|
+
* @param opts.cacheSize bounded prompt-cache entries (LRU). Default 512; 0 disables.
|
|
64
|
+
*/
|
|
65
|
+
function createLocalModelHost( opts ) {
|
|
66
|
+
opts = opts || {};
|
|
67
|
+
const loadModel = opts.loadModel || defaultLoader(opts);
|
|
68
|
+
const vramBudget = opts.vramBudgetGB != null ? opts.vramBudgetGB * 1e9 : null;
|
|
69
|
+
const maxModels = opts.maxModels || null;
|
|
70
|
+
const cacheMax = opts.cacheSize == null ? 512 : opts.cacheSize;
|
|
71
|
+
|
|
72
|
+
const models = new Map(); // key -> { spec, handle, loading, vramBytes, lastUsed, chain }
|
|
73
|
+
const cache = new Map(); // LRU (insertion-ordered) prompt cache
|
|
74
|
+
const stats = { loads: 0, evictions: 0, cacheHits: 0, cacheMisses: 0, infer: 0 };
|
|
75
|
+
let tick = 0;
|
|
76
|
+
|
|
77
|
+
function totalVram() { let s = 0; for ( const e of models.values() ) s += e.vramBytes || 0; return s; }
|
|
78
|
+
|
|
79
|
+
async function evictIfNeeded( keepKey ) {
|
|
80
|
+
// evict LRU residents (never the one just requested) while over the count cap or the vram budget.
|
|
81
|
+
function over() { return (maxModels && models.size > maxModels) || (vramBudget && totalVram() > vramBudget); }
|
|
82
|
+
while ( over() ) {
|
|
83
|
+
let victim = null, oldest = Infinity;
|
|
84
|
+
for ( const [k, e] of models ) if ( k !== keepKey && !e.loading && e.lastUsed < oldest ) { oldest = e.lastUsed; victim = k; }
|
|
85
|
+
if ( !victim ) break;
|
|
86
|
+
const e = models.get(victim);
|
|
87
|
+
models.delete(victim);
|
|
88
|
+
stats.evictions++;
|
|
89
|
+
try { e.handle && e.handle.dispose && e.handle.dispose(); } catch ( _e ) { /* best-effort */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function getModel( spec ) {
|
|
94
|
+
const key = modelKey(spec);
|
|
95
|
+
let e = models.get(key);
|
|
96
|
+
if ( e ) { e.lastUsed = ++tick; return e.loading ? e.loading : e.handle; }
|
|
97
|
+
e = { spec, handle: null, loading: null, vramBytes: 0, lastUsed: ++tick, chain: Promise.resolve() };
|
|
98
|
+
e.loading = Promise.resolve()
|
|
99
|
+
.then(() => loadModel(spec))
|
|
100
|
+
.then(( h ) => { e.handle = h; e.vramBytes = h && h.vramBytes || 0; e.loading = null; stats.loads++; return h; });
|
|
101
|
+
models.set(key, e);
|
|
102
|
+
const h = await e.loading;
|
|
103
|
+
await evictIfNeeded(key); // make room AFTER we know this one's footprint (never evict `key`)
|
|
104
|
+
return h;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The one entry point: resolve/share the model, check the prompt cache, serialize the GPU call. */
|
|
108
|
+
async function ask( req ) {
|
|
109
|
+
const spec = { modelPath: req.modelPath, contextSize: req.contextSize, gpu: req.gpu, lora: req.lora };
|
|
110
|
+
const mk = modelKey(spec), gk = grammarKey(req.grammar);
|
|
111
|
+
const ck = cacheKey(mk, gk, req);
|
|
112
|
+
if ( cacheMax && cache.has(ck) ) { stats.cacheHits++; const v = cache.get(ck); cache.delete(ck); cache.set(ck, v); return v; }
|
|
113
|
+
stats.cacheMisses++;
|
|
114
|
+
await getModel(spec);
|
|
115
|
+
const e = models.get(mk);
|
|
116
|
+
// per-model serialization: chain calls so concurrent asks queue (don't exhaust sequences).
|
|
117
|
+
const run = e.chain.then(() => e.handle.complete({
|
|
118
|
+
system: req.system, user: req.user, maxTokens: req.maxTokens, temperature: req.temperature,
|
|
119
|
+
seed: req.seed, grammar: req.grammar, reasoningBudget: req.reasoningBudget
|
|
120
|
+
}));
|
|
121
|
+
e.chain = run.catch(() => {}); // keep the chain alive past a failure
|
|
122
|
+
const out = await run;
|
|
123
|
+
stats.infer++;
|
|
124
|
+
if ( cacheMax && (req.temperature || 0) === 0 ) { // cache ONLY deterministic results
|
|
125
|
+
cache.set(ck, out);
|
|
126
|
+
if ( cache.size > cacheMax ) cache.delete(cache.keys().next().value); // evict oldest
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function dispose() {
|
|
132
|
+
for ( const e of models.values() ) { try { e.handle && e.handle.dispose && e.handle.dispose(); } catch ( _e ) {} }
|
|
133
|
+
models.clear(); cache.clear();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { ask, getModel, dispose, stats, _models: models, _cache: cache };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── default node-llama-cpp loader (lazy; only required when the default host actually loads a model) ──
|
|
140
|
+
function defaultLoader( hostOpts ) {
|
|
141
|
+
return async function load( spec ) {
|
|
142
|
+
let nlc;
|
|
143
|
+
try { nlc = await import('node-llama-cpp'); }
|
|
144
|
+
catch ( e ) { throw new Error('node-llama-cpp is not installed. Run `npm run local-inference:setup`. Cause: ' + e.message); }
|
|
145
|
+
const { getLlama, LlamaChatSession } = nlc;
|
|
146
|
+
const llama = await getLlama(spec.gpu === undefined ? undefined : { gpu: spec.gpu });
|
|
147
|
+
const before = tryVram(llama);
|
|
148
|
+
const model = await llama.loadModel({ modelPath: spec.modelPath });
|
|
149
|
+
const context = await model.createContext(Object.assign({ contextSize: spec.contextSize || 4096 }, spec.lora ? { lora: spec.lora } : {}));
|
|
150
|
+
const after = tryVram(llama);
|
|
151
|
+
const vramBytes = (before != null && after != null) ? Math.max(0, after - before) : 0;
|
|
152
|
+
const grammars = new Map(); // schema -> compiled grammar (per model)
|
|
153
|
+
async function grammarFor( g ) {
|
|
154
|
+
if ( !g ) return null;
|
|
155
|
+
const k = grammarKey(g);
|
|
156
|
+
if ( grammars.has(k) ) return grammars.get(k);
|
|
157
|
+
const compiled = g.jsonSchema ? await llama.createGrammarForJsonSchema(g.jsonSchema) : await llama.createGrammar({ grammar: g.gbnf });
|
|
158
|
+
grammars.set(k, compiled);
|
|
159
|
+
return compiled;
|
|
160
|
+
}
|
|
161
|
+
// ONE persistent sequence per model, REUSED across calls (`clearHistory` resets the KV for a clean single-turn).
|
|
162
|
+
// A VRAM-tight context allocates only 1 sequence; getSequence()/dispose() per call then throws "No sequences left"
|
|
163
|
+
// on the 2nd call (dispose does not return a slot to a 1-sequence pool). Reuse is also the right shape given the
|
|
164
|
+
// host serializes calls per model. (Live-verified on Qwen3.6-27B-Q2, which loads a 1-sequence context.)
|
|
165
|
+
const seq = context.getSequence();
|
|
166
|
+
return {
|
|
167
|
+
vramBytes,
|
|
168
|
+
async complete( { system, user, maxTokens = 1024, temperature = 0, seed, grammar, reasoningBudget } ) {
|
|
169
|
+
const gr = await grammarFor(grammar);
|
|
170
|
+
try { await seq.clearHistory(); } catch ( _e ) {} // reset the reused sequence's KV for a fresh single-turn
|
|
171
|
+
const session = new LlamaChatSession({ contextSequence: seq, systemPrompt: system || undefined, autoDisposeSequence: false });
|
|
172
|
+
try {
|
|
173
|
+
const popts = Object.assign({ maxTokens, temperature, seed: seed == null ? 0 : seed }, gr ? { grammar: gr } : {});
|
|
174
|
+
// native thinking control (node-llama-cpp `budgets.thoughtTokens`; == CLI --reasoningBudget). 0 = thinking OFF —
|
|
175
|
+
// the adapted lever for a reasoning model at a one-token TYPED touchpoint (CoT there just truncates the answer).
|
|
176
|
+
if ( reasoningBudget != null ) popts.budgets = { thoughtTokens: reasoningBudget };
|
|
177
|
+
const out = await session.prompt(user, popts);
|
|
178
|
+
return finalizeGrammarOutput(gr, out);
|
|
179
|
+
} finally { try { session.dispose({ disposeSequence: false }); } catch ( _e ) {} } // keep the sequence for the next call
|
|
180
|
+
},
|
|
181
|
+
dispose() { try { model.dispose(); } catch ( _e ) {} }
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
function tryVram( llama ) { try { const s = llama.getVramState && llama.getVramState(); return s ? s.used : null; } catch ( _e ) { return null; } }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// a process-wide shared host (so independent makeLocalAsk handles share loads + the prompt cache).
|
|
188
|
+
let _shared = null;
|
|
189
|
+
function sharedLocalModelHost() {
|
|
190
|
+
if ( !_shared ) _shared = createLocalModelHost({
|
|
191
|
+
vramBudgetGB: process.env.SG_LOCAL_VRAM_BUDGET_GB ? Number(process.env.SG_LOCAL_VRAM_BUDGET_GB) : undefined,
|
|
192
|
+
maxModels : process.env.SG_LOCAL_MAX_MODELS ? Number(process.env.SG_LOCAL_MAX_MODELS) : undefined,
|
|
193
|
+
});
|
|
194
|
+
return _shared;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { createLocalModelHost, sharedLocalModelHost, modelKey, grammarKey, finalizeGrammarOutput };
|
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
* Merge-consistency operator — the consistency radius ε (the sheaf C1 gluing brick;
|
|
21
|
+
* doc/WIP/HANDOFF.md §7 Tier 1; SOTA subgraph-grammars C1; experiment E6). Zero core change.
|
|
22
|
+
*
|
|
23
|
+
* NRG already had the other two sheaf conditions at the sub-graph frontier: C2 (the
|
|
24
|
+
* commutative log-odds monoid = separation) and C3 (recombine-the-continuous-partials-
|
|
25
|
+
* THEN-snap, E4). The missing one was C1 (coherent restrictions). The reason it matters,
|
|
26
|
+
* shown decisively in E6: the monoid combine collapses two opposite epistemic states to
|
|
27
|
+
* the SAME certainty —
|
|
28
|
+
* - two confident sources that DISAGREE ℓ=(+2.5,−2.5) -> Σℓ=0 -> κ=0.5
|
|
29
|
+
* - two genuinely NEUTRAL sources ℓ=(+0.1,−0.1) -> Σℓ=0 -> κ=0.5
|
|
30
|
+
* identical κ=0.5, opposite meaning. The combine alone LOSES the disagreement. The
|
|
31
|
+
* consistency radius ε = the spread of the partials in log-odds (max−min; = |ℓ_A−ℓ_B|
|
|
32
|
+
* for two) is the missing dimension. Exposed as a SNAPPED typed fact `mergeConsistency`
|
|
33
|
+
* (barrier-clean enum, never a raw float on a gate), a pure-D `Reconcile` concept can
|
|
34
|
+
* fire/audit on the conflict band. C1 is, in clear, "a fact + a gate."
|
|
35
|
+
*
|
|
36
|
+
* const { createConsistency, consistencyConceptTree } = require('./merge-consistency');
|
|
37
|
+
* register(Graph, [ createConsistency() ]); // wires Merge::combine
|
|
38
|
+
* // a ready-made audited contract (Combine crosses + snaps; Reconcile gates on conflict):
|
|
39
|
+
* const tree = consistencyConceptTree({ partials: ['ellA', 'ellB'] });
|
|
40
|
+
*
|
|
41
|
+
* Combine the CONTINUOUS partials and compute ε BEFORE snapping (correction #6 / E4) — snap
|
|
42
|
+
* first and you lose evidence (two partials that each round down sum higher than their
|
|
43
|
+
* rounded sum). The crossing fact at the frontier is the continuous log-odds; only the
|
|
44
|
+
* snapped enum is consumed by a gate.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
function sigmoid( x ) { return 1 / (1 + Math.exp(-x)); }
|
|
48
|
+
|
|
49
|
+
// Default consistency-radius bands on ε (log-odds disagreement): agree <1, borderline <3,
|
|
50
|
+
// conflict ≥3. A band with no `max` is the catch-all (largest disagreement). Override via opts.bands.
|
|
51
|
+
var DEFAULT_BANDS = [
|
|
52
|
+
{ max: 1, label: 'agree', rank: 0 },
|
|
53
|
+
{ max: 3, label: 'borderline', rank: 1 },
|
|
54
|
+
{ label: 'conflict', rank: 2 }
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// Snap a disagreement ε to its band { rank, label }. The first band whose `max` ε is under
|
|
58
|
+
// wins; a maxless band is the catch-all.
|
|
59
|
+
function bandOf( eps, bands ) {
|
|
60
|
+
bands = bands || DEFAULT_BANDS;
|
|
61
|
+
for ( var i = 0; i < bands.length; i++ )
|
|
62
|
+
if ( bands[i].max == null || eps < bands[i].max ) return { rank: bands[i].rank, label: bands[i].label };
|
|
63
|
+
var last = bands[bands.length - 1];
|
|
64
|
+
return { rank: last.rank, label: last.label };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The C1 operator: combine N log-odds partials and expose the consistency radius ε.
|
|
69
|
+
* @param partials array of log-odds numbers (non-finite entries ignored)
|
|
70
|
+
* @param opts.bands custom band table (default DEFAULT_BANDS)
|
|
71
|
+
* @returns { ell, kappa, eps, rank, label, n }
|
|
72
|
+
* ell = Σ partials (the commutative monoid combine)
|
|
73
|
+
* kappa = σ(ell) (the recombined certainty)
|
|
74
|
+
* eps = max−min (the disagreement; 0 for a single partial)
|
|
75
|
+
* rank/label = the snapped consistency band (the barrier-clean enum)
|
|
76
|
+
*/
|
|
77
|
+
function mergeConsistency( partials, opts ) {
|
|
78
|
+
opts = opts || {};
|
|
79
|
+
var xs = (partials || []).map(Number).filter(function ( x ) { return isFinite(x); });
|
|
80
|
+
var ell = xs.reduce(function ( s, w ) { return s + w; }, 0);
|
|
81
|
+
var eps = xs.length ? Math.max.apply(null, xs) - Math.min.apply(null, xs) : 0;
|
|
82
|
+
var b = bandOf(eps, opts.bands);
|
|
83
|
+
return { ell: ell, kappa: sigmoid(ell), eps: eps, rank: b.rank, label: b.label, n: xs.length };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the merge-consistency provider fragment (host opt-in, like createVerifier).
|
|
88
|
+
* @param opts.bands default band table for every Merge::combine apply (per-concept override
|
|
89
|
+
* via the concept's `merge.bands`).
|
|
90
|
+
* @returns { Merge: { combine } }
|
|
91
|
+
*
|
|
92
|
+
* Concept wiring (the Combine concept at a merge frontier):
|
|
93
|
+
* { require:['ellA','ellB'], provider:['Merge::combine'],
|
|
94
|
+
* merge: { partials:['ellA','ellB'], as:'' } }
|
|
95
|
+
* Emits the self-flag + <as>ell / <as>kappa / <as>eps / <as>mergeConsistency (enum) /
|
|
96
|
+
* <as>mergeConsistencyRank. Reads each partial via getRef (a key on the merge target).
|
|
97
|
+
*/
|
|
98
|
+
function createConsistency( opts ) {
|
|
99
|
+
opts = opts || {};
|
|
100
|
+
var defaultBands = opts.bands;
|
|
101
|
+
return {
|
|
102
|
+
Merge: {
|
|
103
|
+
combine: function ( graph, concept, scope, argz, cb ) {
|
|
104
|
+
var cfg = Object.assign({}, concept._schema && concept._schema.merge, argz && argz[0]),
|
|
105
|
+
prefix = cfg.as || '',
|
|
106
|
+
keys = cfg.partials || [],
|
|
107
|
+
xs = keys.map(function ( k ) { return Number(graph.getRef(k, scope)); }),
|
|
108
|
+
r = mergeConsistency(xs, { bands: cfg.bands || defaultBands }),
|
|
109
|
+
facts = { $_id: '_parent' };
|
|
110
|
+
facts[concept._name] = true;
|
|
111
|
+
facts[prefix + 'ell'] = r.ell;
|
|
112
|
+
facts[prefix + 'kappa'] = r.kappa;
|
|
113
|
+
facts[prefix + 'eps'] = r.eps;
|
|
114
|
+
facts[prefix + 'mergeConsistency'] = r.label; // snapped enum — barrier-clean
|
|
115
|
+
facts[prefix + 'mergeConsistencyRank'] = r.rank;
|
|
116
|
+
cb(null, facts);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A ready-made audited merge-projection contract as a concept-tree fragment: a `Combine`
|
|
124
|
+
* concept that crosses the continuous partials and snaps ε, with a pure-D `Reconcile` gate
|
|
125
|
+
* nested under it that casts ONLY in the conflict band (so the D grammar can react/audit).
|
|
126
|
+
* @param opts.partials partial-fact keys to combine (default ['ellA','ellB'])
|
|
127
|
+
* @param opts.require Combine's require keys (default = the partials)
|
|
128
|
+
* @param opts.conflictRank Reconcile's gate threshold on the snapped rank (default 2 = conflict)
|
|
129
|
+
* @param opts.as output-fact prefix (default '')
|
|
130
|
+
*/
|
|
131
|
+
function consistencyConceptTree( opts ) {
|
|
132
|
+
opts = opts || {};
|
|
133
|
+
var partials = opts.partials || ['ellA', 'ellB'],
|
|
134
|
+
as = opts.as || '',
|
|
135
|
+
rank = opts.conflictRank == null ? 2 : opts.conflictRank;
|
|
136
|
+
return {
|
|
137
|
+
childConcepts: {
|
|
138
|
+
Combine: {
|
|
139
|
+
_id: 'Combine', _name: 'Combine',
|
|
140
|
+
require: opts.require || partials.slice(),
|
|
141
|
+
provider: ['Merge::combine'],
|
|
142
|
+
merge: { partials: partials, as: as },
|
|
143
|
+
childConcepts: {
|
|
144
|
+
Reconcile: {
|
|
145
|
+
_id: 'Reconcile', _name: 'Reconcile',
|
|
146
|
+
require: ['Combine'],
|
|
147
|
+
ensure: ['$' + as + 'mergeConsistencyRank>=' + rank]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
mergeConsistency: mergeConsistency,
|
|
157
|
+
bandOf: bandOf,
|
|
158
|
+
sigmoid: sigmoid,
|
|
159
|
+
DEFAULT_BANDS: DEFAULT_BANDS,
|
|
160
|
+
createConsistency: createConsistency,
|
|
161
|
+
consistencyConceptTree: consistencyConceptTree
|
|
162
|
+
};
|