@cavelang/canonical 0.35.0 → 0.36.1

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/src/emit.ts CHANGED
@@ -8,39 +8,125 @@
8
8
  * (`QUALIFIES` edges) re-indent as full lines.
9
9
  */
10
10
 
11
- import { Claim, Confidence, Tag, Value } from '@cavelang/core'
12
- import { parseDocument, Token } from '@cavelang/parser'
11
+ import { Claim, Confidence, Entity, Tag, Uncertainty, Value, Verb } from '@cavelang/core'
12
+ import { parseDocument, Token, Line } from '@cavelang/parser'
13
13
  import type * as Canonicalize from './canonicalize.ts'
14
14
 
15
+ const checkLiteral = (kind: string, text: string): void => {
16
+ if (typeof text !== 'string') throw new TypeError('CAVE term and value text must be a string')
17
+ const delimiter = kind === 'text' ? '"' : kind === 'code' ? '`' : undefined
18
+ if (delimiter !== undefined && text.includes(delimiter)) {
19
+ throw new TypeError(`CAVE ${kind} literal cannot contain its own delimiter`)
20
+ }
21
+ }
22
+
23
+ const termText = (term: Claim.Term): string => {
24
+ if (term.kind !== 'entity' && term.kind !== 'text' && term.kind !== 'code') {
25
+ throw new TypeError('CAVE term kind must be entity, text or code')
26
+ }
27
+ checkLiteral(term.kind, term.text)
28
+ return Claim.formatTerm(term)
29
+ }
30
+
31
+ // Letter/underscore-led names exclude numeric/date prefixes and payload syntax.
32
+ // The separate NOT check retains the only bare negation token in this subset.
33
+ const simpleObject = /^[A-Za-z_][A-Za-z0-9_./-]*(?![\s\S])/
34
+
35
+ // This subset cannot contain token separators, literal delimiters, comments or metadata.
36
+ const simpleSubject = /^[A-Za-z0-9_][A-Za-z0-9_./:-]*(?![\s\S])/
37
+
38
+ const subjectText = (subject: Claim.Term): string => {
39
+ if (subject.kind === 'entity') {
40
+ if (typeof subject.text !== 'string') throw new TypeError('CAVE entity subject must be one non-metadata atom')
41
+ if (!simpleSubject.test(subject.text)) {
42
+ const head = Token.splitComment(subject.text).head
43
+ const tokens = Token.tokenize(head)
44
+ const token = tokens[0]
45
+ if (head !== subject.text || tokens.length !== 1 || token?.kind !== 'word' ||
46
+ token.text !== subject.text || Line.isMetaStart(token)) {
47
+ throw new TypeError('CAVE entity subject must be one non-metadata atom; use a text or code literal')
48
+ }
49
+ }
50
+ }
51
+ return termText(subject)
52
+ }
53
+
54
+ const valueText = (value: Value.t): string => {
55
+ checkLiteral(value.kind, value.raw)
56
+ const parsed = value.kind === 'text' ? Value.ofText(value.raw) :
57
+ value.kind === 'code' ? Value.ofCode(value.raw) : Value.parse(value.raw)
58
+ if (value.kind !== parsed.kind || value.approx !== parsed.approx ||
59
+ value.num !== parsed.num || value.from !== parsed.from || value.to !== parsed.to || value.unit !== parsed.unit) {
60
+ throw new TypeError('CAVE value fields must agree with their emitted raw text')
61
+ }
62
+ if (value.kind !== 'text' && value.kind !== 'code') {
63
+ const tokens = Token.tokenize(value.raw)
64
+ if (tokens.length === 0 || tokens.some(token => token.kind !== 'word' || Line.isMetaStart(token)) ||
65
+ tokens.map(token => token.text).join(' ') !== value.raw) {
66
+ throw new TypeError('CAVE unquoted value must retain its text and remain outside metadata; use a text or code literal')
67
+ }
68
+ }
69
+ return Value.format(value)
70
+ }
71
+
72
+ const attributeText = (attribute: string): string => {
73
+ const text = `${attribute}:`
74
+ const head = Token.splitComment(text).head
75
+ const tokens = Token.tokenize(head), token = tokens[0]
76
+ if (typeof attribute !== 'string' || attribute.length === 0 || head !== text ||
77
+ tokens.length !== 1 || token?.kind !== 'word' || token.text !== text || Line.isMetaStart(token)) {
78
+ throw new TypeError('CAVE attribute name must retain one non-metadata word before its value')
79
+ }
80
+ return text
81
+ }
82
+
15
83
  const payloadText = (payload: Claim.Payload): undefined | string => {
16
84
  switch (payload.kind) {
17
85
  case 'relation':
18
- return Claim.formatTerm(payload.object)
86
+ return termText(payload.object)
19
87
  case 'attribute':
20
- return `${payload.attribute}: ${Value.format(payload.value)}`
88
+ return `${attributeText(payload.attribute)} ${valueText(payload.value)}`
21
89
  case 'metric':
22
- return Value.format(payload.value)
90
+ return valueText(payload.value)
23
91
  case 'none':
24
92
  return undefined
93
+ default:
94
+ throw new TypeError('CAVE payload kind must be relation, attribute, metric or none')
95
+ }
96
+ }
97
+
98
+ const metadataWord = (text: string): string => {
99
+ const head = Token.splitComment(text).head
100
+ const tokens = Token.tokenize(head), token = tokens[0]
101
+ if (head !== text || tokens.length !== 1 || token?.kind !== 'word' || token.text !== text || text === '@' || text === '#') {
102
+ throw new TypeError('CAVE metadata must retain one nonempty context or tag token')
25
103
  }
104
+ return text
26
105
  }
27
106
 
28
107
  const metaText = (claim: Claim.t): string[] => {
108
+ for (const name of ['contexts', 'tags'] as const) {
109
+ if (!Array.isArray(claim[name])) throw new TypeError(`CAVE claim ${name} must be an array`)
110
+ }
29
111
  const parts: string[] = []
30
112
  if (claim.delta !== undefined) {
31
- parts.push(`+/- ${Value.format(claim.delta)}`)
113
+ Uncertainty.validateDelta(claim.delta.num)
114
+ parts.push(`+/- ${valueText(claim.delta)}`)
32
115
  }
33
116
  if (claim.sigmaLevel !== undefined) {
34
- parts.push(`(${claim.sigmaLevel}σ)`)
117
+ parts.push(`(${Value.formatNumber(Uncertainty.validateSigmaLevel(claim.sigmaLevel))}σ)`)
35
118
  }
36
119
  for (const context of claim.contexts) {
37
- parts.push(`@${context}`)
120
+ if (typeof context !== 'string') throw new TypeError('CAVE context metadata must be a string')
121
+ parts.push(metadataWord(`@${context}`))
38
122
  }
39
123
  for (const tag of claim.tags) {
40
- parts.push(Tag.format(tag))
124
+ const text = metadataWord(Tag.format(tag))
125
+ if (!Tag.equals(Tag.parse(text.slice(1)), tag)) throw new TypeError('CAVE tag must retain its key and value through emission')
126
+ parts.push(text)
41
127
  }
42
128
  if (claim.conf !== 1) {
43
- parts.push(`@ ${Confidence.format(claim.conf)}`)
129
+ parts.push(`@ ${Confidence.formatExact(claim.conf)}`)
44
130
  }
45
131
  if (claim.importance) {
46
132
  parts.push('!')
@@ -48,9 +134,26 @@ const metaText = (claim: Claim.t): string[] => {
48
134
  return parts
49
135
  }
50
136
 
137
+ const singleLine = (line: string): string => {
138
+ if (line.includes('\n')) throw new TypeError('CAVE claim fields cannot contain a newline; use comment lines for multiline commentary')
139
+ if (/["`;]/.test(line) && Token.splitComment(`${line};`).head !== line) {
140
+ throw new TypeError('CAVE claim fields must not open a comment or leave an unmatched literal delimiter')
141
+ }
142
+ return line
143
+ }
144
+
145
+ const validateFlags = (claim: Claim.t): void => {
146
+ for (const name of ['negated', 'importance'] as const) {
147
+ if (typeof claim[name] !== 'boolean') throw new TypeError(`CAVE claim ${name} must be a boolean`)
148
+ }
149
+ }
150
+
51
151
  /** @returns the canonical claim line without its comment. */
52
152
  const claimLine = (claim: Claim.t): string => {
53
- const parts = [Claim.formatTerm(claim.subject), claim.verb]
153
+ validateFlags(claim)
154
+ if (typeof claim.verb !== 'string' || !Verb.isVerbToken(claim.verb)) throw new TypeError('CAVE claim verb must be an uppercase atom')
155
+ if (claim.payload.kind === 'none' && claim.verb !== 'EXISTS') throw new TypeError('CAVE claim requires an object or value payload unless its verb is EXISTS')
156
+ const parts = [subjectText(claim.subject), claim.verb]
54
157
  if (claim.negated) {
55
158
  parts.push('NOT')
56
159
  }
@@ -58,8 +161,28 @@ const claimLine = (claim: Claim.t): string => {
58
161
  if (payload !== undefined) {
59
162
  parts.push(payload)
60
163
  }
61
- parts.push(...metaText(claim))
62
- return parts.join(' ')
164
+ for (const metadata of metaText(claim)) parts.push(metadata)
165
+ const line = singleLine(parts.join(' '))
166
+ if (claim.payload.kind === 'metric' && !['number', 'date', 'trajectory'].includes(claim.payload.value.kind)) {
167
+ throw new TypeError('CAVE metric payload requires a number, date or trajectory; use a relation or attribute for other values')
168
+ }
169
+ if (claim.payload.kind === 'relation' && claim.payload.object.kind === 'entity' &&
170
+ (claim.payload.object.text === 'NOT' || !simpleObject.test(claim.payload.object.text))) {
171
+ const parsed = Line.parseBody(Token.tokenize(`${claim.verb} ${payload}`))
172
+ if (!parsed.ok || parsed.problems.length > 0 || parsed.value.negated ||
173
+ parsed.value.payload.kind !== 'relation' || parsed.value.payload.object.kind !== 'entity' ||
174
+ Entity.normalize(parsed.value.payload.object.text) !== Entity.normalize(claim.payload.object.text)) {
175
+ throw new TypeError('CAVE entity relation object must retain its payload identity; use an explicit value or literal when needed')
176
+ }
177
+ }
178
+ return line
179
+ }
180
+
181
+ const fullClaimLine = (claim: Claim.t): string => {
182
+ const line = claimLine(claim)
183
+ if (claim.subject.kind === 'entity' && Verb.isVerbToken(claim.subject.text) &&
184
+ parseDocument(line).lines[0]?.kind !== 'claim') return `@claim ${line}`
185
+ return line
63
186
  }
64
187
 
65
188
  /**
@@ -68,18 +191,28 @@ const claimLine = (claim: Claim.t): string => {
68
191
  * lines above the claim line, the last comment line riding on the claim.
69
192
  */
70
193
  export const emitClaim = (claim: Claim.t): string =>
71
- Token.joinComment(claimLine(claim), claim.comment)
194
+ Token.joinComment(fullClaimLine(claim), claim.comment)
72
195
 
73
196
  /**
74
- * @returns the qualifier-payload text of a condition claim. Negation always
75
- * emits as a `NOT` *prefix* — the §8.2 canonical `WHEN NOT x` shape — never
76
- * as the claim-internal `VERB NOT` form: a postfix `NOT` after a symbolic
197
+ * @returns the qualifier-payload text of a condition claim. Negation
198
+ * normally emits as a `NOT` *prefix* — the §8.2 canonical `WHEN NOT x` shape —
199
+ * rather than the claim-internal `VERB NOT` form: a postfix `NOT` after a symbolic
77
200
  * comparison verb (`WHEN cpu >= NOT 900`) would be unreadable to the
78
- * parser and silently invert the condition on round trip.
201
+ * parser and silently invert the condition on round trip. The reserved entity
202
+ * name NOT uses an explicit claim marker for an affirmative condition, as does
203
+ * a condition whose verb is NOT.
79
204
  */
80
205
  const conditionText = (claim: Claim.t): string => {
206
+ validateFlags(claim)
207
+ // A leading entity named NOT would be consumed as qualifier negation.
208
+ // An explicit full claim preserves its affirmative reading and also allows
209
+ // NOT in the verb position, which unmarked qualifier parsing excludes.
210
+ if (claim.verb === 'NOT' || (claim.subject.kind === 'entity' && claim.subject.text === 'NOT' && !claim.negated)) {
211
+ const body = `@claim ${claimLine({ ...claim, negated: false })}`
212
+ return claim.negated ? `NOT ${body}` : body
213
+ }
81
214
  const body = claim.verb === 'EXISTS' && claim.payload.kind === 'none' ?
82
- [Claim.formatTerm(claim.subject), ...metaText(claim)].join(' ') :
215
+ singleLine([subjectText(claim.subject), ...metaText(claim)].join(' ')) :
83
216
  claimLine({ ...claim, negated: false })
84
217
  return claim.negated ? `NOT ${body}` : body
85
218
  }
@@ -102,12 +235,18 @@ export const txComment = (tx: string): string =>
102
235
  */
103
236
  export const txOfLine = Token.txOfLine
104
237
 
238
+ /** Optional annotation payload, interpreted by the interchange reader. */
239
+ export const txDataOfLine = Token.txDataOfLine
240
+
105
241
  export type EmitOptions = {
106
242
  /**
107
243
  * Per-claim annotation lines (spec §28.4): when defined for a claim
108
244
  * index, the returned text is emitted verbatim as its own line directly
109
245
  * above that claim, at the claim's indentation. Used by tx-carrying
110
246
  * export ({@link txComment}); return `undefined` to annotate nothing.
247
+ * Called in depth-first appearance order, including every re-statement
248
+ * of a shared or cyclic claim. Repeated appearances use the same index;
249
+ * return a stable transaction identity for that index when enabling replay.
111
250
  */
112
251
  readonly annotate?: (index: number) => undefined | string
113
252
  }
@@ -137,7 +276,7 @@ const tokenText = (token: Token.t): string => {
137
276
  }
138
277
 
139
278
  const commonPrefixLength = (items: readonly RenderItem[]): number => {
140
- const shortest = Math.min(...items.map(item => item.tokens.length))
279
+ const shortest = items.reduce((minimum, item) => Math.min(minimum, item.tokens.length), Infinity)
141
280
  let length = 0
142
281
  while (
143
282
  length < shortest &&
@@ -167,17 +306,31 @@ const emitForest = (
167
306
  inherited: readonly string[],
168
307
  lines: string[]
169
308
  ): void => {
170
- let at = 0
171
- while (at < forest.length) {
309
+ const stack = [{ forest, depth, topLevel, inherited, at: 0, runEnd: 0, canFactor: undefined as boolean | undefined }]
310
+ while (stack.length > 0) {
311
+ const frame = stack[stack.length - 1]!
312
+ const { forest, depth, topLevel, inherited, at } = frame
313
+ if (at >= forest.length) {
314
+ stack.pop()
315
+ continue
316
+ }
172
317
  const firstToken = forest[at]!.tokens[0]
173
- let end = at + 1
174
- while (end < forest.length && forest[end]!.tokens[0] === firstToken) {
175
- end += 1
318
+ if (at >= frame.runEnd) {
319
+ frame.canFactor = undefined
320
+ frame.runEnd = at + 1
321
+ while (frame.runEnd < forest.length && forest[frame.runEnd]!.tokens[0] === firstToken) {
322
+ frame.runEnd += 1
323
+ }
176
324
  }
177
- const run = forest.slice(at, end)
178
- if (run.length > 1) {
325
+ const end = frame.runEnd
326
+ // A one-token line cannot lose a prefix and still retain a leaf token.
327
+ // Every suffix shares this first token. If it already completes a claim,
328
+ // none can form a safe header, so avoid copying and scanning each suffix.
329
+ if (end - at > 1 && forest[at]!.tokens.length > 1 &&
330
+ (frame.canFactor ??= isIncomplete([...inherited, firstToken!], topLevel))) {
331
+ const run = forest.slice(at, end)
179
332
  const common = commonPrefixLength(run)
180
- const shortest = Math.min(...run.map(item => item.tokens.length))
333
+ const shortest = run.reduce((minimum, item) => Math.min(minimum, item.tokens.length), Infinity)
181
334
  const maximum = Math.min(common, shortest - 1)
182
335
  let safe = 0
183
336
  for (let length = 1; length <= maximum; length += 1) {
@@ -190,14 +343,16 @@ const emitForest = (
190
343
  if (safe > 0) {
191
344
  const prefix = run[0]!.tokens.slice(0, safe)
192
345
  lines.push(`${' '.repeat(depth)}${prefix.join(' ')}`)
193
- emitForest(
194
- run.map(item => ({ node: item.node, tokens: item.tokens.slice(safe) })),
195
- depth + 1,
346
+ frame.at = end
347
+ stack.push({
348
+ forest: run.map(item => ({ node: item.node, tokens: item.tokens.slice(safe) })),
349
+ depth: depth + 1,
196
350
  topLevel,
197
- [...inherited, ...prefix],
198
- lines
199
- )
200
- at = end
351
+ inherited: [...inherited, ...prefix],
352
+ at: 0,
353
+ runEnd: 0,
354
+ canFactor: undefined
355
+ })
201
356
  continue
202
357
  }
203
358
  }
@@ -208,19 +363,23 @@ const emitForest = (
208
363
  // annotation stays the line directly above the claim.
209
364
  const rendered = Token.joinComment(item.tokens.join(' '), item.node.comment).split('\n')
210
365
  const last = rendered.pop()!
211
- lines.push(...rendered.map(line => `${indent}${line}`))
366
+ for (const line of rendered) lines.push(`${indent}${line}`)
212
367
  if (item.node.annotation !== undefined) {
213
368
  lines.push(`${indent}${item.node.annotation}`)
214
369
  }
215
370
  lines.push(`${indent}${last}`)
216
- emitForest(
217
- item.node.children.map(node => ({ node, tokens: node.tokens })),
218
- depth + 1,
219
- false,
220
- [],
221
- lines
222
- )
223
- at += 1
371
+ frame.at += 1
372
+ if (item.node.children.length > 0) {
373
+ stack.push({
374
+ forest: item.node.children.map(node => ({ node, tokens: node.tokens })),
375
+ depth: depth + 1,
376
+ topLevel: false,
377
+ inherited: [],
378
+ at: 0,
379
+ runEnd: 0,
380
+ canFactor: undefined
381
+ })
382
+ }
224
383
  }
225
384
  }
226
385
 
@@ -239,9 +398,22 @@ const emitForest = (
239
398
  * the re-statement.
240
399
  */
241
400
  export const emit = (result: Pick<Canonicalize.Result, 'claims' | 'edges'>, options: EmitOptions = {}): string => {
401
+ if (!Array.isArray(result.claims)) throw new TypeError('CAVE claims must be a dense array')
402
+ for (let index = 0; index < result.claims.length; index++) {
403
+ if (!Object.hasOwn(result.claims, index)) throw new TypeError('CAVE claims must be a dense array')
404
+ }
242
405
  const childEdges = new Map<number, Canonicalize.Edge[]>()
243
406
  const isChild = new Set<number>()
244
- for (const edge of result.edges) {
407
+ for (const { parent, child, role } of result.edges) {
408
+ for (const [field, index] of [['parent', parent], ['child', child]] as const) {
409
+ if (!Number.isSafeInteger(index) || index < 0 || index >= result.claims.length) {
410
+ throw new TypeError(`CAVE edge ${field} must index an existing claim`)
411
+ }
412
+ }
413
+ if (role !== 'WHEN' && role !== 'VIA' && role !== 'BECAUSE' && role !== 'QUALIFIES') {
414
+ throw new TypeError('CAVE edge role must be WHEN, VIA, BECAUSE or QUALIFIES')
415
+ }
416
+ const edge = { parent, child, role }
245
417
  isChild.add(edge.child)
246
418
  const existing = childEdges.get(edge.parent)
247
419
  if (existing === undefined) {
@@ -252,29 +424,33 @@ export const emit = (result: Pick<Canonicalize.Result, 'claims' | 'edges'>, opti
252
424
  }
253
425
  const expanded = new Set<number>()
254
426
  const nodeAt = (index: number, role: undefined | Canonicalize.EdgeRole): RenderNode => {
255
- const { claim } = result.claims[index]!
256
- const text = role === undefined || role === 'QUALIFIES' ?
257
- claimLine(claim) :
258
- `${role} ${conditionText(claim)}`
259
- const tokens = Token.tokenize(text).map(tokenText)
260
- const annotation = options.annotate?.(index)
261
- if (expanded.has(index)) {
262
- return {
427
+ const roots: RenderNode[] = []
428
+ const pending: { index: number, role: undefined | Canonicalize.EdgeRole, target: RenderNode[] }[] =
429
+ [{ index, role, target: roots }]
430
+ while (pending.length > 0) {
431
+ const { index, role, target } = pending.pop()!
432
+ const { claim } = result.claims[index]!
433
+ const text = role === undefined || role === 'QUALIFIES' ?
434
+ fullClaimLine(claim) : `${role} ${conditionText(claim)}`
435
+ const tokens = Token.tokenize(text).map(tokenText)
436
+ const annotation = options.annotate?.(index)
437
+ const children: RenderNode[] = []
438
+ target.push({
263
439
  index,
264
440
  tokens,
265
441
  ...claim.comment === undefined ? {} : { comment: claim.comment },
266
442
  ...annotation === undefined ? {} : { annotation },
267
- children: []
443
+ children
444
+ })
445
+ if (expanded.has(index)) continue
446
+ expanded.add(index)
447
+ const edges = childEdges.get(index) ?? []
448
+ for (let at = edges.length - 1; at >= 0; at--) {
449
+ const edge = edges[at]!
450
+ pending.push({ index: edge.child, role: edge.role, target: children })
268
451
  }
269
452
  }
270
- expanded.add(index)
271
- return {
272
- index,
273
- tokens,
274
- ...claim.comment === undefined ? {} : { comment: claim.comment },
275
- ...annotation === undefined ? {} : { annotation },
276
- children: (childEdges.get(index) ?? []).map(edge => nodeAt(edge.child, edge.role))
277
- }
453
+ return roots[0]!
278
454
  }
279
455
  const forest: RenderNode[] = []
280
456
  result.claims.forEach((_, index) => {
package/src/index.ts CHANGED
@@ -18,6 +18,6 @@
18
18
  export * as Registry from './registry.ts'
19
19
  export { canonicalize, canonicalizeText } from './canonicalize.ts'
20
20
  export type { Edge, EdgeRole, Entry, Problem, Result } from './canonicalize.ts'
21
- export { emit, emitClaim, txComment, txOfLine } from './emit.ts'
21
+ export { emit, emitClaim, txComment, txOfLine, txDataOfLine } from './emit.ts'
22
22
  export type { EmitOptions } from './emit.ts'
23
23
  export { standardPrelude, standardRegistry } from './prelude.ts'