@cavelang/canonical 0.1.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.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The shared standard prelude (spec §5.5).
3
+ *
4
+ * Standard verbs SHOULD carry inverse declarations; emitters MAY prepend
5
+ * them to a document or keep them in a shared prelude. This module is that
6
+ * shared prelude: the spec's declaration block as CAVE text, plus a
7
+ * pre-built registry for callers that want inverse-aware reads without
8
+ * ingesting the text first.
9
+ */
10
+ import * as Registry from "./registry.js";
11
+ /** The spec §5.5 declaration block, verbatim. */
12
+ export const standardPrelude = `REVERSE IS verb ; declares that two verbs name the same edge read in opposite directions
13
+ REVERSE HAS arity: 2
14
+ CONTAINS REVERSE PART-OF
15
+ CAUSE REVERSE CAUSED-BY
16
+ PRECEDES REVERSE FOLLOWS
17
+ USES REVERSE USED-BY
18
+ NEEDS REVERSE NEEDED-BY
19
+ ENABLES REVERSE ENABLED-BY
20
+ BLOCKS REVERSE BLOCKED-BY
21
+ EXTENDS REVERSE EXTENDED-BY
22
+ `;
23
+ const standardPairs = [
24
+ ['CONTAINS', 'PART-OF'],
25
+ ['CAUSE', 'CAUSED-BY'],
26
+ ['PRECEDES', 'FOLLOWS'],
27
+ ['USES', 'USED-BY'],
28
+ ['NEEDS', 'NEEDED-BY'],
29
+ ['ENABLES', 'ENABLED-BY'],
30
+ ['BLOCKS', 'BLOCKED-BY'],
31
+ ['EXTENDS', 'EXTENDED-BY']
32
+ ];
33
+ /** Registry with the standard §5.5 inverse pairs declared. */
34
+ export const standardRegistry = standardPairs.reduce((registry, [primary, inverse]) => {
35
+ const declared = Registry.declareReverse(registry, primary, inverse);
36
+ return declared.registry;
37
+ }, Registry.declareVerb(Registry.empty, 'REVERSE'));
38
+ //# sourceMappingURL=prelude.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prelude.js","sourceRoot":"","sources":["../../src/prelude.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAA;AAEzC,iDAAiD;AACjD,MAAM,CAAC,MAAM,eAAe,GAAG;;;;;;;;;;CAU9B,CAAA;AAED,MAAM,aAAa,GAAgC;IACjD,CAAC,UAAU,EAAE,SAAS,CAAC;IACvB,CAAC,OAAO,EAAE,WAAW,CAAC;IACtB,CAAC,UAAU,EAAE,SAAS,CAAC;IACvB,CAAC,MAAM,EAAE,SAAS,CAAC;IACnB,CAAC,OAAO,EAAE,WAAW,CAAC;IACtB,CAAC,SAAS,EAAE,YAAY,CAAC;IACzB,CAAC,QAAQ,EAAE,YAAY,CAAC;IACxB,CAAC,SAAS,EAAE,aAAa,CAAC;CAC3B,CAAA;AAED,8DAA8D;AAC9D,MAAM,CAAC,MAAM,gBAAgB,GAC3B,aAAa,CAAC,MAAM,CAAa,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE,EAAE;IAChE,MAAM,QAAQ,GAAG,QAAQ,CAAC,cAAc,CAAC,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,CAAA;IACpE,OAAO,QAAQ,CAAC,QAAQ,CAAA;AAC1B,CAAC,EAAE,QAAQ,CAAC,WAAW,CAAC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC,CAAA"}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Verb registry (spec §5.4, §5.5).
3
+ *
4
+ * Tracks in-band verb knowledge: extension verb declarations (`MIGRATES IS
5
+ * verb`) and inverse pairs (`CONTAINS REVERSE PART-OF`). No verb is born
6
+ * with an inverse — a relation without a `REVERSE` declaration simply has
7
+ * no reverse name.
8
+ *
9
+ * The registry is an immutable value; declaring returns a new registry, so
10
+ * the canonicalization pipeline can thread it through a document and
11
+ * declarations take effect for subsequent lines only.
12
+ */
13
+ /** An inverse pair. The primary is the left side of the first declaration (spec §5.5). */
14
+ export type Pair = {
15
+ readonly primary: string;
16
+ readonly inverse: string;
17
+ };
18
+ export type Registry = {
19
+ /** Every verb of every pair → its pair. */
20
+ readonly pairs: ReadonlyMap<string, Pair>;
21
+ /** Extension verbs declared via `X IS verb`. */
22
+ readonly declared: ReadonlySet<string>;
23
+ };
24
+ export type t = Registry;
25
+ /** Registry with no declarations. */
26
+ export declare const empty: Registry;
27
+ export type Declared = {
28
+ readonly ok: true;
29
+ readonly registry: Registry;
30
+ } | {
31
+ readonly ok: false;
32
+ readonly registry: Registry;
33
+ readonly problem: string;
34
+ };
35
+ /**
36
+ * Declares `a REVERSE b`: `a` is primary, `b` its inverse (spec §5.5).
37
+ * Redeclaring an existing pair in either direction is a no-op; a
38
+ * declaration that conflicts with an existing pair is rejected — the first
39
+ * declaration wins.
40
+ */
41
+ export declare const declareReverse: (registry: Registry, a: string, b: string) => Declared;
42
+ /** Declares an extension verb (`X IS verb`, spec §5.4). */
43
+ export declare const declareVerb: (registry: Registry, verb: string) => Registry;
44
+ /**
45
+ * @returns the canonical primary of `verb` and whether `verb` is the
46
+ * inverse side. An unpaired verb is its own primary.
47
+ */
48
+ export declare const primaryOf: (registry: Registry, verb: string) => {
49
+ primary: string;
50
+ isInverse: boolean;
51
+ };
52
+ /**
53
+ * @returns the opposite name of `verb` — `CONTAINS` → `PART-OF`,
54
+ * `PART-OF` → `CONTAINS` — or `undefined` when no inverse is declared
55
+ * (reverse reads then fall back to an un-named object-side scan, spec §5.5).
56
+ */
57
+ export declare const inverseOf: (registry: Registry, verb: string) => undefined | string;
58
+ /** @returns `true` if `verb` was declared as an extension verb. */
59
+ export declare const isDeclared: (registry: Registry, verb: string) => boolean;
60
+ /** All pairs, deduplicated, in insertion order. */
61
+ export declare const allPairs: (registry: Registry) => Pair[];
62
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,0FAA0F;AAC1F,MAAM,MAAM,IAAI,GAAG;IACjB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,QAAQ,GAAG;IACrB,2CAA2C;IAC3C,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;IACzC,gDAAgD;IAChD,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;CACvC,CAAA;AAED,MAAM,MAAM,CAAC,GAAG,QAAQ,CAAA;AAExB,qCAAqC;AACrC,eAAO,MAAM,KAAK,EAAE,QAGnB,CAAA;AAED,MAAM,MAAM,QAAQ,GAChB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,GAClD;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAA;AAEjF;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,UAAU,QAAQ,EAAE,GAAG,MAAM,EAAE,GAAG,MAAM,KAAG,QAyBzE,CAAA;AAED,2DAA2D;AAC3D,eAAO,MAAM,WAAW,GAAI,UAAU,QAAQ,EAAE,MAAM,MAAM,KAAG,QAO9D,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,SAAS,GAAI,UAAU,QAAQ,EAAE,MAAM,MAAM,KAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAMjG,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,SAAS,GAAI,UAAU,QAAQ,EAAE,MAAM,MAAM,KAAG,SAAS,GAAG,MAMxE,CAAA;AAED,mEAAmE;AACnE,eAAO,MAAM,UAAU,GAAI,UAAU,QAAQ,EAAE,MAAM,MAAM,KAAG,OACjC,CAAA;AAE7B,mDAAmD;AACnD,eAAO,MAAM,QAAQ,GAAI,UAAU,QAAQ,KAAG,IAAI,EACX,CAAA"}
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Verb registry (spec §5.4, §5.5).
3
+ *
4
+ * Tracks in-band verb knowledge: extension verb declarations (`MIGRATES IS
5
+ * verb`) and inverse pairs (`CONTAINS REVERSE PART-OF`). No verb is born
6
+ * with an inverse — a relation without a `REVERSE` declaration simply has
7
+ * no reverse name.
8
+ *
9
+ * The registry is an immutable value; declaring returns a new registry, so
10
+ * the canonicalization pipeline can thread it through a document and
11
+ * declarations take effect for subsequent lines only.
12
+ */
13
+ import { Verb } from '@cavelang/core';
14
+ /** Registry with no declarations. */
15
+ export const empty = {
16
+ pairs: new Map(),
17
+ declared: new Set()
18
+ };
19
+ /**
20
+ * Declares `a REVERSE b`: `a` is primary, `b` its inverse (spec §5.5).
21
+ * Redeclaring an existing pair in either direction is a no-op; a
22
+ * declaration that conflicts with an existing pair is rejected — the first
23
+ * declaration wins.
24
+ */
25
+ export const declareReverse = (registry, a, b) => {
26
+ if (!Verb.isVerbToken(a) || !Verb.isVerbToken(b)) {
27
+ return { ok: false, registry, problem: `REVERSE operands must be UPPERCASE verbs, got ${a} REVERSE ${b}` };
28
+ }
29
+ const existingA = registry.pairs.get(a);
30
+ const existingB = registry.pairs.get(b);
31
+ const existing = existingA ?? existingB;
32
+ if (existing !== undefined) {
33
+ const same = (existing.primary === a && existing.inverse === b) ||
34
+ (existing.primary === b && existing.inverse === a);
35
+ if (same && existingA === existingB) {
36
+ return { ok: true, registry };
37
+ }
38
+ return {
39
+ ok: false,
40
+ registry,
41
+ problem: `${a} REVERSE ${b} conflicts with existing ${existing.primary} REVERSE ${existing.inverse} — first declaration wins (spec §5.5)`
42
+ };
43
+ }
44
+ const pair = { primary: a, inverse: b };
45
+ const pairs = new Map(registry.pairs);
46
+ pairs.set(a, pair);
47
+ pairs.set(b, pair);
48
+ return { ok: true, registry: { pairs, declared: registry.declared } };
49
+ };
50
+ /** Declares an extension verb (`X IS verb`, spec §5.4). */
51
+ export const declareVerb = (registry, verb) => {
52
+ if (registry.declared.has(verb)) {
53
+ return registry;
54
+ }
55
+ const declared = new Set(registry.declared);
56
+ declared.add(verb);
57
+ return { pairs: registry.pairs, declared };
58
+ };
59
+ /**
60
+ * @returns the canonical primary of `verb` and whether `verb` is the
61
+ * inverse side. An unpaired verb is its own primary.
62
+ */
63
+ export const primaryOf = (registry, verb) => {
64
+ const pair = registry.pairs.get(verb);
65
+ if (pair === undefined || pair.primary === verb) {
66
+ return { primary: verb, isInverse: false };
67
+ }
68
+ return { primary: pair.primary, isInverse: true };
69
+ };
70
+ /**
71
+ * @returns the opposite name of `verb` — `CONTAINS` → `PART-OF`,
72
+ * `PART-OF` → `CONTAINS` — or `undefined` when no inverse is declared
73
+ * (reverse reads then fall back to an un-named object-side scan, spec §5.5).
74
+ */
75
+ export const inverseOf = (registry, verb) => {
76
+ const pair = registry.pairs.get(verb);
77
+ if (pair === undefined) {
78
+ return undefined;
79
+ }
80
+ return pair.primary === verb ? pair.inverse : pair.primary;
81
+ };
82
+ /** @returns `true` if `verb` was declared as an extension verb. */
83
+ export const isDeclared = (registry, verb) => registry.declared.has(verb);
84
+ /** All pairs, deduplicated, in insertion order. */
85
+ export const allPairs = (registry) => [...new Set(registry.pairs.values())];
86
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/registry.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@cavelang/canonical",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "CAVE canonicalization — verb registry, REVERSE inverse resolution, continuation expansion, canonical emitter.",
6
+ "license": "CC0-1.0",
7
+ "author": "Mirek Rusin (https://github.com/mirek)",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/mirek/cave.git",
11
+ "directory": "packages/canonical"
12
+ },
13
+ "engines": {
14
+ "node": ">=22.18"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/src/index.d.ts",
19
+ "default": "./dist/src/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "!dist/test",
25
+ "!dist/tsconfig.tsbuildinfo",
26
+ "src",
27
+ "!src/**/*.test.ts",
28
+ "README.md",
29
+ "License.md"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "dependencies": {
35
+ "@cavelang/core": "0.1.0",
36
+ "@cavelang/parser": "0.1.0"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -b",
40
+ "test": "node --disable-warning=ExperimentalWarning --test 'test/*.test.ts'",
41
+ "typecheck": "tsc -b"
42
+ }
43
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Canonicalization pipeline (spec §13.4).
3
+ *
4
+ * Turns a parsed document into canonical claims plus qualifier edges:
5
+ *
6
+ * 1. verbs are already uppercase (parser enforces the lexical shape);
7
+ * 2. inverse verbs swap subject/object and substitute the primary (§5.5);
8
+ * 3. continuation lines fill their inherited endpoint from the parent (§8.3);
9
+ * 4. entity whitespace normalizes to `-`, proper-noun casing preserved;
10
+ * 5. `raw` keeps the line exactly as written;
11
+ * 6–10. confidence, `~`, multipliers, tag splitting are handled by the
12
+ * parser and `@cavelang/core`;
13
+ * 11. claim keys are computed on the canonical form (`Key.of`);
14
+ * 12. contexts/tags ride on each claim for the store's side tables.
15
+ *
16
+ * Qualifier lines become claim nodes joined to their parent by edges
17
+ * (§8.1); `UNLESS x` normalizes to `WHEN` + negated condition (§8.2);
18
+ * grouped full claims link to their parent with the `QUALIFIES` role
19
+ * (§13.2). `REVERSE` and extension-verb declarations update the registry
20
+ * in-band, affecting subsequent lines (§5.4, §5.5).
21
+ */
22
+
23
+ import { Claim, Entity, Value, Verb } from '@cavelang/core'
24
+ import { parseDocument, type Ast } from '@cavelang/parser'
25
+ import * as Registry from './registry.ts'
26
+
27
+ /** Edge roles persisted in `cave_edge` (spec §13.2). */
28
+ export type EdgeRole = 'WHEN' | 'VIA' | 'BECAUSE' | 'QUALIFIES'
29
+
30
+ /** A canonical claim with its 1-based source line. */
31
+ export type Entry = {
32
+ readonly claim: Claim.t
33
+ readonly line: number
34
+ }
35
+
36
+ /** Edge between claim indices (into `Result.claims`). */
37
+ export type Edge = {
38
+ readonly parent: number
39
+ readonly role: EdgeRole
40
+ readonly child: number
41
+ }
42
+
43
+ export type Problem = {
44
+ readonly line: number
45
+ readonly message: string
46
+ }
47
+
48
+ export type Result = {
49
+ readonly claims: readonly Entry[]
50
+ readonly edges: readonly Edge[]
51
+ /** Registry after processing — input registry plus in-band declarations. */
52
+ readonly registry: Registry.t
53
+ readonly problems: readonly Problem[]
54
+ }
55
+
56
+ const roleOf: Record<Verb.Qualifier, EdgeRole> = {
57
+ WHEN: 'WHEN',
58
+ UNLESS: 'WHEN',
59
+ VIA: 'VIA',
60
+ BECAUSE: 'BECAUSE'
61
+ }
62
+
63
+ /** `>` maps to the standard `EXCEEDS`; other operators keep their symbol. */
64
+ const comparisonVerb = (op: Ast.ComparisonOp): string =>
65
+ op === '>' ? 'EXCEEDS' : op
66
+
67
+ const normalizeTerm = (term: Claim.Term): Claim.Term =>
68
+ term.kind === 'entity' ? Claim.entity(Entity.normalize(term.text)) : term
69
+
70
+ /**
71
+ * Term ↔ payload conversion for the §5.5 inverse swap. The parser
72
+ * classifies a date/number endpoint as a metric payload but a subject is
73
+ * always a term; swapping must re-classify both directions the same way,
74
+ * or `deploy PRECEDES 2026-01-01` and `2026-01-01 FOLLOWS deploy` would
75
+ * key differently — two keys for one fact.
76
+ */
77
+ const termOfPayload = (payload: Claim.Payload): undefined | Claim.Term => {
78
+ switch (payload.kind) {
79
+ case 'relation':
80
+ return payload.object
81
+ case 'metric':
82
+ return payload.value.kind === 'code' ? Claim.code(payload.value.raw) :
83
+ payload.value.kind === 'text' ? Claim.text(payload.value.raw) :
84
+ Claim.entity(Entity.normalize(payload.value.raw))
85
+ default:
86
+ return undefined
87
+ }
88
+ }
89
+
90
+ const payloadOfTerm = (term: Claim.Term): Claim.Payload => {
91
+ if (term.kind === 'entity') {
92
+ const value = Value.parse(term.text)
93
+ if (value.kind === 'number' || value.kind === 'date') {
94
+ return Claim.metric(value)
95
+ }
96
+ }
97
+ return Claim.relation(term)
98
+ }
99
+
100
+ const initOf = (meta: Ast.Meta): Partial<Claim.Init> => ({
101
+ contexts: meta.contexts,
102
+ tags: meta.tags,
103
+ importance: meta.importance,
104
+ ...meta.conf !== undefined ? { conf: meta.conf } : {},
105
+ ...meta.delta !== undefined ? { delta: meta.delta } : {},
106
+ ...meta.sigmaLevel !== undefined ? { sigmaLevel: meta.sigmaLevel } : {},
107
+ ...meta.comment !== undefined ? { comment: meta.comment } : {}
108
+ })
109
+
110
+ /** Canonicalizes a parsed document. */
111
+ export const canonicalize = (document: Ast.Document, registry: Registry.t = Registry.empty): Result => {
112
+ const claims: Entry[] = []
113
+ const edges: Edge[] = []
114
+ const problems: Problem[] = []
115
+ /** line index → claim index + the subject as (virtually) written, for §8.3 inheritance. */
116
+ const byLine = new Map<number, { index: number, writtenSubject: Claim.Term }>()
117
+
118
+ const problem = (line: number, message: string): void => {
119
+ problems.push({ line, message })
120
+ }
121
+
122
+ /** Canonicalizes one full claim body — §13.4 steps 2 and 4 plus assembly. */
123
+ const buildClaim = (full: Ast.Full, raw: string, line: number): { claim: Claim.t, writtenSubject: Claim.Term } => {
124
+ const writtenSubject = normalizeTerm(full.subject)
125
+ let subject = writtenSubject
126
+ let verb = full.verb
127
+ let payload: Claim.Payload = full.payload
128
+ if (payload.kind === 'relation') {
129
+ payload = Claim.relation(normalizeTerm(payload.object))
130
+ }
131
+ const { primary, isInverse } = Registry.primaryOf(registry, verb)
132
+ if (isInverse) {
133
+ const objectTerm = termOfPayload(payload)
134
+ if (objectTerm === undefined) {
135
+ problem(line, `inverse verb ${verb} needs an object to swap with — keeping the line as written (spec §5.5)`)
136
+ } else {
137
+ payload = payloadOfTerm(subject)
138
+ subject = objectTerm
139
+ verb = primary
140
+ }
141
+ }
142
+ const claim = Claim.of({
143
+ subject,
144
+ verb,
145
+ negated: full.negated,
146
+ payload,
147
+ raw,
148
+ ...initOf(full.meta)
149
+ })
150
+ return { claim, writtenSubject }
151
+ }
152
+
153
+ const append = (claim: Claim.t, line: number, writtenSubject: Claim.Term, lineIndex: number): number => {
154
+ const index = claims.length
155
+ claims.push({ claim, line })
156
+ byLine.set(lineIndex, { index, writtenSubject })
157
+ return index
158
+ }
159
+
160
+ /** In-band declarations take effect for subsequent lines (spec §5.4, §5.5). */
161
+ const applyDeclarations = (claim: Claim.t, line: number): void => {
162
+ if (claim.payload.kind !== 'relation' || claim.negated) {
163
+ return
164
+ }
165
+ const object = claim.payload.object
166
+ if (claim.verb === Verb.REVERSE && claim.subject.kind === 'entity' && object.kind === 'entity') {
167
+ const declared = Registry.declareReverse(registry, claim.subject.text, object.text)
168
+ registry = declared.registry
169
+ if (!declared.ok) {
170
+ problem(line, declared.problem)
171
+ }
172
+ return
173
+ }
174
+ if (claim.verb === 'IS' && object.kind === 'entity' && object.text === 'verb' &&
175
+ claim.subject.kind === 'entity' && Verb.isVerbToken(claim.subject.text)) {
176
+ registry = Registry.declareVerb(registry, claim.subject.text)
177
+ }
178
+ }
179
+
180
+ /** Builds the condition claim of a qualifier line (spec §8.2). */
181
+ const conditionOf = (payload: Ast.QualifierPayload, unless: boolean): Ast.Full => {
182
+ switch (payload.kind) {
183
+ case 'claim':
184
+ return {
185
+ ...payload.claim,
186
+ negated: payload.claim.negated !== (payload.negated !== unless)
187
+ }
188
+ case 'entity':
189
+ return {
190
+ subject: payload.term,
191
+ verb: 'EXISTS',
192
+ negated: payload.negated !== unless,
193
+ payload: Claim.none,
194
+ meta: payload.meta
195
+ }
196
+ case 'comparison':
197
+ return {
198
+ subject: payload.left,
199
+ verb: comparisonVerb(payload.op),
200
+ negated: payload.negated !== unless,
201
+ payload: Claim.metric(payload.value),
202
+ meta: payload.meta
203
+ }
204
+ }
205
+ }
206
+
207
+ document.lines.forEach((line, lineIndex) => {
208
+ switch (line.kind) {
209
+ case 'claim': {
210
+ const { claim, writtenSubject } = buildClaim(line.claim, line.raw, line.line)
211
+ const index = append(claim, line.line, writtenSubject, lineIndex)
212
+ if (line.parent !== undefined) {
213
+ const parent = byLine.get(line.parent)
214
+ if (parent !== undefined) {
215
+ edges.push({ parent: parent.index, role: 'QUALIFIES', child: index })
216
+ }
217
+ }
218
+ applyDeclarations(claim, line.line)
219
+ return
220
+ }
221
+ case 'continuation': {
222
+ const parent = byLine.get(line.parent!)
223
+ if (parent === undefined) {
224
+ problem(line.line, 'continuation has no canonicalized parent')
225
+ return
226
+ }
227
+ const full: Ast.Full = { subject: parent.writtenSubject, ...line.body }
228
+ const { claim, writtenSubject } = buildClaim(full, line.raw, line.line)
229
+ append(claim, line.line, writtenSubject, lineIndex)
230
+ // §8.3: each continuation is an ordinary independent claim, so an
231
+ // in-band declaration works here exactly as on a full line (§5.4).
232
+ applyDeclarations(claim, line.line)
233
+ return
234
+ }
235
+ case 'qualifier': {
236
+ const parent = byLine.get(line.parent!)
237
+ if (parent === undefined) {
238
+ problem(line.line, 'qualifier has no canonicalized parent')
239
+ return
240
+ }
241
+ const full = conditionOf(line.payload, line.qualifier === 'UNLESS')
242
+ const { claim, writtenSubject } = buildClaim(full, line.raw, line.line)
243
+ const index = append(claim, line.line, writtenSubject, lineIndex)
244
+ edges.push({ parent: parent.index, role: roleOf[line.qualifier], child: index })
245
+ return
246
+ }
247
+ default:
248
+ return
249
+ }
250
+ })
251
+
252
+ return { claims, edges, registry, problems }
253
+ }
254
+
255
+ /**
256
+ * Parses and canonicalizes CAVE text in one step. Parser diagnostics merge
257
+ * into `problems`.
258
+ */
259
+ export const canonicalizeText = (input: string, registry: Registry.t = Registry.empty): Result => {
260
+ const document = parseDocument(input)
261
+ const result = canonicalize(document, registry)
262
+ if (document.diagnostics.length === 0) {
263
+ return result
264
+ }
265
+ return {
266
+ ...result,
267
+ problems: [
268
+ ...document.diagnostics.map(diagnostic => ({ line: diagnostic.line, message: diagnostic.message })),
269
+ ...result.problems
270
+ ]
271
+ }
272
+ }
package/src/emit.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Canonical emitter.
3
+ *
4
+ * Emits canonical CAVE text from canonical claims: colon attribute form
5
+ * (§3.4 — emitters MUST produce it), primary verb direction (§5.5),
6
+ * `WHEN NOT` rather than `UNLESS` (§8.2), metadata in the §3.2 anatomy
7
+ * order. Qualifier edges re-indent under their parent; grouped claims
8
+ * (`QUALIFIES` edges) re-indent as full lines.
9
+ */
10
+
11
+ import { Claim, Confidence, Tag, Value } from '@cavelang/core'
12
+ import type * as Canonicalize from './canonicalize.ts'
13
+
14
+ const payloadText = (payload: Claim.Payload): undefined | string => {
15
+ switch (payload.kind) {
16
+ case 'relation':
17
+ return Claim.formatTerm(payload.object)
18
+ case 'attribute':
19
+ return `${payload.attribute}: ${Value.format(payload.value)}`
20
+ case 'metric':
21
+ return Value.format(payload.value)
22
+ case 'none':
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ const metaText = (claim: Claim.t): string[] => {
28
+ const parts: string[] = []
29
+ if (claim.delta !== undefined) {
30
+ parts.push(`+/- ${Value.format(claim.delta)}`)
31
+ }
32
+ if (claim.sigmaLevel !== undefined) {
33
+ parts.push(`(${claim.sigmaLevel}σ)`)
34
+ }
35
+ for (const context of claim.contexts) {
36
+ parts.push(`@${context}`)
37
+ }
38
+ for (const tag of claim.tags) {
39
+ parts.push(Tag.format(tag))
40
+ }
41
+ if (claim.conf !== 1) {
42
+ parts.push(`@ ${Confidence.format(claim.conf)}`)
43
+ }
44
+ if (claim.importance) {
45
+ parts.push('!')
46
+ }
47
+ if (claim.comment !== undefined) {
48
+ parts.push(`; ${claim.comment}`)
49
+ }
50
+ return parts
51
+ }
52
+
53
+ /** @returns one canonical line for a claim (no indentation). */
54
+ export const emitClaim = (claim: Claim.t): string => {
55
+ const parts = [Claim.formatTerm(claim.subject), claim.verb]
56
+ if (claim.negated) {
57
+ parts.push('NOT')
58
+ }
59
+ const payload = payloadText(claim.payload)
60
+ if (payload !== undefined) {
61
+ parts.push(payload)
62
+ }
63
+ parts.push(...metaText(claim))
64
+ return parts.join(' ')
65
+ }
66
+
67
+ /**
68
+ * @returns the qualifier-payload text of a condition claim. Negation always
69
+ * emits as a `NOT` *prefix* — the §8.2 canonical `WHEN NOT x` shape — never
70
+ * as the claim-internal `VERB NOT` form: a postfix `NOT` after a symbolic
71
+ * comparison verb (`WHEN cpu >= NOT 900`) would be unreadable to the
72
+ * parser and silently invert the condition on round trip.
73
+ */
74
+ const conditionText = (claim: Claim.t): string => {
75
+ const body = claim.verb === 'EXISTS' && claim.payload.kind === 'none' ?
76
+ [Claim.formatTerm(claim.subject), ...metaText(claim)].join(' ') :
77
+ emitClaim({ ...claim, negated: false })
78
+ return claim.negated ? `NOT ${body}` : body
79
+ }
80
+
81
+ /**
82
+ * Emits a whole canonicalization result as canonical CAVE text: top-level
83
+ * claims in claim order, children indented two spaces per level.
84
+ */
85
+ export const emit = (result: Pick<Canonicalize.Result, 'claims' | 'edges'>): string => {
86
+ const childEdges = new Map<number, Canonicalize.Edge[]>()
87
+ const isChild = new Set<number>()
88
+ for (const edge of result.edges) {
89
+ isChild.add(edge.child)
90
+ const existing = childEdges.get(edge.parent)
91
+ if (existing === undefined) {
92
+ childEdges.set(edge.parent, [edge])
93
+ } else {
94
+ existing.push(edge)
95
+ }
96
+ }
97
+ const lines: string[] = []
98
+ const emitAt = (index: number, depth: number, role: undefined | Canonicalize.EdgeRole): void => {
99
+ const { claim } = result.claims[index]!
100
+ const indent = ' '.repeat(depth)
101
+ if (role === undefined || role === 'QUALIFIES') {
102
+ lines.push(`${indent}${emitClaim(claim)}`)
103
+ } else {
104
+ lines.push(`${indent}${role} ${conditionText(claim)}`)
105
+ }
106
+ for (const edge of childEdges.get(index) ?? []) {
107
+ emitAt(edge.child, depth + 1, edge.role)
108
+ }
109
+ }
110
+ result.claims.forEach((_, index) => {
111
+ if (!isChild.has(index)) {
112
+ emitAt(index, 0, undefined)
113
+ }
114
+ })
115
+ return lines.length === 0 ? '' : `${lines.join('\n')}\n`
116
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `@cavelang/canonical` — the CAVE semantic layer (spec §5.4, §5.5, §8, §13.4).
3
+ *
4
+ * Verb registry with in-band `REVERSE`/extension declarations, the
5
+ * canonicalization pipeline (inverse resolution, continuation expansion,
6
+ * qualifier edges, `UNLESS` normalization), the shared standard prelude and
7
+ * the canonical emitter.
8
+ *
9
+ * ```ts
10
+ * import { canonicalizeText, standardRegistry, emit } from '@cavelang/canonical'
11
+ *
12
+ * const result = canonicalizeText('packages/api PART-OF monorepo', standardRegistry)
13
+ * result.claims[0].claim // monorepo CONTAINS packages/api — one fact, two names
14
+ * emit(result) // canonical text, primary direction
15
+ * ```
16
+ */
17
+
18
+ export * as Registry from './registry.ts'
19
+ export { canonicalize, canonicalizeText } from './canonicalize.ts'
20
+ export type { Edge, EdgeRole, Entry, Problem, Result } from './canonicalize.ts'
21
+ export { emit, emitClaim } from './emit.ts'
22
+ export { standardPrelude, standardRegistry } from './prelude.ts'