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,177 @@
|
|
|
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
|
+
* glossary — the P2 cross-round terminology REFERENCE (the "glossaire de campagne"): a persistent,
|
|
10
|
+
* lattice-backed store of canonical GROUNDED notions (theses / arguments / tags) that follows the pool
|
|
11
|
+
* BETWEEN refinement rounds. It is the condition of a reliable re-split (the C9 re-plan / the generative
|
|
12
|
+
* loop's round 2): without a managed reference, duplicates go invisible, meaning drifts, and a re-split
|
|
13
|
+
* cites phantom keys. It is [ZERO-CORE] — it ASSEMBLES organs that already exist and are tested:
|
|
14
|
+
*
|
|
15
|
+
* • RECONCILIATION composes TWO gates (they compose, they do not compete):
|
|
16
|
+
* – witness-overlap ≥ θ = the GROUNDED semantic selector — which existing notion a new term duplicates
|
|
17
|
+
* (it picks the ring MEMBER). Same denominator as the generative loop's merge check (critique.js).
|
|
18
|
+
* – `registry.js#mergeRingProposals` = the SAFETY + AUDIT layer — it admits the alias under CONFLUENCE
|
|
19
|
+
* (a surface phrasing can never denote TWO notions → no silent collision), tags provenance, and BUMPS
|
|
20
|
+
* the version (the invalidation signal that version-gates stale caches/digests). A bad merge is
|
|
21
|
+
* RETRACTABLE (`retractRingAlias`) — a plain Set (the harness) can do none of this.
|
|
22
|
+
* • ENTRY-level JTMS retract (a notion whose witnesses left the pool) cascades via the uniform in-pool test
|
|
23
|
+
* — the same structural reconcile the C9/boucle-generative harness proved, here over the PERSISTENT store.
|
|
24
|
+
*
|
|
25
|
+
* Modelled as a registry with ONE Tier-2 key `notion` whose `enum` GROWS as notions are harvested (the
|
|
26
|
+
* canonical ids) and whose `synonyms[id]` holds the surface phrasings; the ring primitives operate
|
|
27
|
+
* generically on `reg.keys[key]`, so a hand-built single-key registry is a legal registry.
|
|
28
|
+
*
|
|
29
|
+
* const g = createGlossary();
|
|
30
|
+
* g.harvest({ text: 'open-source maximizes adoption', witnesses: ['p1','p3'], side: 'PRO' }); // → {status:'added', id:'n1'}
|
|
31
|
+
* g.harvest({ text: 'openness drives uptake', witnesses: ['p1','p4'], side: 'PRO' }); // → {status:'merged', id:'n1'} (overlap 0.5, alias audited)
|
|
32
|
+
* g.inject(); // citable vocabulary block
|
|
33
|
+
* g.reconcile(pool); // JTMS entry-retract (witnesses gone → cascade)
|
|
34
|
+
*
|
|
35
|
+
* NOTE: a notion enters the ring only when GROUNDED (≥1 witness) — 0-fabrication: harvest never invents a
|
|
36
|
+
* witness, inject renders only the store. An ungrounded (open) point is not a canonical notion yet.
|
|
37
|
+
*/
|
|
38
|
+
const { digest, normToken } = require('../providers/canonicalize.js');
|
|
39
|
+
const { mergeRingProposals, retractRingAlias, creditRingAlias } = require('./registry.js');
|
|
40
|
+
const { clusterByGrounding, arbitrate } = require('./granularity.js');
|
|
41
|
+
|
|
42
|
+
const overlapOf = ( a, b ) => { // |shared| / |candidate|, candidate = a
|
|
43
|
+
if ( !a.length ) return 0;
|
|
44
|
+
const set = new Set(b);
|
|
45
|
+
return a.filter(( x ) => set.has(x) ).length / a.length;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function createGlossary( opts ) {
|
|
49
|
+
opts = opts || {};
|
|
50
|
+
const KEY = opts.key || 'notion';
|
|
51
|
+
const THETA = opts.overlap != null ? opts.overlap : 0.5;
|
|
52
|
+
// a legal single-key registry (Tier-2, open interior); the ring primitives read `reg.keys[key]` generically.
|
|
53
|
+
let registry = { version: 'v1', frozen: false, conflicts: [], ringProvenance: {},
|
|
54
|
+
keys: { [KEY]: { tier: 2, enum: [], synonyms: {}, producers: [], consumers: [], values: [] } } };
|
|
55
|
+
const entries = new Map(); // id -> { id, text, witnesses[], side, type, round, provenance, status, aliases[] }
|
|
56
|
+
let counter = 0;
|
|
57
|
+
|
|
58
|
+
const memberOfAlias = ( text ) => { // the notion a surface phrasing already denotes (or null)
|
|
59
|
+
const na = normToken(text), syn = registry.keys[KEY].synonyms || {};
|
|
60
|
+
for ( const m of Object.keys(syn) ) if ( (syn[m] || []).some(( a ) => normToken(a) === na ) ) return m;
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
const propose = ( member, alias, via ) => { // admit an alias through the CONFLUENCE gate; swap in the grown (versioned) registry on admit
|
|
64
|
+
const r = mergeRingProposals(registry, [{ key: KEY, member, alias, via: via || 'harvest' }]);
|
|
65
|
+
if ( r.admitted.length ) { registry = r.registry; return { admitted: true }; }
|
|
66
|
+
return { admitted: false, reason: (r.rejected[0] && r.rejected[0].reason) || 'rejected' };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Harvest one grounded element into the glossary. Returns the reconciliation outcome:
|
|
71
|
+
* { status:'added', id } a NEW canonical notion
|
|
72
|
+
* { status:'merged', id, alias } a duplicate of `id` (surface identity OR grounded overlap ≥ θ) — alias audited
|
|
73
|
+
* { status:'conflict', id, rejectedReason } the surface would denote TWO notions — SURFACED, never silently merged
|
|
74
|
+
* { status:'ungrounded' } no witness → not a canonical notion (0-fabrication)
|
|
75
|
+
* @param entry { text, witnesses:[argId], side?, type?, round?, provenance? }
|
|
76
|
+
*/
|
|
77
|
+
function harvest( entry ) {
|
|
78
|
+
const text = String((entry && entry.text) || '').trim();
|
|
79
|
+
const witnesses = ((entry && entry.witnesses) || []).slice();
|
|
80
|
+
if ( !text ) return { status: 'ungrounded', reason: 'no text' };
|
|
81
|
+
if ( !witnesses.length ) return { status: 'ungrounded', reason: 'no witness (0-fabrication: a notion enters the ring only when grounded)' };
|
|
82
|
+
const side = entry.side != null ? String(entry.side).toUpperCase() : null;
|
|
83
|
+
|
|
84
|
+
// (1) SURFACE IDENTITY — the exact phrasing already names a notion → that notion, definitively.
|
|
85
|
+
const surf = memberOfAlias(text);
|
|
86
|
+
if ( surf ) { entries.get(surf).aliases.push({ text, witnesses, via: 'surface' }); return { status: 'merged', id: surf, alias: text }; }
|
|
87
|
+
|
|
88
|
+
// (2) GROUNDED OVERLAP — witness-overlap ≥ θ picks the member; the ring gate admits/audits the alias.
|
|
89
|
+
let best = null, bestOv = 0;
|
|
90
|
+
for ( const e of entries.values() ) {
|
|
91
|
+
if ( e.status !== 'active' || !e.witnesses.length ) continue;
|
|
92
|
+
if ( side && e.side && e.side !== side ) continue; // never merge across sides
|
|
93
|
+
const ov = overlapOf(witnesses, e.witnesses);
|
|
94
|
+
if ( ov > bestOv ) { bestOv = ov; best = e; }
|
|
95
|
+
}
|
|
96
|
+
if ( best && bestOv >= THETA ) {
|
|
97
|
+
const r = propose(best.id, text, entry.provenance);
|
|
98
|
+
if ( r.admitted ) { best.aliases.push({ text, witnesses, via: entry.provenance || 'harvest' }); return { status: 'merged', id: best.id, alias: text }; }
|
|
99
|
+
return { status: 'conflict', id: best.id, rejectedReason: r.reason }; // confluence refused — SURFACED, not silently collided
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// (3) NEW GROUNDED NOTION — extend the enum, register its text as the first alias (version bumps).
|
|
103
|
+
const id = 'n' + (++counter);
|
|
104
|
+
registry.keys[KEY].enum = registry.keys[KEY].enum.concat([id]);
|
|
105
|
+
const r = propose(id, text, entry.provenance);
|
|
106
|
+
if ( !r.admitted ) { // the text already denotes another notion → a disguised merge into its owner
|
|
107
|
+
registry.keys[KEY].enum = registry.keys[KEY].enum.filter(( x ) => x !== id );
|
|
108
|
+
const owner = memberOfAlias(text);
|
|
109
|
+
if ( owner ) { entries.get(owner).aliases.push({ text, witnesses, via: 'surface-late' }); return { status: 'merged', id: owner, alias: text }; }
|
|
110
|
+
counter--; return { status: 'conflict', id: null, rejectedReason: r.reason };
|
|
111
|
+
}
|
|
112
|
+
entries.set(id, { id, text, witnesses: witnesses.slice(), side, type: entry.type || null,
|
|
113
|
+
round: entry.round != null ? entry.round : 0, provenance: entry.provenance || 'harvest', status: 'active', aliases: [] });
|
|
114
|
+
return { status: 'added', id };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* JTMS entry-retract over the PERSISTENT store — an active notion whose witnesses no longer ALL live in the
|
|
119
|
+
* pool is retracted. The uniform in-pool test gives the CASCADE for free (a shared witness leaving drops
|
|
120
|
+
* every notion citing it; a notion retracted for one bad witness never drags down notions sharing its
|
|
121
|
+
* still-valid witnesses). Pure + structural; identical logic to critique.js#reconcile, over the store.
|
|
122
|
+
* @returns retracted ids
|
|
123
|
+
*/
|
|
124
|
+
function reconcile( pool ) {
|
|
125
|
+
const inPool = new Set((pool || []).map(( a ) => typeof a === 'string' ? a : a.id ));
|
|
126
|
+
const retracted = [];
|
|
127
|
+
for ( const e of entries.values() )
|
|
128
|
+
if ( e.status === 'active' && e.witnesses.length && !e.witnesses.every(( w ) => inPool.has(w) ) ) { e.status = 'retracted'; retracted.push(e.id); }
|
|
129
|
+
return retracted;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Render the canonical notions as a deterministic, sorted CITABLE-VOCABULARY block (the re-découpe injection). */
|
|
133
|
+
function inject( o ) {
|
|
134
|
+
o = o || {};
|
|
135
|
+
const activeOnly = o.activeOnly !== false;
|
|
136
|
+
const list = [...entries.values()].filter(( e ) => (activeOnly ? e.status === 'active' : true) && (!o.side || e.side === o.side) );
|
|
137
|
+
if ( !list.length ) return '';
|
|
138
|
+
return list.map(( e ) => '- ' + e.id + ': ' + e.text + (e.side ? ' [' + e.side + ']' : '') ).join('\n');
|
|
139
|
+
}
|
|
140
|
+
/** The set of citable notion ids — a re-ask that cites a key OUTSIDE this set is a phantom (caught by validation). */
|
|
141
|
+
function citableKeys( o ) {
|
|
142
|
+
o = o || {};
|
|
143
|
+
const activeOnly = o.activeOnly !== false;
|
|
144
|
+
return new Set([...entries.values()].filter(( e ) => (activeOnly ? e.status === 'active' : true)).map(( e ) => e.id ));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Retract a bad merge (a wrong alias) — the un-learn verb; bumps the version (the invalidation signal). */
|
|
148
|
+
function retractAlias( id, alias ) {
|
|
149
|
+
const r = retractRingAlias(registry, KEY, alias); registry = r.registry;
|
|
150
|
+
const e = entries.get(id); if ( e ) e.aliases = e.aliases.filter(( a ) => normToken(a.text) !== normToken(alias) );
|
|
151
|
+
return { retracted: r.retracted, member: r.member, version: registry.version };
|
|
152
|
+
}
|
|
153
|
+
/** Credit a verified reuse of an alias — support++, NEVER bumps the version (resolution semantics unchanged). */
|
|
154
|
+
function credit( alias ) {
|
|
155
|
+
const r = creditRingAlias(registry, KEY, alias); registry = r.registry;
|
|
156
|
+
return { member: r.member, support: r.support, version: registry.version };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// P2 granularity ARBITER over the active notions — group them into grounded dimensions (co-citation of
|
|
160
|
+
// witnesses) and return the lazy-2-régime frame verdict (coherent | mixed=the re-plan TOO-NARROW signal |
|
|
161
|
+
// unstructured=escalate to Q2). Delegates to granularity.js; the glossary just supplies its active notions.
|
|
162
|
+
const activeItems = ( o ) => [...entries.values()].filter(( e ) => e.status === 'active' && e.witnesses.length )
|
|
163
|
+
.map(( e ) => ({ id: e.id, witnesses: e.witnesses, side: e.side }) );
|
|
164
|
+
return {
|
|
165
|
+
harvest, reconcile, inject, citableKeys, retractAlias, credit,
|
|
166
|
+
cluster: ( o ) => clusterByGrounding(activeItems(o), o),
|
|
167
|
+
arbitrate: ( o ) => arbitrate(activeItems(o), o),
|
|
168
|
+
notions: ( o ) => [...entries.values()].filter(( e ) => !(o && o.activeOnly) || e.status === 'active' ),
|
|
169
|
+
get: ( id ) => entries.get(id) || null,
|
|
170
|
+
version: () => registry.version,
|
|
171
|
+
snapshot: () => ({ version: registry.version, notions: [...entries.values()].map(( e ) => ({ ...e, aliases: e.aliases.slice() })),
|
|
172
|
+
rings: JSON.parse(JSON.stringify(registry.keys[KEY].synonyms || {})), provenance: Object.assign({}, registry.ringProvenance) }),
|
|
173
|
+
fingerprint: () => digest({ v: registry.version, n: [...entries.values()].map(( e ) => e.id + ':' + e.status + ':' + e.witnesses.join(',') ).sort() })
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { createGlossary, overlapOf };
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
* Grammar graph (host-side authoring tool, zero core change) — the second, orthogonal view
|
|
21
|
+
* the Studio was missing: not the IS-A tree (`childConcepts`, already shown), but the
|
|
22
|
+
* concept↔fact FLUX graph, where the real interactions live.
|
|
23
|
+
*
|
|
24
|
+
* A grammar's concepts interact through FACTS: a concept WRITES facts (its self-flag + the
|
|
25
|
+
* keys its `applyMutations` template sets) and READS facts (its `require` LHS + the `$ref`s in
|
|
26
|
+
* its `assert`/`ensure`, with the dependency's POLARITY — a `!$f` / `$f==false` read is a
|
|
27
|
+
* defeasance edge). Crossing two concept sets, a fact written in one set and read in another is
|
|
28
|
+
* a CROSS-CORPUS link; a fact written in two sets is a silent COLLISION (the `leadTime` trap);
|
|
29
|
+
* a fact read but produced by nobody is an external ENTRY POINT (seed / engine input).
|
|
30
|
+
*
|
|
31
|
+
* const { conceptFactGraph } = require('./grammar-graph');
|
|
32
|
+
* const g = conceptFactGraph({ common: tree, clinical: tree2 });
|
|
33
|
+
* // g.concepts, g.facts, g.edges, g.crossCorpus, g.collisions, g.entryPoints, g.tiling
|
|
34
|
+
*
|
|
35
|
+
* The ref / polarity / produced-fact extraction reuses validate.js (single source of truth);
|
|
36
|
+
* the tiling overlay (separators / forks / frontier alphabets) comes from decompose.js#forkPlan.
|
|
37
|
+
* Pure static analysis — no engine runtime.
|
|
38
|
+
*/
|
|
39
|
+
const { eachConcept, refsOf, refKeyOf, templateKeys, negatedRefKeys } = require('./validate');
|
|
40
|
+
const { parseExpression } = require('../graph/expr');
|
|
41
|
+
const { forkPlan } = require('./decompose');
|
|
42
|
+
|
|
43
|
+
const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
|
|
44
|
+
const schemaOf = (c) => (c && c._schema) || c || {};
|
|
45
|
+
|
|
46
|
+
function conceptKind( schema ) {
|
|
47
|
+
const p = schema.provider;
|
|
48
|
+
if ( !p ) return 'pure';
|
|
49
|
+
const name = Array.isArray(p) ? p[0] : p;
|
|
50
|
+
return (typeof name === 'string' && name.startsWith('LLM::')) ? 'llm' : 'provider';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A `$$key` in a mutation template marks a bagRef (external data) — detect it for the flag badge.
|
|
54
|
+
function hasBagRef( applyMutations ) {
|
|
55
|
+
for ( const obj of asArray(applyMutations) ) {
|
|
56
|
+
if ( !obj || typeof obj !== 'object' ) continue;
|
|
57
|
+
for ( const k of Object.keys(obj) ) if ( k.startsWith('$$') ) return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function conceptFlags( schema ) {
|
|
63
|
+
return {
|
|
64
|
+
onCast : !!schema.onCast, // standing-cluster subscribe hook
|
|
65
|
+
cleaner : !!schema.cleaner, // retraction / teardown hook
|
|
66
|
+
autoCastFalse: schema.autoCast === false,
|
|
67
|
+
enum : schema.type === 'enum',
|
|
68
|
+
bagRef : hasBagRef(schema.applyMutations)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Is this a set-map ({ setName: tree }) or a single bare tree (has its own childConcepts)?
|
|
73
|
+
function asSetMap( conceptMap ) {
|
|
74
|
+
if ( conceptMap && Object.prototype.hasOwnProperty.call(conceptMap, 'childConcepts') )
|
|
75
|
+
return { default: conceptMap };
|
|
76
|
+
return conceptMap || {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Shallow-merge the top-level childConcepts of every set into one tree (nested childConcepts are
|
|
80
|
+
// preserved) for the tiling overlay. An approximation of the engine's deepmerge — enough for the
|
|
81
|
+
// static decomposition; a same-named top-level concept in two sets is overwritten (last wins).
|
|
82
|
+
function mergeTrees( trees ) {
|
|
83
|
+
const childConcepts = {};
|
|
84
|
+
for ( const t of trees ) Object.assign(childConcepts, (t && t.childConcepts) || {});
|
|
85
|
+
return { childConcepts };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the concept↔fact grammar graph from a concept map (or a single tree).
|
|
90
|
+
* @param conceptMap { setName: tree, … } or a single concept tree
|
|
91
|
+
* @returns {{
|
|
92
|
+
* concepts: Array<{id,name,set,kind,flags}>,
|
|
93
|
+
* facts: Array<{key,producedBy:string[],consumedBy:Array<{name,polarity,via}>,sets:string[]}>,
|
|
94
|
+
* edges: Array<{kind:'writes',concept,fact} | {kind:'reads',concept,fact,polarity,via}>,
|
|
95
|
+
* crossCorpus: Array<{fact,fromSet,toSet}>,
|
|
96
|
+
* collisions: Array<{fact,sets:string[]}>,
|
|
97
|
+
* entryPoints: string[],
|
|
98
|
+
* tiling: object|null
|
|
99
|
+
* }}
|
|
100
|
+
*/
|
|
101
|
+
function conceptFactGraph( conceptMap ) {
|
|
102
|
+
const map = asSetMap(conceptMap);
|
|
103
|
+
const sets = Object.keys(map);
|
|
104
|
+
|
|
105
|
+
const concepts = [];
|
|
106
|
+
const setOfConcept = new Map(); // concept name -> set
|
|
107
|
+
const edges = [];
|
|
108
|
+
const producersByFact = new Map(); // fact -> Set(concept name)
|
|
109
|
+
const producerSetsByFact = new Map(); // fact -> Set(set)
|
|
110
|
+
|
|
111
|
+
for ( const set of sets ) {
|
|
112
|
+
eachConcept(map[set], ( c, key ) => {
|
|
113
|
+
const schema = schemaOf(c);
|
|
114
|
+
const name = c._name || key;
|
|
115
|
+
if ( !name ) return; // unnamed node — validate.js flags it; skip here
|
|
116
|
+
concepts.push({ id: c._id || name, name, set, kind: conceptKind(schema), flags: conceptFlags(schema) });
|
|
117
|
+
setOfConcept.set(name, set);
|
|
118
|
+
|
|
119
|
+
// produced: self-flag + applyMutations keys
|
|
120
|
+
const produced = new Set([name, ...templateKeys(schema.applyMutations)]);
|
|
121
|
+
for ( const f of produced ) {
|
|
122
|
+
edges.push({ kind: 'writes', concept: name, fact: f });
|
|
123
|
+
if ( !producersByFact.has(f) ) producersByFact.set(f, new Set());
|
|
124
|
+
producersByFact.get(f).add(name);
|
|
125
|
+
if ( !producerSetsByFact.has(f) ) producerSetsByFact.set(f, new Set());
|
|
126
|
+
producerSetsByFact.get(f).add(set);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// consumed: require (positive); assert/ensure (polarity from the AST)
|
|
130
|
+
for ( const r of refsOf(schema.require, false) )
|
|
131
|
+
edges.push({ kind: 'reads', concept: name, fact: refKeyOf(r).key, polarity: '+', via: 'require' });
|
|
132
|
+
for ( const via of ['assert', 'ensure'] )
|
|
133
|
+
for ( const e of asArray(schema[via]) ) {
|
|
134
|
+
if ( typeof e !== 'string' ) continue;
|
|
135
|
+
let ast; try { ast = parseExpression(e); } catch ( _e ) { ast = null; }
|
|
136
|
+
const negs = negatedRefKeys(ast);
|
|
137
|
+
for ( const r of refsOf(e, true) ) {
|
|
138
|
+
const k = refKeyOf(r).key;
|
|
139
|
+
edges.push({ kind: 'reads', concept: name, fact: k, polarity: negs.has(k) ? '-' : '+', via });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// consumers per fact
|
|
146
|
+
const consumersByFact = new Map(); // fact -> [{name,polarity,via}]
|
|
147
|
+
const consumerSetsByFact = new Map(); // fact -> Set(set)
|
|
148
|
+
for ( const e of edges ) if ( e.kind === 'reads' ) {
|
|
149
|
+
if ( !consumersByFact.has(e.fact) ) consumersByFact.set(e.fact, []);
|
|
150
|
+
consumersByFact.get(e.fact).push({ name: e.concept, polarity: e.polarity, via: e.via });
|
|
151
|
+
const s = setOfConcept.get(e.concept);
|
|
152
|
+
if ( !consumerSetsByFact.has(e.fact) ) consumerSetsByFact.set(e.fact, new Set());
|
|
153
|
+
if ( s ) consumerSetsByFact.get(e.fact).add(s);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const allFacts = new Set([...producersByFact.keys(), ...consumersByFact.keys()]);
|
|
157
|
+
const facts = [...allFacts].sort().map(( key ) => ({
|
|
158
|
+
key,
|
|
159
|
+
producedBy: [...(producersByFact.get(key) || [])].sort(),
|
|
160
|
+
consumedBy: (consumersByFact.get(key) || []).slice()
|
|
161
|
+
.sort(( a, b ) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
|
|
162
|
+
sets: [...new Set([...(producerSetsByFact.get(key) || []), ...(consumerSetsByFact.get(key) || [])])].sort()
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
// collisions: a fact written by concepts in >= 2 sets
|
|
166
|
+
const collisions = [];
|
|
167
|
+
for ( const [fact, setSet] of producerSetsByFact )
|
|
168
|
+
if ( setSet.size >= 2 ) collisions.push({ fact, sets: [...setSet].sort() });
|
|
169
|
+
collisions.sort(( a, b ) => (a.fact < b.fact ? -1 : 1));
|
|
170
|
+
|
|
171
|
+
// cross-corpus links: produced in set X, consumed in set Y != X
|
|
172
|
+
const crossCorpus = [];
|
|
173
|
+
for ( const fact of allFacts ) {
|
|
174
|
+
const pSets = producerSetsByFact.get(fact) || new Set();
|
|
175
|
+
const cSets = consumerSetsByFact.get(fact) || new Set();
|
|
176
|
+
for ( const from of pSets ) for ( const to of cSets ) if ( from !== to )
|
|
177
|
+
crossCorpus.push({ fact, fromSet: from, toSet: to });
|
|
178
|
+
}
|
|
179
|
+
crossCorpus.sort(( a, b ) => (a.fact < b.fact ? -1 : a.fact > b.fact ? 1 : a.fromSet < b.fromSet ? -1 : 1));
|
|
180
|
+
|
|
181
|
+
// entry points: read by someone, produced by no one (external / seed / engine input)
|
|
182
|
+
const entryPoints = [...allFacts].filter(( f ) => !producersByFact.has(f) && consumersByFact.has(f)).sort();
|
|
183
|
+
|
|
184
|
+
// tiling overlay (best-effort; never throw the whole derivation on a degenerate corpus)
|
|
185
|
+
let tiling = null;
|
|
186
|
+
try { tiling = forkPlan(mergeTrees(sets.map(( s ) => map[s]))); } catch ( _e ) { tiling = null; }
|
|
187
|
+
|
|
188
|
+
return { concepts, facts, edges, crossCorpus, collisions, entryPoints, tiling };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { conceptFactGraph };
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
* granularity — the STRUCTURAL granularity ARBITER (P2's 4th client, the sonde's grounded dimensions):
|
|
10
|
+
* cluster grounded notions / key-points into candidate DIMENSIONS by their GROUNDING overlap (co-citation
|
|
11
|
+
* of witnesses), with NO prose parsing. The sonde (WIP 2026-07-13) measured that the real dimensions of a
|
|
12
|
+
* question come from CLUSTERING the KPs on their grounding, not from asking Q2 to parse them (its "moral;
|
|
13
|
+
* legal" prior is a shallow cliché). Two items are in the same dimension iff their witness-sets share ≥
|
|
14
|
+
* `minShared` arguments — transitively, via connected components on the co-citation graph.
|
|
15
|
+
*
|
|
16
|
+
* The arbiter's job is to ARBITRATE the lazy 2-régime factorization (sonde): when the grounding is dense
|
|
17
|
+
* enough to form ≥2 grounded groups, it RESOLVES the dimensions for free ('mixed' = the re-plan's
|
|
18
|
+
* `frame: TOO-NARROW` signal, grounded — the moral⊥legal separation, structurally); when the grounding is
|
|
19
|
+
* a single connected block it is a 'coherent' frame (decide normally); when it is all singletons (sparse
|
|
20
|
+
* co-citation — measured ~6% multi-matched on ArgKP) there is no grounded dimension structure and it
|
|
21
|
+
* ESCALATES ('unstructured' → Q2 proposes dimensions, seeded by the groups). It never hallucinates a
|
|
22
|
+
* dimension (the discriminating negative control). Deterministic, grounded, ZERO-CORE.
|
|
23
|
+
*
|
|
24
|
+
* const { arbitrate } = require('skynet-graph/lib/authoring/granularity');
|
|
25
|
+
* arbitrate([{id:'n1',witnesses:['a','b']}, {id:'n2',witnesses:['b','c']}, {id:'n3',witnesses:['x','y']}]);
|
|
26
|
+
* // → { frame:'mixed', dimensions:[['n1','n2'],['n3']-dropped], ... } (n1~n2 share b; n3 disjoint)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// Union-find over the item ids, edges = share ≥ minShared witnesses (optionally never across `side`).
|
|
30
|
+
function clusterByGrounding( items, opts ) {
|
|
31
|
+
opts = opts || {};
|
|
32
|
+
const minShared = opts.minShared != null ? opts.minShared : 1;
|
|
33
|
+
const bySide = !!opts.bySide;
|
|
34
|
+
const parent = new Map();
|
|
35
|
+
const find = ( x ) => { while ( parent.get(x) !== x ) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; };
|
|
36
|
+
for ( const it of items ) parent.set(it.id, it.id);
|
|
37
|
+
let edges = 0;
|
|
38
|
+
for ( let i = 0; i < items.length; i++ ) for ( let j = i + 1; j < items.length; j++ ) {
|
|
39
|
+
const A = items[i], B = items[j];
|
|
40
|
+
if ( bySide && A.side && B.side && A.side !== B.side ) continue;
|
|
41
|
+
const wb = new Set(B.witnesses || []);
|
|
42
|
+
const shared = (A.witnesses || []).filter(( w ) => wb.has(w) ).length;
|
|
43
|
+
if ( shared >= minShared ) { const ra = find(A.id), rb = find(B.id); if ( ra !== rb ) { parent.set(ra, rb); edges++; } }
|
|
44
|
+
}
|
|
45
|
+
const groups = new Map();
|
|
46
|
+
for ( const it of items ) { const r = find(it.id); if ( !groups.has(r) ) groups.set(r, []); groups.get(r).push(it.id); }
|
|
47
|
+
const clusters = [...groups.values()].map(( g ) => g.slice().sort() ).sort(( a, b ) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0 );
|
|
48
|
+
return { clusters, separability: { components: clusters.length, singletons: clusters.filter(( c ) => c.length === 1 ).length,
|
|
49
|
+
sizes: clusters.map(( c ) => c.length ).sort(( a, b ) => b - a ), edges } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Classify a set of grounded items into a FRAME verdict for the re-plan (the `frame: TOO-NARROW` signal):
|
|
54
|
+
* 'coherent' — one connected grounded block → a single dimension → decide normally (no re-plan).
|
|
55
|
+
* 'mixed' — ≥2 grounded groups (size ≥ minGroup) → the frame conflates dimensions → SEPARATE them
|
|
56
|
+
* (the moral⊥legal case, detected structurally, grounded — the re-plan's re-split seed).
|
|
57
|
+
* 'unstructured' — no grounded groups (all singletons; sparse co-citation) → ESCALATE to Q2 to propose
|
|
58
|
+
* dimensions (the lazy-régime GAP), seeded by the singletons; never fabricate a split.
|
|
59
|
+
* @param items [{ id, witnesses:[argId], side? }]
|
|
60
|
+
* @returns { frame, dimensions:[[id..]], singletons:[[id]..], separability, reason }
|
|
61
|
+
*/
|
|
62
|
+
function arbitrate( items, opts ) {
|
|
63
|
+
opts = opts || {};
|
|
64
|
+
const minGroup = opts.minGroup != null ? opts.minGroup : 2;
|
|
65
|
+
const { clusters, separability } = clusterByGrounding(items, opts);
|
|
66
|
+
const dimensions = clusters.filter(( c ) => c.length >= minGroup );
|
|
67
|
+
const singletons = clusters.filter(( c ) => c.length < minGroup );
|
|
68
|
+
let frame, reason;
|
|
69
|
+
if ( dimensions.length >= 2 ) { frame = 'mixed';
|
|
70
|
+
reason = dimensions.length + ' grounded groups share no witnesses across groups — the frame conflates ' + dimensions.length + ' dimensions'; }
|
|
71
|
+
else if ( dimensions.length === 1 && !singletons.length ) { frame = 'coherent';
|
|
72
|
+
reason = 'one connected grounded block — a single dimension'; }
|
|
73
|
+
else if ( dimensions.length === 1 ) { frame = 'coherent';
|
|
74
|
+
reason = 'one grounded group' + (singletons.length ? ' + ' + singletons.length + ' ungrounded singleton(s)' : ''); }
|
|
75
|
+
else { frame = 'unstructured';
|
|
76
|
+
reason = 'no grounded group (co-citation too sparse) — escalate to Q2 to propose dimensions'; }
|
|
77
|
+
return { frame, dimensions, singletons, separability, reason };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { clusterByGrounding, arbitrate };
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
* graph-net — a SHARED-WEIGHT message-passing population over an arbitrary graph: ONE node-update
|
|
21
|
+
* rule (a gate-NN × update-NN) applied at every node, reading a permutation-invariant aggregate of
|
|
22
|
+
* its neighbours, run to a FIXPOINT, trained by the same implicit-diff/DEQ machinery (equilibrium.js).
|
|
23
|
+
* A quantized equilibrium GNN — the substrate generalised from concept-net's fixed structures to an
|
|
24
|
+
* actual graph, so it (a) handles real graph problems where recurrence is essential and (b)
|
|
25
|
+
* GENERALISES to new graphs (one rule everywhere = the distillation payoff). Host-side, ZERO-CORE.
|
|
26
|
+
* Study 2026-06-26 (see doc/concept-learning.md); the rung after the population substrate.
|
|
27
|
+
*
|
|
28
|
+
* Unit: z'[i] = gate_i · cand_i, both σ over ctx_i = [ mean(neighbour states), own state ]; weights
|
|
29
|
+
* SHARED across nodes (θ = 6 numbers, any graph size). Seed-initialised: settle starts from z0.
|
|
30
|
+
*
|
|
31
|
+
* CLAMPED nodes (`opts.clamp`) are held fixed at their z0 value — the "grounded" anchors for a
|
|
32
|
+
* SEED-ANCHORED propagation problem (e.g. known types propagating across a dependency graph). This
|
|
33
|
+
* matters: an UNCLAMPED equilibrium GNN OVERSMOOTHS to the consensus fixpoint (washes out the seed →
|
|
34
|
+
* cannot do symmetry-breaking labelling, finding #26); a clamp breaks the symmetry so the fixpoint is
|
|
35
|
+
* seed-determined and learnable. The DEQ adjoint runs on the FREE (non-clamped) subspace (a clamped
|
|
36
|
+
* node is an identity J_z row → I−J_z singular).
|
|
37
|
+
*
|
|
38
|
+
* const gp = graphPopulation(n, edges, { clamp: [0] }); // node 0 = the grounded source
|
|
39
|
+
* const r = trainGraph(gp, episodes, { steps, lr }); // episodes: [{ z0, target }]
|
|
40
|
+
* const z = gp.settle(r.theta, z0).z; // propagate a NEW graph, no oracle
|
|
41
|
+
*/
|
|
42
|
+
const { solveFixpoint, implicitGrad, numJac, spectralRadius } = require('./equilibrium.js');
|
|
43
|
+
|
|
44
|
+
const sigmoid = (x) => 1 / (1 + Math.exp(-x));
|
|
45
|
+
|
|
46
|
+
function graphPopulation( n, edges, opts ) {
|
|
47
|
+
opts = opts || {};
|
|
48
|
+
const clamp = new Set(opts.clamp || []);
|
|
49
|
+
const free = []; for ( let i = 0; i < n; i++ ) if ( !clamp.has(i) ) free.push(i);
|
|
50
|
+
const nb = Array.from({ length: n }, () => []);
|
|
51
|
+
for ( const [a, b] of (edges || []) ) { nb[a].push(b); nb[b].push(a); }
|
|
52
|
+
const nParams = 6; // [Wg_agg, Wg_self, bg, Wu_agg, Wu_self, bu] — SHARED
|
|
53
|
+
function F( z, th ) {
|
|
54
|
+
const zn = z.slice();
|
|
55
|
+
for ( const i of free ) { // clamped nodes keep z[i]
|
|
56
|
+
const ne = nb[i]; let agg = 0;
|
|
57
|
+
for ( const j of ne ) agg += z[j];
|
|
58
|
+
agg = ne.length ? agg / ne.length : 0;
|
|
59
|
+
const self = z[i];
|
|
60
|
+
zn[i] = sigmoid(th[0] * agg + th[1] * self + th[2]) * sigmoid(th[3] * agg + th[4] * self + th[5]);
|
|
61
|
+
}
|
|
62
|
+
return zn;
|
|
63
|
+
}
|
|
64
|
+
const scatter = (zf, z0) => { const z = z0.slice(); free.forEach((j, c) => { z[j] = zf[c]; }); return z; }; // clamped from z0
|
|
65
|
+
const gather = (z) => free.map((j) => z[j]);
|
|
66
|
+
const freeStep = (zf, z0, th) => gather(F(scatter(zf, z0), th)); // the reduced sweep the DEQ differentiates
|
|
67
|
+
return {
|
|
68
|
+
n, nParams, F, neighbors: nb, clamp: [...clamp], free, scatter, gather, freeStep,
|
|
69
|
+
settle( th, z0, o ) { return solveFixpoint((z) => F(z, th), z0.slice(), { maxIter: (o && o.maxIter) || 3000, tol: (o && o.tol) || 1e-12 }); },
|
|
70
|
+
settleFree( th, z0, o ) { const r = solveFixpoint((zf) => freeStep(zf, z0, th), gather(z0), { maxIter: (o && o.maxIter) || 3000, tol: (o && o.tol) || 1e-12 }); return { zfree: r.z, z: scatter(r.z, z0), iters: r.iters, converged: r.converged }; },
|
|
71
|
+
randomParams( rng, scale ) { rng = rng || Math.random; scale = scale == null ? 1 : scale; const t = new Array(nParams); for ( let i = 0; i < nParams; i++ ) t[i] = (rng() * 2 - 1) * scale; return t; },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Mean-squared per-FREE-node loss over episodes [{ z0, target }]. */
|
|
76
|
+
function graphLoss( gp, th, episodes ) {
|
|
77
|
+
let s = 0, cnt = 0;
|
|
78
|
+
for ( const ep of episodes ) { const z = gp.settle(th, ep.z0).z; for ( const i of gp.free ) { s += (z[i] - ep.target[i]) ** 2; cnt++; } }
|
|
79
|
+
return s / cnt;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Implicit-diff (DEQ) gradient of graphLoss wrt the shared θ (over the free subspace), summed. */
|
|
83
|
+
function graphGrad( gp, th, episodes ) {
|
|
84
|
+
const g = new Array(gp.nParams).fill(0), N = episodes.length * gp.free.length;
|
|
85
|
+
for ( const ep of episodes ) {
|
|
86
|
+
const r = gp.settleFree(th, ep.z0), zf = r.zfree;
|
|
87
|
+
const gl = zf.map((v, c) => 2 * (v - ep.target[gp.free[c]]) / N);
|
|
88
|
+
const Jz = numJac((zz) => gp.freeStep(zz, ep.z0, th), zf), Jt = numJac((tt) => gp.freeStep(zf, ep.z0, tt), th);
|
|
89
|
+
const gr = implicitGrad(Jz, Jt, gl, { mode: 'direct' });
|
|
90
|
+
for ( let k = 0; k < gp.nParams; k++ ) g[k] += gr.grad[k];
|
|
91
|
+
}
|
|
92
|
+
return g;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Train the shared node-rule on episodes (Adam). @returns { theta, loss0, loss }. */
|
|
96
|
+
function trainGraph( gp, episodes, conf ) {
|
|
97
|
+
conf = conf || {};
|
|
98
|
+
const steps = conf.steps || 1500, lr = conf.lr || 0.05;
|
|
99
|
+
let th = (conf.theta0 || gp.randomParams(conf.rng, conf.initScale)).slice();
|
|
100
|
+
const loss0 = graphLoss(gp, th, episodes), mm = th.map(() => 0), vv = th.map(() => 0), b1 = 0.9, b2 = 0.999;
|
|
101
|
+
for ( let s = 1; s <= steps; s++ ) {
|
|
102
|
+
const gr = graphGrad(gp, th, episodes);
|
|
103
|
+
for ( let k = 0; k < th.length; k++ ) { mm[k] = b1 * mm[k] + (1 - b1) * gr[k]; vv[k] = b2 * vv[k] + (1 - b2) * gr[k] * gr[k]; th[k] -= lr * (mm[k] / (1 - b1 ** s)) / (Math.sqrt(vv[k] / (1 - b2 ** s)) + 1e-8); }
|
|
104
|
+
}
|
|
105
|
+
return { theta: th, loss0, loss: graphLoss(gp, th, episodes) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { graphPopulation, graphLoss, graphGrad, trainGraph };
|