diff --git a/CHANGELOG.md b/CHANGELOG.md index 734a949..5dd9b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to CK.Lib.Js are documented here. +## [Unreleased] + +### Added +- **`ck-notation.js` — Concept Kernel Notation (CKN) compiler** (proposed; #7). An additive, + separately-importable module at `@conceptkernel/cklib/notation` that turns an instance-plane + notation expression into a construct on a live kernel: `compile(source) → plan` (pure, + inspectable `[{verb, payload}]`) and `assemble(handle, planOrSource)` (runs the plan through a + `ConceptKernel` handle). Plan-emitter only — no RDF/quad store/query language; `ck.js` + unchanged; zero runtime deps; offline-tested (`tests/smoke-notation.mjs`). Instance plane + (χ/ρ→create · edges→link · τ→transition · π→verify/provenance) over shipped verbs; the genome + plane (σ/α/γ → propose ▸ vote ▸ apply) is deferred to a later slice. Ships as its **own** + release cut, not folded into the scoring byte-set. + ## [1.5.3] — 2026-07-01 > **✅ RELEASED 2026-07-01 — attested-success.** CI run `28545233818` → `ghcr.io/conceptkernel/ck-lib-js:1.5.3` diff --git a/ck-notation.js b/ck-notation.js new file mode 100644 index 0000000..46ab190 --- /dev/null +++ b/ck-notation.js @@ -0,0 +1,205 @@ +// ck-notation.js — CK.Lib.Js L3: the Concept Kernel Notation (CKN) compiler. +// +// One import to turn a notation expression into a construct on a live kernel: +// import { compile, assemble } from '@conceptkernel/cklib/notation'; +// +// Concept Kernel Notation (conceptkernel.org) says WHAT a kernel is and WHAT to lay down in +// it, as the strands χ · ρ · σ · α · γ · π · δ · φ ⟫ε. This module takes an INSTANCE-plane +// expression — the χ/ρ/edges/τ/π strands: instances, their properties, edges, transitions and +// attestations over an ALREADY-declared genome — and compiles it to an ordered dispatch plan +// the shipped verb surface (ck.js) already serves. Notation in, sealed construct out. +// +// INVARIANTS — the acceptance bar (issue #7). None of these may be crossed: +// • Plan-emitter, not a graph. compile() output is [{verb, payload}] — no RDF, no quad +// store, no query language emitted. The L1 store-not-graph invariant (ck.js header) holds. +// • Zero authority. Every step runs through the handle's governed dispatch; pgCK gates each. +// The plan is a PROPOSAL — inspectable before it runs; the server still decides every step. +// compile() resolves / evaluates / traverses nothing. +// • ck.js unchanged. This module sits strictly ABOVE ConceptKernel, using only its public +// surface (here: handle.do, the open affordance form) — no new core method, no new transport. +// • Zero runtime deps; compile() is pure (no I/O) so it tests offline, like the smoke-*.mjs. +// +// SCOPE — slice 1 is the instance plane only, over shipped, ungated verbs. The genome plane +// (declaring new χ/ρ/σ/α/γ → kernel.propose ▸ vote ▸ apply) is DEFERRED to slice 2: a +// genome-plane expression is rejected here rather than pretended. When that plane lands, its +// steps degrade honestly to `gov_plane_unavailable`, mirroring ck.js's existing `_gov` path. + +const CKP = 'https://conceptkernel.org/ontology/v3.8/core#'; + +// Strand labels — the nine-symbol alphabet and its ASCII aliases. +const STRAND = { + 'χ': 'chi', chi: 'chi', + 'ρ': 'rho', rho: 'rho', + 'τ': 'tau', tau: 'tau', + 'π': 'pi', pi: 'pi', + edges: 'edges', edge: 'edges', + 'σ': 'genome', sigma: 'genome', // shape strand — genome plane, slice 2 + 'α': 'genome', alpha: 'genome', // affordance strand — genome plane, slice 2 + 'γ': 'genome', gamma: 'genome', // grant strand — genome plane, slice 2 +}; + +/** A typed compile error — thrown by compile()/parse() on malformed or out-of-scope source. + * (Substrate rejections at assemble() time are NOT errors: they are recorded honestly per step.) */ +export class NotationError extends Error { + constructor(code, message) { super(message); this.name = 'NotationError'; this.code = code; } +} + +const isUrn = (s) => /^ s.replace(/^$/, ''); + +/** Resolve a type token: full IRI as-is · CURIE `ckp:X` via the core namespace · bare name → core. */ +function resolveType(tok) { + if (/^https?:\/\//.test(tok) || tok.startsWith('urn:')) return tok; + const m = tok.match(/^([A-Za-z][\w-]*):(.+)$/); + if (m && m[1] === 'ckp') return CKP + m[2]; + if (m) return tok; // an unknown prefix is left verbatim — the server resolves or rejects it + return CKP + tok; +} + +/** + * parse(source) → { kernel, members, props, edges, transitions, attests } + * Pure. Exposed for callers that want to inspect the declaration (e.g. assert the declared + * kernel) before compiling. Throws NotationError on a malformed or genome-plane expression. + */ +export function parse(source) { + if (typeof source !== 'string' || !source.trim()) throw new NotationError('parse_error', 'empty notation expression'); + const src = source + .split('\n').map((l) => l.replace(/;.*$/, '')).join('\n') // ';' starts a comment + .replace(/[⟪⟫]/g, '\n'); + + const head = src.match(/CK\s*\(\s*([^)\s]+)\s*\)\s*(?:⟦\s*(\w+)\s*⟧|\[\[\s*(\w+)\s*\]\])?\s*(?:≜|:=)?/); + if (!head) throw new NotationError('parse_error', 'missing CK( Kernel ) header'); + const plane = (head[2] || head[3] || 'instance').toLowerCase(); + if (plane !== 'instance') throw new NotationError('genome_plane_deferred', `plane ⟦${plane}⟧ is the genome plane — deferred to slice 2 (propose ▸ vote ▸ apply)`); + + const ast = { kernel: head[1], members: [], props: {}, edges: [], transitions: [], attests: [] }; + const memberSet = new Set(); + const body = src.slice(src.indexOf(head[0]) + head[0].length); + + let current = null, buf = [], genome = false; + const flush = () => { if (current && buf.length) strand(current, buf.join(' ')); buf = []; }; + for (const line of body.split('\n')) { + const m = line.match(/^\s*([χρστπσαγ]|chi|rho|tau|pi|sigma|alpha|gamma|edges?)\s*[:=]\s*(.*)$/u); + if (m) { flush(); current = STRAND[m[1]]; buf = [m[2]]; } + else if (current && line.trim()) buf.push(line.trim()); + } + flush(); + if (genome) throw new NotationError('genome_plane_deferred', 'σ/α/γ declaration strands are the genome plane — deferred to slice 2'); + + function strand(kind, s) { + if (kind === 'genome') { genome = true; return; } + if (kind === 'chi') { + for (const m of s.matchAll(/([^\s,()]+)\s*\(\s*([A-Za-z]\w*)\s*\)/g)) { + ast.members.push({ ref: m[2], type: resolveType(m[1]) }); + memberSet.add(m[2]); + } + } else if (kind === 'rho') { + for (const m of s.matchAll(/([A-Za-z]\w*|<[^>]+>|urn:\S+)\.([\w:]+)\s*=\s*("([^"]*)"|'([^']*)'|\S+)/g)) { + (ast.props[m[1]] ||= []).push({ p: m[2], o: m[4] ?? m[5] ?? m[3] }); + } + } else if (kind === 'edges') { + for (const m of s.matchAll(/([A-Za-z]\w*|<[^>]+>|urn:\S+)\s*[—-]\s*([\w:]+)\s*(?:→|->)\s*(\(\s*[^)]+\)|[A-Za-z]\w*|<[^>]+>|urn:\S+)/gu)) { + const targets = m[3].startsWith('(') + ? m[3].slice(1, -1).split(/∥|\|\|/).map((t) => t.trim()).filter(Boolean) + : [m[3]]; + for (const t of targets) ast.edges.push({ source: unwrap(m[1]), predicate: m[2], target: unwrap(t) }); + } + } else if (kind === 'tau') { + const m = s.match(/^\s*([A-Za-z]\w*|<[^>]+>|urn:\S+)\s*(.+)$/u); + if (!m) return; + const states = [...m[2].matchAll(/(?:→|->)\s*'([^']+)'/g)].map((x) => x[1]); + if (states.length) ast.transitions.push({ ref: unwrap(m[1]), states }); + } else if (kind === 'pi') { + for (const m of s.matchAll(/(verify|provenance)\s*\(\s*([A-Za-z]\w*|<[^>]+>|urn:\S+)\s*\)/gu)) { + ast.attests.push({ op: m[1], ref: unwrap(m[2]) }); + } + } + } + + for (const t of Object.keys(ast.props)) { + if (!memberSet.has(t) && !isUrn(t)) throw new NotationError('parse_error', `ρ targets unknown member '${t}'`); + } + for (const e of ast.edges) for (const end of [e.source, e.target]) { + if (!memberSet.has(end) && !isUrn(end)) throw new NotationError('parse_error', `edge references unknown member '${end}'`); + } + if (!ast.members.length && !ast.edges.length && !ast.transitions.length && !ast.attests.length) { + throw new NotationError('parse_error', 'expression declares nothing (no χ/edges/τ/π)'); + } + return ast; +} + +/** A symbolic reference to a member created earlier in the plan (resolved at assemble time). */ +const ref = (name) => ({ $ref: name }); +const end = (x) => (isUrn(x) ? unwrap(x) : ref(x)); + +/** + * compile(source) → plan + * Pure, I/O-free. `plan` is an ordered array of `{ verb, payload, ref? }` steps — a dispatch + * plan, not a graph. `ref` names the member a create binds; `{ $ref }` markers in payloads are + * resolved from earlier steps at assemble() time. Order is the notation's own reading: + * create(χ+ρ) → update(URN-ρ) → link(edges) → transition(τ) → verify/provenance(π). + * + * The compiler injects NO type-specific field: every property comes from the ρ strand exactly + * as written, so a type's declared-required fields (e.g. a shape's target_kernel) are the + * author's to supply — the server rejects a missing one honestly, as it does for any create. + */ +export function compile(source) { + const ast = parse(source); + const plan = []; + for (const m of ast.members) { + const payload = { type: m.type }; + for (const { p, o } of ast.props[m.ref] || []) payload[p.replace(/^ckp:/, '')] = o; + plan.push({ verb: 'instance.create', payload, ref: m.ref }); + } + for (const [target, props] of Object.entries(ast.props)) { + if (!isUrn(target)) continue; // member ρ already folded into its create + const payload = { id: unwrap(target) }; + for (const { p, o } of props) payload[p.replace(/^ckp:/, '')] = o; + plan.push({ verb: 'instance.update', payload }); + } + for (const e of ast.edges) plan.push({ verb: 'instance.link', payload: { source: end(e.source), predicate: e.predicate, target: end(e.target) } }); + for (const t of ast.transitions) for (const s of t.states) plan.push({ verb: 'instance.transition', payload: { id: end(t.ref), to_state: s } }); + for (const a of ast.attests) plan.push({ verb: `instance.${a.op}`, payload: { id: end(a.ref) } }); + return plan; +} + +/** + * assemble(handle, planOrSource, opts?) → { ok, urns, steps } + * Runs a plan through a live ConceptKernel handle. Each step is dispatched via the handle's + * open `do(verb, payload)` form (so the store ingests replies and pgCK gates every step); + * `{ $ref }` payload values resolve from the sealed ids of earlier steps. + * + * Honest degrade (never throws on a substrate reject): a failed create aborts the steps that + * reference it (`skipped_dependency`); any other rejected step is recorded and the walk + * continues. Nothing is fabricated — a step reports the substrate's own error. + * (compile() DOES throw on malformed / genome-plane source: that is author error, not substrate.) + */ +export async function assemble(handle, planOrSource, opts = {}) { + const plan = typeof planOrSource === 'string' ? compile(planOrSource) : planOrSource; + const bound = Object.create(null); + const dead = new Set(); + const steps = []; + const resolve = (v) => (v && typeof v === 'object' && v.$ref) ? bound[v.$ref] : v; + for (const step of plan) { + const refs = Object.values(step.payload).filter((v) => v && v.$ref).map((v) => v.$ref); + if (refs.some((r) => dead.has(r))) { + steps.push({ verb: step.verb, ok: false, error: 'skipped_dependency', refs }); + if (step.ref) dead.add(step.ref); + continue; + } + const payload = {}; + for (const [k, v] of Object.entries(step.payload)) payload[k] = resolve(v); + let reply; + try { reply = await handle.do(step.verb, payload, opts); } + catch (e) { reply = { ok: false, error: String((e && e.message) || e) }; } + const ok = !!reply && reply.ok !== false; + const id = reply && (reply.id ?? reply.result?.['@id']); + if (step.ref) { if (ok && id) bound[step.ref] = id; else dead.add(step.ref); } + steps.push(ok + ? { verb: step.verb, ok: true, ...(step.ref ? { ref: step.ref } : {}), id, proof_digest: reply.proof_digest ?? null, verified: reply.verified ?? undefined } + : { verb: step.verb, ok: false, ...(step.ref ? { ref: step.ref } : {}), error: (reply && reply.error) || 'rejected' }); + } + return { ok: steps.every((s) => s.ok), urns: bound, steps }; +} + +export default { parse, compile, assemble, NotationError }; diff --git a/package.json b/package.json index 1f46f40..c8d474e 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,14 @@ "main": "ck.js", "exports": { ".": "./ck.js", + "./notation": "./ck-notation.js", "./internal/client": "./ck-client.js", "./internal/store": "./ck-store.js", "./client": "./ck-client.js" }, "files": [ "ck.js", + "ck-notation.js", "ck-client.js", "ck-store.js", "vendor/", @@ -20,8 +22,8 @@ "CHANGELOG.md" ], "scripts": { - "check": "node --check ck.js && node --check ck-client.js && node --check ck-store.js", - "test": "npm run check && node tests/smoke-ck.mjs && node tests/smoke-ck-store.mjs && node tests/smoke-ck-client.mjs && node tests/smoke-derived.mjs" + "check": "node --check ck.js && node --check ck-notation.js && node --check ck-client.js && node --check ck-store.js", + "test": "npm run check && node tests/smoke-ck.mjs && node tests/smoke-ck-store.mjs && node tests/smoke-ck-client.mjs && node tests/smoke-derived.mjs && node tests/smoke-notation.mjs" }, "repository": { "type": "git", diff --git a/tests/smoke-notation.mjs b/tests/smoke-notation.mjs new file mode 100644 index 0000000..a87fd6d --- /dev/null +++ b/tests/smoke-notation.mjs @@ -0,0 +1,118 @@ +// smoke-notation.mjs — local verification of the CKN compiler (ck-notation.js). +// compile() is checked purely (no NATS); assemble() runs over a MOCK transport via CK.activate. +// Run: node tests/smoke-notation.mjs +import { parse, compile, assemble, NotationError } from '../ck-notation.js'; +import { CK } from '../ck.js'; + +let pass = 0, fail = 0; +const ok = (n, c) => { if (c) { pass++; console.log(' ✅', n); } else { fail++; console.log(' ❌', n); } }; +const j = JSON.stringify; +const throws = (fn, code) => { try { fn(); return false; } catch (e) { return e instanceof NotationError && e.code === code; } }; + +// A CSVC-style instance-plane expression (Unicode strands, ';' comments, ∥ fan-out). +const SENTENCE = ` +CK( CSVC.Session ) ⟦instance⟧ ≜ ⟪ + χ : Position(A), Position(B), Synthesis(S), Decision(D) + ρ : A.holds = "ship Friday" B.holds = "ship after audit" + edges : + A —contradicts→ B ; the sealed conflict + S —resolves→ (A∥B) ; synthesis over both + D —sealed_as→ S + τ : D → 'decided' + π : verify(D) +⟫`; + +console.log('1. parse — declaration is inspectable'); +const ast = parse(SENTENCE); +ok('kernel read', ast.kernel === 'CSVC.Session'); +ok('χ: 4 members', ast.members.length === 4 && ast.members[0].type.endsWith('#Position')); +ok('ρ folded per member', (ast.props.A || [])[0]?.o === 'ship Friday'); + +console.log('2. compile — ordered [{verb, payload}] dispatch plan'); +const plan = compile(SENTENCE); +const verbs = plan.map((s) => s.verb); +ok('verb order create×4 → link×4 → transition → verify', j(verbs) === j([ + 'instance.create', 'instance.create', 'instance.create', 'instance.create', + 'instance.link', 'instance.link', 'instance.link', 'instance.link', + 'instance.transition', 'instance.verify'])); +ok('create binds a ref, ρ folded into payload', plan[0].ref === 'A' && plan[0].payload.holds === 'ship Friday'); +ok('no type-specific field injected (payload = {type, …ρ} only)', j(Object.keys(plan[0].payload).sort()) === j(['holds', 'type'])); +ok('link uses symbolic $refs (A —contradicts→ B)', plan[4].payload.source.$ref === 'A' && plan[4].payload.target.$ref === 'B'); +ok('(A∥B) fans out in order → S→A then S→B', plan[5].payload.target.$ref === 'A' && plan[6].payload.target.$ref === 'B' && plan[5].payload.source.$ref === 'S'); +ok('transition payload uses to_state, targets D', plan[8].payload.to_state === 'decided' && plan[8].payload.id.$ref === 'D'); + +console.log('3. ASCII forms compile identically'); +const ascii = compile(` +CK( CSVC.Session ) [[instance]] := + chi : Position(A), Position(B), Synthesis(S), Decision(D) + rho : A.holds = "ship Friday" B.holds = "ship after audit" + edges : A -contradicts-> B, S -resolves-> (A||B), D -sealed_as-> S + tau : D -> 'decided' + pi : verify(D)`); +ok('ASCII plan ≡ Unicode plan', j(ascii) === j(plan)); + +console.log('4. URN pointers pass through (instances already in the graph)'); +const ptr = compile(`CK( K ) ≜ + χ : Task(T) + edges : T -part_of_goal-> + τ : -> 'done'`); +ok('edge target is the raw URN', ptr[1].payload.target === 'urn:ckp:K/goal/g1'); +ok('τ on a pointer needs no create', ptr[2].payload.id === 'urn:ckp:K/task/t9'); + +console.log('5. typed rejects — compile throws NotationError (author error, not substrate)'); +ok('empty → parse_error', throws(() => compile(''), 'parse_error')); +ok('no header → parse_error', throws(() => compile('hello'), 'parse_error')); +ok('⟦genome⟧ plane → genome_plane_deferred', throws(() => compile('CK( K ) ⟦genome⟧ ≜ χ : New(N)'), 'genome_plane_deferred')); +ok('σ strand → genome_plane_deferred', throws(() => compile('CK( K ) ≜ σ : Shape[x]'), 'genome_plane_deferred')); +ok('ρ on unknown member → parse_error', throws(() => compile('CK( K ) ≜ ρ : Z.x = "1"'), 'parse_error')); + +console.log('6. assemble — over a live handle (mock transport), refs + honest degrade'); +function mockTransport(cfg = {}) { + let n = 0; const calls = []; + const seal = (id, extra) => ({ ok: true, id, verified: true, proof_digest: 'pf:' + id, ...extra }); + return { + calls, + async connect() {}, async close() {}, + async affordances() { return []; }, + subscribe() { return () => {}; }, + async dispatch(verb, kernelUrn, payload) { + calls.push({ verb, payload }); + switch (verb) { + case 'instance.create': { const id = payload.type + '#' + (++n); return seal(id, { result: { '@id': id, ...payload } }); } + case 'instance.link': return cfg.noLink ? { ok: false, error: 'unknown_affordance' } : seal('edge#' + (++n)); + case 'instance.transition': return seal(payload.id); + case 'instance.verify': return { ok: true, verified: true, proof_digest: 'pf:v' }; + default: return { ok: false, error: 'unknown_affordance' }; + } + }, + }; +} + +const t1 = mockTransport(); +const k1 = await CK.activate('CSVC.Session', { transport: t1, hydrate: false }); +const r1 = await assemble(k1, SENTENCE); +ok('refs resolved from create replies', !!r1.urns.A && !!r1.urns.D); +ok('dispatch order matches the plan', j(t1.calls.map((c) => c.verb)) === j(compile(SENTENCE).map((s) => s.verb))); +ok('link payload got the RESOLVED source id (not the $ref)', typeof t1.calls[4].payload.source === 'string' && t1.calls[4].payload.source === r1.urns.A); +ok('transition dispatched with resolved D + to_state', t1.calls[8].payload.id === r1.urns.D && t1.calls[8].payload.to_state === 'decided'); +ok('all steps ok → aggregate ok', r1.ok === true); +await k1.close(); + +const t2 = mockTransport({ noLink: true }); +const k2 = await CK.activate('CSVC.Session', { transport: t2, hydrate: false }); +const r2 = await assemble(k2, SENTENCE); +ok('link reject reported honestly (not thrown, not fabricated)', r2.steps[4].ok === false && r2.steps[4].error === 'unknown_affordance'); +ok('walk continues past a non-create reject', r2.steps[8].ok === true && r2.steps[9].ok === true); +ok('aggregate ok is honest (not all steps landed)', r2.ok === false); +await k2.close(); + +const t3 = mockTransport(); +const k3 = await CK.activate('K', { transport: t3, hydrate: false }); +const preCompiled = compile('CK( K ) ≜ χ : Task(T)'); // an inspectable [{verb,payload}] plan +ok('compile() returns a bare inspectable array', Array.isArray(preCompiled) && preCompiled[0].verb === 'instance.create'); +const r3 = await assemble(k3, preCompiled); // assemble accepts a plan, not only a source string +ok('assemble runs a pre-compiled plan (inspect-then-run split)', r3.ok === true && !!r3.urns.T); +await k3.close(); + +console.log(`\n${fail ? '❌ FAIL' : '✅ PASS'} — ${pass} passed, ${fail} failed\n`); +process.exit(fail ? 1 : 0);