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,129 @@
|
|
|
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
|
+
* borderline — the BORDERLINE-ONLY LLM gate (G-1 rung 2; the "ask the librarian" LAST resort). The library-science
|
|
10
|
+
* dispatch pattern (owner) + wiseways.me's production shape: the deterministic barrier (`canonicalize.js`, exact members
|
|
11
|
+
* + curated synonym ring) resolves ~everything; a small model is consulted ONLY for the thin residual band — a surface
|
|
12
|
+
* in NEITHER the enum members NOR the ring — and even then it may only PROPOSE, never author.
|
|
13
|
+
*
|
|
14
|
+
* SOUNDNESS (the load-bearing discipline — why this does not reopen the false-merge the barrier guards):
|
|
15
|
+
* • OUTSIDE the barrier. `canonValue` stays a pure, deterministic, confluent lookup. This gate runs only AFTER it MISSES.
|
|
16
|
+
* • RE-CANONICALIZED. The model's answer is snapped back through the SAME spec (`canonValue`) — a hallucinated or
|
|
17
|
+
* free-text answer becomes a member or a miss; the model can NEVER put raw prose on a dependency edge (fuzzy recall,
|
|
18
|
+
* EXACT truth). Constrained decoding (`enumGbnf`) makes the answer a valid member/`none` by construction.
|
|
19
|
+
* • PROVISIONAL. A borderline resolution is a GUESS on out-of-vocabulary. It is returned `via:'llm-borderline',
|
|
20
|
+
* provisional:true` — best-effort for THIS run's dispatch, but treated like a CanonMiss for MEMOIZATION (never mint a
|
|
21
|
+
* reusable digest → never CACHE a borderline merge). So an un-validated guess can serve one dispatch but can never
|
|
22
|
+
* become a cached false-merge.
|
|
23
|
+
* • PROPOSE-ONLY (LILO/AutoDoc). It also returns a `proposal:{alias,member}` — a candidate SYNONYM-RING entry for
|
|
24
|
+
* human/gate validation. Admitted proposals grow the ring, which is deterministic + confluent (validated by
|
|
25
|
+
* `validate.js`'s critical-pair check), so the exogenous vocabulary CONVERGES and the model fires ever less.
|
|
26
|
+
*
|
|
27
|
+
* const { makeLocalAsk } = require('skynet-graph/lib/providers/llm-local');
|
|
28
|
+
* const snap = makeBorderlineSnap({ ask: makeLocalAsk({ modelPath, gbnf: null }) }); // a small local model suffices
|
|
29
|
+
* const r = await snap('catastrophic', { enum: ['low','high'], synonyms: { high: ['severe'] } });
|
|
30
|
+
* // r = { value:'high', via:'llm-borderline', provisional:true, proposal:{ alias:'catastrophic', member:'high' } }
|
|
31
|
+
*/
|
|
32
|
+
var canonicalize = require('./canonicalize');
|
|
33
|
+
|
|
34
|
+
// A GBNF grammar restricting the model's reply to a bare enum member or "none" (constrained decoding = the answer is a
|
|
35
|
+
// valid member by construction). Members are emitted as quoted GBNF string literals. Pass as `makeLocalAsk({ gbnf })`.
|
|
36
|
+
function enumGbnf( spec ) {
|
|
37
|
+
var members = (spec && spec.enum) || [];
|
|
38
|
+
var alts = members.concat(['none']).map(function ( m ) { return JSON.stringify(String(m)); });
|
|
39
|
+
return 'root ::= ' + alts.join(' | ');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Extract the chosen member from a model reply, THROUGH the barrier (so the ring + normalization apply). Tries the whole
|
|
43
|
+
// reply first (a constrained/one-word answer), then a token scan (robust to a free-text answer). Returns the member or null.
|
|
44
|
+
function pickMember( answer, spec ) {
|
|
45
|
+
var whole = canonicalize.canonValue(String(answer == null ? '' : answer).trim(), spec);
|
|
46
|
+
if ( !whole.miss ) return whole.value;
|
|
47
|
+
var toks = String(answer == null ? '' : answer).toLowerCase().split(/[^a-z0-9_-]+/);
|
|
48
|
+
for ( var i = 0; i < toks.length; i++ ) {
|
|
49
|
+
if ( !toks[i] ) continue;
|
|
50
|
+
var c = canonicalize.canonValue(toks[i], spec);
|
|
51
|
+
if ( !c.miss ) return c.value;
|
|
52
|
+
}
|
|
53
|
+
return null; // "none" / unresolvable → stays a miss
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
var DEFAULT_SYS = 'You map a term to the single closest label from a fixed list, or "none" if it fits none. Reply with only the label.';
|
|
57
|
+
function defaultPrompt( raw, members ) {
|
|
58
|
+
return 'Labels: ' + members.join(', ') + ', none.\nWhich single label best matches the term "' + String(raw) + '"? Reply with only the label.';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a borderline snapper over an `ask` backend (e.g. `makeLocalAsk(...)` — a SMALL local model is enough).
|
|
63
|
+
* @param opts.ask async ({system,user,maxTokens,temperature,grammar}) -> string (the model seam; the ONLY coupling).
|
|
64
|
+
* @param opts.system optional system prompt override.
|
|
65
|
+
* @param opts.prompt optional (raw, members) -> user-prompt override.
|
|
66
|
+
* @param opts.constrain opt-in: request CONSTRAINED decoding — pass `grammar:{gbnf:enumGbnf(spec)}` per call so the reply
|
|
67
|
+
* is a valid member or "none" BY CONSTRUCTION (a backend that understands `grammar`, e.g.
|
|
68
|
+
* `makeLocalAsk`, applies it; others ignore it). DEFAULT off = free-text + `pickMember` re-canon:
|
|
69
|
+
* the signature-stability screen found constrained decoding a semantic-consistency HAZARD on a small
|
|
70
|
+
* model, so grammar is FORMAT-INSURANCE, not the default. (The Park/GAD distortion is muted here —
|
|
71
|
+
* a single-enum-token reply has no multi-token future to renormalize.)
|
|
72
|
+
* @returns async snap(raw, spec) -> { value, miss?, via?, provisional?, proposal? }
|
|
73
|
+
* If the deterministic barrier already resolves `raw` (member or ring), returns THAT (the model is NOT called).
|
|
74
|
+
* On a genuine miss, consults the model, re-canonicalizes, and returns a PROVISIONAL member (+ a ring proposal)
|
|
75
|
+
* or the original miss (→ the host's existing CanonMiss escalation). Never mutates the spec/ring.
|
|
76
|
+
*/
|
|
77
|
+
function makeBorderlineSnap( opts ) {
|
|
78
|
+
opts = opts || {};
|
|
79
|
+
var ask = opts.ask;
|
|
80
|
+
if ( typeof ask !== 'function' ) throw new Error('makeBorderlineSnap: opts.ask (a model backend) is required');
|
|
81
|
+
var system = opts.system || DEFAULT_SYS;
|
|
82
|
+
var mkPrompt = opts.prompt || defaultPrompt;
|
|
83
|
+
var constrain = opts.constrain; // opt-in: per-call constrained decoding (format-insurance)
|
|
84
|
+
|
|
85
|
+
return async function snap( raw, spec ) {
|
|
86
|
+
spec = spec || {};
|
|
87
|
+
var det = canonicalize.canonValue(raw, spec);
|
|
88
|
+
if ( !det.miss ) return det; // the deterministic barrier resolved it → NEVER call the model
|
|
89
|
+
if ( !spec.enum || raw == null || String(raw).trim() === '' ) return det; // nothing to disambiguate
|
|
90
|
+
|
|
91
|
+
var req = { system: system, user: mkPrompt(raw, spec.enum), maxTokens: 16, temperature: 0 };
|
|
92
|
+
if ( constrain ) req.grammar = { gbnf: enumGbnf(spec) }; // per-call grammar (tracks THIS spec; un-bakeable at construction)
|
|
93
|
+
var answer = await ask(req);
|
|
94
|
+
var member = pickMember(answer, spec); // re-canonicalized through the SAME spec (defense in depth)
|
|
95
|
+
if ( member == null ) return det; // model said none / unresolvable → stays a miss (escalate as today)
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
value: member, via: 'llm-borderline', provisional: true, // best-effort for THIS run; treat like CanonMiss for memo
|
|
99
|
+
proposal: { alias: String(raw), member: member }, // a candidate ring entry for human/gate validation (propose-only)
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Batch helper: canonicalize a raw reply against a facts schema (the deterministic barrier), then run the borderline gate
|
|
106
|
+
* on the MISSES only. Returns the same shape as `canonFacts` plus `borderline` (the provisional snaps) and `proposals`
|
|
107
|
+
* (candidate ring entries). The still-unresolved keys remain in `misses` (→ the host's CanonMiss escalation). `provisional`
|
|
108
|
+
* keys carry a member value for dispatch but MUST be treated as un-cacheable (no reusable digest) by the caller.
|
|
109
|
+
* @param snap a `makeBorderlineSnap(...)` snapper.
|
|
110
|
+
*/
|
|
111
|
+
async function borderlineFacts( raw, factsSchema, snap ) {
|
|
112
|
+
raw = raw || {};
|
|
113
|
+
var base = canonicalize.canonFacts(raw, factsSchema);
|
|
114
|
+
var facts = Object.assign({}, base.facts), misses = [], borderline = [], proposals = [];
|
|
115
|
+
for ( var i = 0; i < base.misses.length; i++ ) {
|
|
116
|
+
var key = base.misses[i], spec = (factsSchema || {})[key] || {};
|
|
117
|
+
var src = spec.from != null ? spec.from : key;
|
|
118
|
+
var r = await snap(raw[src], spec);
|
|
119
|
+
if ( r && r.provisional && !r.miss ) {
|
|
120
|
+
facts[key] = r.value; borderline.push({ key: key, raw: raw[src], member: r.value });
|
|
121
|
+
if ( r.proposal ) proposals.push(Object.assign({ key: key }, r.proposal));
|
|
122
|
+
} else {
|
|
123
|
+
misses.push(key); // still out-of-vocab → the existing fail-closed escalation
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { facts: facts, misses: misses, synonyms: base.synonyms, borderline: borderline, proposals: proposals };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { makeBorderlineSnap: makeBorderlineSnap, borderlineFacts: borderlineFacts, enumGbnf: enumGbnf, pickMember: pickMember };
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
* DERIVATION CACHE — an ADDITIVE, opt-in way to use the engine that benefits from the rest (it changes
|
|
10
|
+
* NOTHING in the core or in any grammar). It is a content-addressed memo over a PROVIDER: a provider's
|
|
11
|
+
* mutation template is keyed on the CANONICAL JUSTIFICATION of the cast, so a retract→re-derive cycle
|
|
12
|
+
* (finding #22 re-RUNS the provider — a fresh, expensive LLM call) becomes a hash lookup, and the SAME
|
|
13
|
+
* typed sub-problem flowing through the SAME method (a second instance) replays instead of re-calling.
|
|
14
|
+
*
|
|
15
|
+
* Soundness (study `doc/WIP/studies/2026-06-27-method-instance-workflow-cache-control.md` §2): a cached
|
|
16
|
+
* template stored under `(C, d)` may be REPLAYED iff `digest(canonical-justification) = d`; the fixpoint is
|
|
17
|
+
* then identical on all TRACKED facts (prose is untracked, no gate reads it). The cache's trust surface ⊆
|
|
18
|
+
* the engine's EXISTING incrementality contract (the skip-on-already-cast of #22 already assumes tracked
|
|
19
|
+
* facts are a stable function of the justification), so it adds ZERO new unsoundness. It is the "good" dual
|
|
20
|
+
* of the nogood cache (`learn-nogood.js`), and the fast/episodic half of CLS whose slow half is
|
|
21
|
+
* `crystallize.js` — a hot key auto-nominates its chain for crystallization (wire `stats`/`onHot`).
|
|
22
|
+
*
|
|
23
|
+
* Discipline (the blocking points the study flags):
|
|
24
|
+
* - the KEY must capture everything the provider READS. Only the provider knows that, so the key fn is
|
|
25
|
+
* supplied per provider (default = the concept's memo-surface projection, sound when the provider reads
|
|
26
|
+
* only its gate facts). A cross-object provider (reads endpoint states, etc.) supplies a key fn that
|
|
27
|
+
* digests those reads — see `keyFromScope`.
|
|
28
|
+
* - a key fn returning `null` (a `CanonMiss`, an unkeyable/exploratory cast) BYPASSES the cache (fail-open
|
|
29
|
+
* to a fresh call — never a wrong replay).
|
|
30
|
+
* - the key is namespaced by concept name + an optional method/lib VERSION token (B8): a method edit must
|
|
31
|
+
* not serve a stale template to a new-version instance. Pass `version()` (e.g. a concept-lib digest).
|
|
32
|
+
*/
|
|
33
|
+
const { digest } = require('./canonicalize.js');
|
|
34
|
+
|
|
35
|
+
function clone( x ) { return x == null ? x : JSON.parse(JSON.stringify(x)); }
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param opts.store a Map-like (has/get/set/delete/size) — default a bounded in-memory LRU-ish Map
|
|
39
|
+
* @param opts.max max entries before oldest-eviction (default 5000)
|
|
40
|
+
* @param opts.version () => string — a method/concept-lib version token folded into every key (B8)
|
|
41
|
+
* @param opts.onHit (key, concept) => void (e.g. bump a crystallization counter)
|
|
42
|
+
* @param opts.onMiss (key, concept) => void — a cold (uncached) derivation; symmetric to onHit (hot-spot tally)
|
|
43
|
+
* @param opts.canonMiss (tpl, concept) => bool — treat a result as un-cacheable (default: a `<name>CanonMiss` flag)
|
|
44
|
+
*/
|
|
45
|
+
function createProviderCache( opts ) {
|
|
46
|
+
opts = opts || {};
|
|
47
|
+
const store = opts.store || new Map();
|
|
48
|
+
const max = opts.max || 5000;
|
|
49
|
+
const version = opts.version || (() => '');
|
|
50
|
+
const canonMiss = opts.canonMiss || defaultCanonMiss;
|
|
51
|
+
// FIDELITY-GATED REPLAY (opt-in; default = exact-key hit/miss, regression-clean). Per-key the cache learns how
|
|
52
|
+
// reliably the canonical key predicts the provider's verdict: a few WARMUP hits are VERIFIED (re-derive + compare
|
|
53
|
+
// to the cached template) to build a reproduce-rate; thereafter a high-rate key REPLAYS (0 calls) but a low-rate
|
|
54
|
+
// key — a LOSSY canonicalization that drops a decision-relevant fact — ESCALATES (always re-derives, never serves
|
|
55
|
+
// a stale entry). This is the continuous form of the K1 cliff (the "cost gradient, not cliff" / micro-task floor),
|
|
56
|
+
// borrowed from Proxy-KD's per-sample fidelity weight w(x,y) (arXiv:2401.07013 v2, principle 4 of the 2026-06-28 study).
|
|
57
|
+
// opts.fidelity = { threshold=0.8, warmup=3, equals?(a,b) } (omit → fidelity gating OFF)
|
|
58
|
+
const fidelity = opts.fidelity || null;
|
|
59
|
+
const fidThreshold = fidelity && fidelity.threshold != null ? fidelity.threshold : 0.8;
|
|
60
|
+
const fidWarmup = fidelity && fidelity.warmup != null ? fidelity.warmup : 3;
|
|
61
|
+
const tplEquals = ( fidelity && fidelity.equals ) || (( a, b ) => digest(a) === digest(b));
|
|
62
|
+
const fidStore = new Map(); // key -> { ok, total }
|
|
63
|
+
// SINGLE-FLIGHT (in-flight dedup) — default ON, opt-out via opts.singleflight === false. The engine casts
|
|
64
|
+
// unstable objects as a CONCURRENT pool (taskflow async pool in `lib/graph/tasks/`), so N same-key derivations
|
|
65
|
+
// can be in flight at once. The cache only `set`s AFTER a provider resolves, so without coalescing each of the
|
|
66
|
+
// N fires the provider (a race → redundant work, NO amortization; correctness is unaffected — same key, same
|
|
67
|
+
// answer). Coalescing concurrent same-key misses onto ONE provider run makes amortization robust to the
|
|
68
|
+
// engine's native concurrency, not just to a serialized/streaming execution model. (Transparent to SYNC
|
|
69
|
+
// providers: the first call resolves + stores before the second starts, so there is never an in-flight window.)
|
|
70
|
+
const singleflight = opts.singleflight !== false;
|
|
71
|
+
const inflight = new Map(); // key -> [ wake-thunk ] (waiters re-run the lookup once the originator resolves)
|
|
72
|
+
const stats = { hits: 0, misses: 0, bypass: 0, stores: 0, calls: 0, verified: 0, escalated: 0, coalesced: 0 };
|
|
73
|
+
|
|
74
|
+
function fullKey( concept, sub ) { return concept._name + '' + version() + '' + sub; }
|
|
75
|
+
|
|
76
|
+
function evict() { if ( store.size > max ) { const k = store.keys().next().value; store.delete(k); } }
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Wrap a provider fn `(graph, concept, scope, argz, cb)` with content-addressed caching.
|
|
80
|
+
* @param fn the provider to memoize
|
|
81
|
+
* @param keyFn (graph, concept, scope, argz) => canonical-input (object|string) | null
|
|
82
|
+
* returns null to BYPASS (CanonMiss / exploratory / unkeyable). Default = memo-surface.
|
|
83
|
+
* @param transform optional `{ onStore(tpl, info), onReplay(tpl, info) }` — a GENERIC store/replay
|
|
84
|
+
* rewrite (info = {graph,concept,scope,argz}). The cache itself stays grammar-agnostic;
|
|
85
|
+
* the F6 abstractivation (`authoring/abstract.js#methodTransform`) supplies these to
|
|
86
|
+
* PARAMETERIZE a structural template on store and BIND it to the new call site on replay,
|
|
87
|
+
* so a structural decision transfers across related-but-different problems (finding #30).
|
|
88
|
+
* onStore returning null = DON'T store (not transfer-safe); onReplay returning null =
|
|
89
|
+
* BYPASS to a fresh call (a frontier ref couldn't be re-bound) — never a wrong replay.
|
|
90
|
+
*/
|
|
91
|
+
function wrap( fn, keyFn, transform ) {
|
|
92
|
+
keyFn = keyFn || defaultKeyFn;
|
|
93
|
+
const onStore = transform && transform.onStore, onReplay = transform && transform.onReplay;
|
|
94
|
+
const wrapped = function ( graph, concept, scope, argz, cb ) {
|
|
95
|
+
stats.calls++;
|
|
96
|
+
// an explicitly-exploratory cast (wants a fresh draw) bypasses.
|
|
97
|
+
if ( scope && scope._ && scope._.explore ) { stats.bypass++; return fn(graph, concept, scope, argz, cb); }
|
|
98
|
+
let sub;
|
|
99
|
+
try { sub = keyFn(graph, concept, scope, argz); } catch ( e ) { sub = null; }
|
|
100
|
+
if ( sub == null ) { stats.bypass++; return fn(graph, concept, scope, argz, cb); }
|
|
101
|
+
const key = fullKey(concept, typeof sub === 'string' ? sub : digest(sub));
|
|
102
|
+
const info = { graph: graph, concept: concept, scope: scope, argz: argz };
|
|
103
|
+
if ( store.has(key) ) {
|
|
104
|
+
let hit = clone(store.get(key));
|
|
105
|
+
if ( onReplay ) { hit = onReplay(hit, info); if ( hit == null ) { stats.bypass++; return fn(graph, concept, scope, argz, cb); } }
|
|
106
|
+
if ( fidelity ) {
|
|
107
|
+
const fs = fidStore.get(key) || { ok: 0, total: 0 };
|
|
108
|
+
const established = fs.total >= fidWarmup;
|
|
109
|
+
const rate = fs.total ? fs.ok / fs.total : 1;
|
|
110
|
+
if ( established && rate < fidThreshold ) { // LOSSY key → escalate (never serve stale)
|
|
111
|
+
stats.escalated++; return fn(graph, concept, scope, argz, cb);
|
|
112
|
+
}
|
|
113
|
+
if ( !established ) { // WARMUP/VERIFY → re-derive, compare, serve fresh
|
|
114
|
+
stats.verified++;
|
|
115
|
+
return fn(graph, concept, scope, argz, function ( err, tpl ) {
|
|
116
|
+
if ( !err && tpl != null ) { fs.total++; if ( tplEquals(tpl, store.get(key)) ) fs.ok++; fidStore.set(key, fs); }
|
|
117
|
+
cb(err, tpl);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
stats.hits++; opts.onHit && opts.onHit(key, concept); return cb(null, hit);
|
|
122
|
+
}
|
|
123
|
+
// SINGLE-FLIGHT: coalesce concurrent same-key MISSES onto ONE provider run. A waiter re-runs the
|
|
124
|
+
// lookup once the originator resolves — a cacheable result → it then HITS the store/replay (with its
|
|
125
|
+
// OWN onReplay+fidelity, so a transform-wrap waiter still rebinds to its call site); an uncacheable
|
|
126
|
+
// CanonMiss (not stored) → it re-derives, exactly as without single-flight (no new unsoundness).
|
|
127
|
+
if ( singleflight ) {
|
|
128
|
+
if ( inflight.has(key) ) { stats.coalesced++; inflight.get(key).push(function () { wrapped(graph, concept, scope, argz, cb); }); return; }
|
|
129
|
+
inflight.set(key, []);
|
|
130
|
+
}
|
|
131
|
+
stats.misses++;
|
|
132
|
+
opts.onMiss && opts.onMiss(key, concept); // symmetric to onHit: a cold (uncached) derivation — the per-concept hot-spot tally reads this + onHit + `fid`
|
|
133
|
+
fn(graph, concept, scope, argz, function ( err, tpl ) {
|
|
134
|
+
if ( !err && tpl != null && !canonMiss(tpl, concept) ) {
|
|
135
|
+
let toStore = tpl;
|
|
136
|
+
if ( onStore ) toStore = onStore(tpl, info); // null = not transfer-safe → skip storing
|
|
137
|
+
if ( toStore != null ) { store.set(key, clone(toStore)); stats.stores++; evict(); }
|
|
138
|
+
}
|
|
139
|
+
cb(err, tpl); // the COLD call returns its own ground template, unperturbed
|
|
140
|
+
if ( singleflight ) {
|
|
141
|
+
const waiters = inflight.get(key) || [];
|
|
142
|
+
inflight.delete(key);
|
|
143
|
+
for ( let i = 0 ; i < waiters.length ; i++ ) waiters[i](); // wake waiters: each re-runs the lookup
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
return wrapped;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Wrap a whole provider FRAGMENT `{Ns: {fn,...}}` — apply a per-fn key map (or the default to all).
|
|
152
|
+
* @param fragment { Ns: { fn: providerFn } }
|
|
153
|
+
* @param keyMap { "Ns::fn": keyFn } (a fn absent from the map uses the default memo-surface key)
|
|
154
|
+
* @param transformMap { "Ns::fn": {onStore,onReplay} } (optional per-fn store/replay transform; see `wrap`)
|
|
155
|
+
*/
|
|
156
|
+
function wrapFragment( fragment, keyMap, transformMap ) {
|
|
157
|
+
keyMap = keyMap || {}; transformMap = transformMap || {};
|
|
158
|
+
const out = {};
|
|
159
|
+
for ( const ns in fragment ) {
|
|
160
|
+
out[ns] = {};
|
|
161
|
+
for ( const fn in fragment[ns] ) {
|
|
162
|
+
const k = keyMap[ns + '::' + fn];
|
|
163
|
+
out[ns][fn] = (k === false) ? fragment[ns][fn] : wrap(fragment[ns][fn], k || undefined, transformMap[ns + '::' + fn]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// default key: the concept's MEMO SURFACE projected off the cast object (sound when the provider reads
|
|
170
|
+
// only its own gate facts). Falls back to bypass if the surface is empty/unknowable.
|
|
171
|
+
function defaultKeyFn( graph, concept, scope ) {
|
|
172
|
+
let keys = [];
|
|
173
|
+
try { keys = require('../authoring/memo-stability.js').memoSurfaceKeys(graph, concept._name) || []; } catch ( e ) { keys = []; }
|
|
174
|
+
const flat = keys.filter(( k ) => k.indexOf(':') < 0); // skip cross-object walks (the provider may read further — bypass unless a custom key is given)
|
|
175
|
+
if ( !flat.length || flat.length !== keys.length ) return null;
|
|
176
|
+
const f = scope._, proj = {};
|
|
177
|
+
for ( const k of flat ) proj[k] = f[k];
|
|
178
|
+
return proj;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { wrap, wrapFragment, stats, store, clear: () => store.clear(), size: () => store.size, key: fullKey, fid: fidStore };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// a result is un-cacheable if it carries a CanonMiss signal (the K1 fail-closed marker).
|
|
185
|
+
function defaultCanonMiss( tpl, concept ) {
|
|
186
|
+
const arr = Array.isArray(tpl) ? tpl : [tpl];
|
|
187
|
+
return arr.some(( t ) => t && typeof t === 'object' && Object.keys(t).some(( k ) => /CanonMiss$/.test(k) && t[k]));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build a key fn that digests a SCOPE projection (the facts the provider reads on the cast object),
|
|
192
|
+
* optionally following cross-object refs the provider consumes. Use this for a cross-object provider.
|
|
193
|
+
* @param spec.facts string[] of fact keys read off the cast object (scope._)
|
|
194
|
+
* @param spec.refs { name: "ref:path" } resolved via scope.getRef (e.g. endpoint states)
|
|
195
|
+
* — returns null (bypass) if any required ref is unresolved.
|
|
196
|
+
* @param spec.require optional: keys/refs that MUST be present (else bypass)
|
|
197
|
+
*/
|
|
198
|
+
function keyFromScope( spec ) {
|
|
199
|
+
spec = spec || {};
|
|
200
|
+
const facts = spec.facts || [], refs = spec.refs || {}, req = spec.require || [];
|
|
201
|
+
return function ( graph, concept, scope ) {
|
|
202
|
+
const f = scope._, key = {};
|
|
203
|
+
for ( const k of facts ) key[k] = f[k];
|
|
204
|
+
for ( const name in refs ) {
|
|
205
|
+
let v;
|
|
206
|
+
try { v = scope.getRef ? scope.getRef(refs[name]) : undefined; } catch ( e ) { v = undefined; }
|
|
207
|
+
key[name] = (v && v._ !== undefined) ? v._ : v; // an entity-ref → its facts; a value-ref → the value
|
|
208
|
+
}
|
|
209
|
+
for ( const r of req ) if ( key[r] === undefined || key[r] === null ) return null; // missing input → bypass
|
|
210
|
+
// Return a STABLE STRING (not the object): a ref can resolve to an OBJECT (entity facts or a whole
|
|
211
|
+
// bagRef record), and `digest` uses the TOP-LEVEL keys as a JSON.stringify allowlist — it would strip
|
|
212
|
+
// nested fields, collapsing every record to the same key (a false hit). stableStringify is recursive +
|
|
213
|
+
// key-sorted, so a different record snapshot yields a different key (C1/B2). wrap() uses a string sub as-is.
|
|
214
|
+
return stableStringify(key);
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// deterministic, key-sorted, fully-recursive stringify (digest-safe for nested refs).
|
|
219
|
+
function stableStringify( x ) {
|
|
220
|
+
if ( x === undefined ) return 'null';
|
|
221
|
+
if ( x === null || typeof x !== 'object' ) return JSON.stringify(x);
|
|
222
|
+
if ( Array.isArray(x) ) return '[' + x.map(stableStringify).join(',') + ']';
|
|
223
|
+
return '{' + Object.keys(x).sort().map(( k ) => JSON.stringify(k) + ':' + stableStringify(x[k])).join(',') + '}';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { createProviderCache, keyFromScope, defaultCanonMiss, stableStringify };
|
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
* Deterministic fact canonicalization — the implementation half of the typed-fact
|
|
21
|
+
* spine / canonicalization barrier (doc/MODELISATION.md §4.2, roadmap #1).
|
|
22
|
+
*
|
|
23
|
+
* WHY (the existential risk K1 — prose memo-fragmentation): a concept that depends
|
|
24
|
+
* on variable LLM *prose* re-keys every run (two semantically-equal replies differ
|
|
25
|
+
* textually) → the memo edge never hits → incrementality evaporates, *worse* than
|
|
26
|
+
* nil (you still pay the watcher bookkeeping). The fix is a MODELING rule, enforced
|
|
27
|
+
* here: an `LLM::complete` expert that feeds downstream experts emits a small set of
|
|
28
|
+
* DISCRETE, TYPED, low-cardinality facts (enums, ids, numbers, booleans, short
|
|
29
|
+
* canonical strings); only those are written as *tracked* keys; the free text is a
|
|
30
|
+
* terminal, *untracked* fact. This module snaps the raw LLM values onto that grid.
|
|
31
|
+
*
|
|
32
|
+
* HARD RULE — deterministic snapping ONLY, never embedding/similarity matching.
|
|
33
|
+
* A fuzzy false-hit (GPTCache-style ~33%) graves a WRONG fact that *propagates*
|
|
34
|
+
* through the cascade-invalidation graph (it triggers/inhibits other concepts).
|
|
35
|
+
* Strict structured extraction with a closed vocabulary fails closed instead.
|
|
36
|
+
*
|
|
37
|
+
* A fact spec is a small object:
|
|
38
|
+
* { enum: [...], default? } snap to a closed vocabulary: exact match, else a
|
|
39
|
+
* whitespace/case-normalized match; a miss -> `default`
|
|
40
|
+
* (or null) and is reported in `misses` (fail-closed,
|
|
41
|
+
* visible — never a silent wrong snap).
|
|
42
|
+
* { grain: N } round a numeric to the nearest multiple of N (the
|
|
43
|
+
* per-EDGE grain declaration: the downstream's needed
|
|
44
|
+
* granularity, not a global setting).
|
|
45
|
+
* { type: 'int' | 'number' | 'bool' | 'id' | 'string' } plain typed coercion.
|
|
46
|
+
* { from: 'rawKey' } read the raw value from a different reply key
|
|
47
|
+
* (combinable with any of the above).
|
|
48
|
+
*
|
|
49
|
+
* Everything here is pure and synchronous (independently unit-tested).
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
// ---- closed-vocabulary normalization: trim, lowercase, collapse inner whitespace ----
|
|
53
|
+
function normToken( v ) {
|
|
54
|
+
return String(v == null ? '' : v).trim().toLowerCase().replace(/\s+/g, ' ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// number of decimal places a grain implies (so 0.1 rounds to 1 decimal, 25 -> 0) ----
|
|
58
|
+
function grainDecimals( grain ) {
|
|
59
|
+
var s = String(grain);
|
|
60
|
+
var dot = s.indexOf('.');
|
|
61
|
+
return dot === -1 ? 0 : (s.length - dot - 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─────────────────────────── the enum normalization map (+ optional CURATED SYNONYM RING) ──────────────────────────
|
|
65
|
+
//
|
|
66
|
+
// A per-spec compiled, CONFLUENT normalization map `σ: Σ*/~normToken ⇀ E` extending the enum map with an optional
|
|
67
|
+
// curated synonym ring (`spec.synonyms = { <member>: [<alias>...] }`). Members are the NORMAL FORMS; each alias is a
|
|
68
|
+
// non-preferred term (ISO 25964-1 §10 USE/UF; SKOS altLabel→prefLabel). Keyed POST-`normToken`, so it is a partial
|
|
69
|
+
// function on the normalization quotient → **confluent by construction** (Church-Rosser: normToken is idempotent, one
|
|
70
|
+
// normal form per class), GIVEN the two critical-pair conditions checked here at COMPILE (a violation THROWS):
|
|
71
|
+
// • SINGLE-VALUED — no alias maps to two members; • DISJOINT — no alias `normToken`-collides a member (or a
|
|
72
|
+
// different member's alias). These are the ground-TRS critical pairs (Knuth-Bendix 1970).
|
|
73
|
+
// This is a DETERMINISTIC, closed-domain, author-time LOOKUP — a bigger table, categorically NOT an embedding/similarity
|
|
74
|
+
// oracle (which is non-deterministic, non-confluent, open-domain, runtime — the ~33% GPTCache false-hit). So it stays
|
|
75
|
+
// INSIDE the barrier's HARD RULE ("no non-deterministic open-domain runtime similarity"), it does not weaken it.
|
|
76
|
+
// REGIME (the honest ceiling — signature-stability screen): a strong closed-vocab PROMPT already makes the model emit
|
|
77
|
+
// the MEMBER directly, so a ring is REDUNDANT for model-sourced facts. Ring the EXOGENOUS vocabulary you are forced to
|
|
78
|
+
// ingest (human free-text, legacy CSV/DB, weak/non-LLM upstreams, cross-system status strings) — finite, domain-stable,
|
|
79
|
+
// so the ring CONVERGES (not a treadmill). The residual (a surface in neither members nor ring) stays a fail-closed
|
|
80
|
+
// CanonMiss → the host-owned escalation; NEVER an inline fuzzy/edit-distance match (that reimports the banned oracle).
|
|
81
|
+
var _enumMaps = new WeakMap();
|
|
82
|
+
function compileEnumMap( spec ) {
|
|
83
|
+
var cached = _enumMaps.get(spec);
|
|
84
|
+
if ( cached ) return cached;
|
|
85
|
+
var map = Object.create(null), origin = Object.create(null); // normToken(term) -> member ; origin ∈ member|alias
|
|
86
|
+
var vocab = spec.enum || [];
|
|
87
|
+
for ( var i = 0; i < vocab.length; i++ ) {
|
|
88
|
+
var m = vocab[i], nk = normToken(m);
|
|
89
|
+
if ( nk in map && map[nk] !== m ) throw new Error('enum: two members normalize to "' + nk + '" (' + map[nk] + ' vs ' + m + ')');
|
|
90
|
+
map[nk] = m; origin[nk] = 'member';
|
|
91
|
+
}
|
|
92
|
+
var syn = spec.synonyms || null;
|
|
93
|
+
if ( syn ) {
|
|
94
|
+
for ( var member in syn ) {
|
|
95
|
+
if ( vocab.indexOf(member) < 0 ) throw new Error('synonyms: ring key "' + member + '" is not an enum member'); // malformed → fail-closed
|
|
96
|
+
var aliases = syn[member] || [];
|
|
97
|
+
for ( var a = 0; a < aliases.length; a++ ) {
|
|
98
|
+
var na = normToken(aliases[a]);
|
|
99
|
+
if ( na in map ) {
|
|
100
|
+
if ( map[na] !== member ) // critical pair: alias collides a member / another ring's member
|
|
101
|
+
throw new Error('synonyms: "' + aliases[a] + '" → "' + member + '" collides with "' + map[na] + '" (' + origin[na] + ') — not single-valued/disjoint');
|
|
102
|
+
// else a harmless duplicate (same member) → skip
|
|
103
|
+
} else { map[na] = member; origin[na] = 'alias'; }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
var out = { map: map, origin: origin };
|
|
108
|
+
_enumMaps.set(spec, out);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Canonicalize one raw value against one spec.
|
|
114
|
+
* @returns {{ value: *, miss?: boolean, via?: 'synonym' }} `miss:true` = out-of-vocabulary / un-coercible (falls back
|
|
115
|
+
* to `default`/null). `via:'synonym'` = snapped through a curated ring alias (surfaced for audit/reversibility).
|
|
116
|
+
*/
|
|
117
|
+
function canonValue( raw, spec ) {
|
|
118
|
+
spec = spec || {};
|
|
119
|
+
|
|
120
|
+
// enum: snap to a closed vocabulary (deterministic, fail-closed) via the confluent normalization map ----------
|
|
121
|
+
if ( spec.enum ) {
|
|
122
|
+
var comp = compileEnumMap(spec);
|
|
123
|
+
var nk = normToken(raw); // exact + case/whitespace + curated-ring, one probe
|
|
124
|
+
if ( nk in comp.map ) return comp.origin[nk] === 'alias' ? { value: comp.map[nk], via: 'synonym' } : { value: comp.map[nk] };
|
|
125
|
+
return { value: spec.default !== undefined ? spec.default : null, miss: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// grain: round a numeric to the nearest multiple of N -------------------------
|
|
129
|
+
if ( spec.grain != null ) {
|
|
130
|
+
var n = typeof raw === 'number' ? raw : parseFloat(raw);
|
|
131
|
+
if ( !isFinite(n) ) return { value: spec.default !== undefined ? spec.default : null, miss: true };
|
|
132
|
+
var snapped = Math.round(n / spec.grain) * spec.grain;
|
|
133
|
+
return { value: Number(snapped.toFixed(grainDecimals(spec.grain))) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// plain typed coercion --------------------------------------------------------
|
|
137
|
+
switch ( spec.type ) {
|
|
138
|
+
case 'int': {
|
|
139
|
+
var iv = typeof raw === 'number' ? raw : parseFloat(raw);
|
|
140
|
+
return isFinite(iv) ? { value: Math.round(iv) } : { value: spec.default !== undefined ? spec.default : null, miss: true };
|
|
141
|
+
}
|
|
142
|
+
case 'number': {
|
|
143
|
+
var fv = typeof raw === 'number' ? raw : parseFloat(raw);
|
|
144
|
+
return isFinite(fv) ? { value: fv } : { value: spec.default !== undefined ? spec.default : null, miss: true };
|
|
145
|
+
}
|
|
146
|
+
case 'bool': {
|
|
147
|
+
if ( typeof raw === 'boolean' ) return { value: raw };
|
|
148
|
+
var t = normToken(raw);
|
|
149
|
+
if ( t === 'true' || t === '1' || t === 'yes' || t === 'y' ) return { value: true };
|
|
150
|
+
if ( t === 'false' || t === '0' || t === 'no' || t === 'n' || t === '' ) return { value: false };
|
|
151
|
+
return { value: spec.default !== undefined ? spec.default : null, miss: true };
|
|
152
|
+
}
|
|
153
|
+
case 'id':
|
|
154
|
+
return { value: raw == null ? (spec.default !== undefined ? spec.default : null) : String(raw).trim() };
|
|
155
|
+
case 'string':
|
|
156
|
+
default:
|
|
157
|
+
// a SHORT canonical string (whitespace-normalized). Note: a free-text
|
|
158
|
+
// field is prose, not a fact — keep it out of `facts`/off dependency edges.
|
|
159
|
+
return { value: raw == null ? (spec.default !== undefined ? spec.default : null) : normTokenPreserveCase(raw) };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// like normToken but preserves case (short canonical string, not a vocab key) ----
|
|
164
|
+
function normTokenPreserveCase( v ) {
|
|
165
|
+
return String(v).trim().replace(/\s+/g, ' ');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Canonicalize a raw reply object against a facts schema.
|
|
170
|
+
* @param raw the parsed LLM reply (a plain object)
|
|
171
|
+
* @param factsSchema { <factKey>: <spec> }
|
|
172
|
+
* @returns {{ facts: {<factKey>: value}, misses: [<factKey>...] }}
|
|
173
|
+
* `facts` holds ONLY the declared keys, canonicalized — the tracked spine.
|
|
174
|
+
*/
|
|
175
|
+
function canonFacts( raw, factsSchema ) {
|
|
176
|
+
raw = raw || {};
|
|
177
|
+
var facts = {}, misses = [], synonyms = [];
|
|
178
|
+
Object.keys(factsSchema || {}).forEach(function ( key ) {
|
|
179
|
+
var spec = factsSchema[key] || {};
|
|
180
|
+
var src = spec.from != null ? spec.from : key;
|
|
181
|
+
var out = canonValue(raw[src], spec);
|
|
182
|
+
facts[key] = out.value;
|
|
183
|
+
if ( out.miss ) misses.push(key);
|
|
184
|
+
if ( out.via === 'synonym' ) synonyms.push({ key: key, raw: raw[src], member: out.value }); // audit trail (reversibility)
|
|
185
|
+
});
|
|
186
|
+
return { facts: facts, misses: misses, synonyms: synonyms };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Stable content digest of a canonical fact set — usable as an explicit memo key
|
|
191
|
+
* so identical projections short-circuit *even across re-prose*. Keys sorted so the
|
|
192
|
+
* digest is order-independent.
|
|
193
|
+
*/
|
|
194
|
+
function digest( facts ) {
|
|
195
|
+
return JSON.stringify(facts || {}, Object.keys(facts || {}).sort());
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { canonValue: canonValue, canonFacts: canonFacts, digest: digest, normToken: normToken, compileEnumMap: compileEnumMap };
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
* Typed CONSTAT record (Q6) — promotes memory-on-retraction from an ad-hoc string blob into a
|
|
21
|
+
* STRUCTURED, queryable, bisectable learning surface, with EXISTING engine machinery (the
|
|
22
|
+
* `cleaner` hook + `{__push}` + a surviving anchor). Zero core change.
|
|
23
|
+
*
|
|
24
|
+
* A constat is what a defeasant concept deposits when it RETRACTS: WHAT fell (`kind`/`claim`),
|
|
25
|
+
* WHY (`retractedBecause` — the premise), with what SNAPPED certainty (`certaintyBand` — never a
|
|
26
|
+
* raw float, the barrier), and at which revision (`atRev` — bisectable). The lineage is free:
|
|
27
|
+
* `graph.getCurrentRevision()` + the concept's `_computeWhy` credit graph join it to the trace.
|
|
28
|
+
*
|
|
29
|
+
* Q6 verdict (doc/WIP/plans/2026-06-24-poc-roadmap-learning-tiling.md §4): the log-odds inc/dec
|
|
30
|
+
* channel already exists (semiring + {__push}+fold + snap-band) — do NOT add a core op; the ONE
|
|
31
|
+
* real gain is this typed constat record. The certainty is read from a SNAPPED key
|
|
32
|
+
* (`certaintyBand`/`confBand`), never a raw continuous value (K1).
|
|
33
|
+
*
|
|
34
|
+
* const { createConstat, recordConstat } = require('./constat');
|
|
35
|
+
* register(Graph, [ createConstat() ]); // wires Constat::record
|
|
36
|
+
* // on a defeasant concept: cleaner:['Constat::record'], constat:{ claimKey:'diagnosis', because:'labVerdict' }
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// The canonical constat shape (a `produces`-style declaration — for docs + author-time checks).
|
|
40
|
+
var CONSTAT_FIELDS = {
|
|
41
|
+
kind : { role: 'fact', type: 'id' }, // the concept/claim that retracted
|
|
42
|
+
claim : { role: 'fact', type: 'id' }, // the value it had asserted
|
|
43
|
+
retractedBecause: { role: 'fact', type: 'id' }, // the premise that fell (the defeasance cause)
|
|
44
|
+
certaintyBand : { role: 'fact', enum: ['low', 'medium', 'high', 'certain'] }, // SNAPPED, never raw (K1)
|
|
45
|
+
atRev : { role: 'fact', type: 'int' }, // the revision — bisectable
|
|
46
|
+
note : { role: 'prose' } // free text — UNTRACKED, terminal
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a constat record from the retracting concept + its scope.
|
|
51
|
+
* @param cfg.claimKey the fact key holding the asserted value (e.g. 'diagnosis')
|
|
52
|
+
* @param cfg.because the premise that fell (e.g. 'labVerdict')
|
|
53
|
+
* @param cfg.extra extra fields to merge in (e.g. evidenceRefs)
|
|
54
|
+
*/
|
|
55
|
+
function buildConstat( graph, concept, scope, cfg ) {
|
|
56
|
+
cfg = cfg || {};
|
|
57
|
+
var e = (scope && scope._) || {};
|
|
58
|
+
var rec = {
|
|
59
|
+
kind : concept._name,
|
|
60
|
+
claim : cfg.claimKey != null && e[cfg.claimKey] != null ? e[cfg.claimKey] : null,
|
|
61
|
+
retractedBecause: cfg.because != null ? cfg.because : null,
|
|
62
|
+
// certainty is read from a SNAPPED band key only (barrier); never a raw float
|
|
63
|
+
certaintyBand : e.certaintyBand != null ? e.certaintyBand : (e.confBand != null ? e.confBand : null),
|
|
64
|
+
atRev : graph.getCurrentRevision()
|
|
65
|
+
};
|
|
66
|
+
if ( cfg.extra ) for ( var k in cfg.extra ) rec[k] = cfg.extra[k];
|
|
67
|
+
return rec;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `{__push}` mutation fragment a cleaner returns to deposit a constat on a surviving anchor
|
|
72
|
+
* (race-free append — a shared `lessons` array, not a distinct key).
|
|
73
|
+
* @param cfg.memId/storeKey the anchor node + array fact (default 'mem'/'lessons')
|
|
74
|
+
*/
|
|
75
|
+
function recordConstat( graph, concept, scope, cfg ) {
|
|
76
|
+
cfg = cfg || {};
|
|
77
|
+
var tpl = { $$_id: cfg.memId || 'mem' };
|
|
78
|
+
tpl[cfg.storeKey || 'lessons'] = { __push: buildConstat(graph, concept, scope, cfg) };
|
|
79
|
+
return tpl;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Package the constat cleaner provider (host opt-in, like createNogood / createVerifier).
|
|
84
|
+
* @returns { Constat: { record } }
|
|
85
|
+
*
|
|
86
|
+
* Wiring on a defeasant concept:
|
|
87
|
+
* { ..., cleaner:['Constat::record'], constat:{ claimKey:'diagnosis', because:'labVerdict' } }
|
|
88
|
+
* On uncast, reads the concept's `constat` config (+ any argz) and deposits the typed record.
|
|
89
|
+
*/
|
|
90
|
+
function createConstat() {
|
|
91
|
+
return {
|
|
92
|
+
Constat: {
|
|
93
|
+
record: function ( graph, concept, scope, argz, cb ) {
|
|
94
|
+
var cfg = Object.assign({ memId: 'mem', storeKey: 'lessons' },
|
|
95
|
+
concept._schema && concept._schema.constat, argz && argz[0]);
|
|
96
|
+
cb(null, recordConstat(graph, concept, scope, cfg));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
CONSTAT_FIELDS: CONSTAT_FIELDS,
|
|
104
|
+
buildConstat : buildConstat,
|
|
105
|
+
recordConstat : recordConstat,
|
|
106
|
+
createConstat : createConstat
|
|
107
|
+
};
|