@cavelang/core 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.
Files changed (62) hide show
  1. package/README.md +76 -0
  2. package/dist/src/claim.d.ts +108 -0
  3. package/dist/src/claim.d.ts.map +1 -0
  4. package/dist/src/claim.js +65 -0
  5. package/dist/src/claim.js.map +1 -0
  6. package/dist/src/confidence.d.ts +26 -0
  7. package/dist/src/confidence.d.ts.map +1 -0
  8. package/dist/src/confidence.js +34 -0
  9. package/dist/src/confidence.js.map +1 -0
  10. package/dist/src/context.d.ts +26 -0
  11. package/dist/src/context.d.ts.map +1 -0
  12. package/dist/src/context.js +31 -0
  13. package/dist/src/context.js.map +1 -0
  14. package/dist/src/entity.d.ts +26 -0
  15. package/dist/src/entity.d.ts.map +1 -0
  16. package/dist/src/entity.js +41 -0
  17. package/dist/src/entity.js.map +1 -0
  18. package/dist/src/index.d.ts +23 -0
  19. package/dist/src/index.d.ts.map +1 -0
  20. package/dist/src/index.js +23 -0
  21. package/dist/src/index.js.map +1 -0
  22. package/dist/src/key.d.ts +28 -0
  23. package/dist/src/key.d.ts.map +1 -0
  24. package/dist/src/key.js +49 -0
  25. package/dist/src/key.js.map +1 -0
  26. package/dist/src/multiplier.d.ts +17 -0
  27. package/dist/src/multiplier.d.ts.map +1 -0
  28. package/dist/src/multiplier.js +19 -0
  29. package/dist/src/multiplier.js.map +1 -0
  30. package/dist/src/tag.d.ts +29 -0
  31. package/dist/src/tag.d.ts.map +1 -0
  32. package/dist/src/tag.js +28 -0
  33. package/dist/src/tag.js.map +1 -0
  34. package/dist/src/uncertainty.d.ts +21 -0
  35. package/dist/src/uncertainty.d.ts.map +1 -0
  36. package/dist/src/uncertainty.js +26 -0
  37. package/dist/src/uncertainty.js.map +1 -0
  38. package/dist/src/uuidv7.d.ts +28 -0
  39. package/dist/src/uuidv7.d.ts.map +1 -0
  40. package/dist/src/uuidv7.js +69 -0
  41. package/dist/src/uuidv7.js.map +1 -0
  42. package/dist/src/value.d.ts +57 -0
  43. package/dist/src/value.d.ts.map +1 -0
  44. package/dist/src/value.js +104 -0
  45. package/dist/src/value.js.map +1 -0
  46. package/dist/src/verb.d.ts +47 -0
  47. package/dist/src/verb.d.ts.map +1 -0
  48. package/dist/src/verb.js +55 -0
  49. package/dist/src/verb.js.map +1 -0
  50. package/package.json +39 -0
  51. package/src/claim.ts +148 -0
  52. package/src/confidence.ts +42 -0
  53. package/src/context.ts +43 -0
  54. package/src/entity.ts +49 -0
  55. package/src/index.ts +23 -0
  56. package/src/key.ts +55 -0
  57. package/src/multiplier.ts +28 -0
  58. package/src/tag.ts +42 -0
  59. package/src/uncertainty.ts +29 -0
  60. package/src/uuidv7.ts +76 -0
  61. package/src/value.ts +138 -0
  62. package/src/verb.ts +78 -0
package/src/claim.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Claims — the CAVE model (spec §2.1).
3
+ *
4
+ * Every CAVE line denotes a claim c = ⟨s, v, o, n, m⟩: subject, verb,
5
+ * object-or-attribute/value, negated, metadata (confidence, contexts, tags,
6
+ * value uncertainty, importance, comment, transaction identity). Claims are
7
+ * immutable — belief changes by appending (spec §9).
8
+ *
9
+ * This module defines the *canonical* claim shape: subject/verb/object are in
10
+ * primary direction (inverse forms are normalized before keying, spec §5.5),
11
+ * with the author's original text preserved in `raw`.
12
+ */
13
+
14
+ import * as Value from './value.ts'
15
+ import * as Tag from './tag.ts'
16
+ import * as Context from './context.ts'
17
+ import * as Confidence from './confidence.ts'
18
+
19
+ /** Subject or object term (spec §16: atom, literal or code literal). */
20
+ export type TermKind = 'entity' | 'text' | 'code'
21
+
22
+ export type Term = {
23
+ readonly kind: TermKind
24
+ readonly text: string
25
+ }
26
+
27
+ /** @returns entity term. */
28
+ export const entity = (text: string): Term =>
29
+ ({ kind: 'entity', text })
30
+
31
+ /** @returns double-quoted natural-language literal term. */
32
+ export const text = (value: string): Term =>
33
+ ({ kind: 'text', text: value })
34
+
35
+ /** @returns backticked code literal term. */
36
+ export const code = (value: string): Term =>
37
+ ({ kind: 'code', text: value })
38
+
39
+ /** @returns canonical text of a term with its delimiters. */
40
+ export const formatTerm = (term: Term): string => {
41
+ switch (term.kind) {
42
+ case 'text':
43
+ return `"${term.text}"`
44
+ case 'code':
45
+ return `\`${term.text}\``
46
+ default:
47
+ return term.text
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Claim payload — the canonical line shapes (spec §3.1) plus the object-less
53
+ * form used by bare existence assertions (spec §5.2 `EXISTS`):
54
+ *
55
+ * - `relation`: `subject VERB object`
56
+ * - `attribute`: `subject HAS attribute: value`
57
+ * - `metric`: `metric IS value`
58
+ * - `none`: `memory-leak EXISTS @production`
59
+ */
60
+ export type Payload =
61
+ | { readonly kind: 'relation', readonly object: Term }
62
+ | { readonly kind: 'attribute', readonly attribute: string, readonly value: Value.t }
63
+ | { readonly kind: 'metric', readonly value: Value.t }
64
+ | { readonly kind: 'none' }
65
+
66
+ /** Canonical (primary-direction) claim. */
67
+ export type Claim = {
68
+ readonly subject: Term
69
+ /** Canonical primary verb, uppercase (spec §13.4 steps 1–2). */
70
+ readonly verb: string
71
+ /** `VERB NOT` logical negation (spec §5.6). */
72
+ readonly negated: boolean
73
+ readonly payload: Payload
74
+ /** Contexts in author order, deduplicated. */
75
+ readonly contexts: readonly Context.t[]
76
+ readonly tags: readonly Tag.t[]
77
+ /** Epistemic confidence in [0, 1]; 1 when omitted (spec §6.3). */
78
+ readonly conf: Confidence.t
79
+ /** `!` marker (spec §6). */
80
+ readonly importance: boolean
81
+ /** `+/-` value uncertainty (spec §7.2). */
82
+ readonly delta?: Value.t
83
+ /** `(Nσ)` σ-level override; semantic default is 2 (spec §7.2). */
84
+ readonly sigmaLevel?: number
85
+ /** `;` persisted prose (spec §6.4). */
86
+ readonly comment?: string
87
+ /** The line exactly as written, including inverse form (spec §5.5). */
88
+ readonly raw: string
89
+ }
90
+
91
+ export type t = Claim
92
+
93
+ export type Init = {
94
+ subject: Term
95
+ verb: string
96
+ payload: Payload
97
+ negated?: boolean
98
+ contexts?: readonly Context.t[]
99
+ tags?: readonly Tag.t[]
100
+ conf?: Confidence.t
101
+ importance?: boolean
102
+ delta?: Value.t
103
+ sigmaLevel?: number
104
+ comment?: string
105
+ raw?: string
106
+ }
107
+
108
+ /** @returns claim with defaults applied (spec §6: all suffixes optional). */
109
+ export const of = (init: Init): Claim => ({
110
+ subject: init.subject,
111
+ verb: init.verb,
112
+ negated: init.negated ?? false,
113
+ payload: init.payload,
114
+ contexts: Context.dedupe(init.contexts ?? []),
115
+ tags: init.tags ?? [],
116
+ conf: init.conf ?? Confidence.defaultConfidence,
117
+ importance: init.importance ?? false,
118
+ ...init.delta !== undefined ? { delta: init.delta } : {},
119
+ ...init.sigmaLevel !== undefined ? { sigmaLevel: init.sigmaLevel } : {},
120
+ ...init.comment !== undefined ? { comment: init.comment } : {},
121
+ raw: init.raw ?? ''
122
+ })
123
+
124
+ /** @returns relational claim payload. */
125
+ export const relation = (object: Term): Payload =>
126
+ ({ kind: 'relation', object })
127
+
128
+ /** @returns attribute/value claim payload. */
129
+ export const attribute = (attribute: string, value: Value.t): Payload =>
130
+ ({ kind: 'attribute', attribute, value })
131
+
132
+ /** @returns metric claim payload. */
133
+ export const metric = (value: Value.t): Payload =>
134
+ ({ kind: 'metric', value })
135
+
136
+ /** Object-less payload — bare existence assertion (spec §5.2 `EXISTS`). */
137
+ export const none: Payload =
138
+ { kind: 'none' }
139
+
140
+ /**
141
+ * σ derived from the claim's `+/- Δ (kσ)` metadata: σ = Δ / k with k
142
+ * defaulting to 2 (spec §7.2). `undefined` when the claim carries no numeric
143
+ * uncertainty.
144
+ */
145
+ export const sigmaOf = (claim: Claim): undefined | number =>
146
+ claim.delta?.num === undefined ?
147
+ undefined :
148
+ claim.delta.num / (claim.sigmaLevel ?? 2)
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Claim confidence — `@ N%` (spec §6.3).
3
+ *
4
+ * Epistemic belief in the assertion, stored as a decimal in [0, 1].
5
+ * Omitted confidence means `@ 100%` — directly observed, certain for
6
+ * practical purposes. `@ 0%` means evidentially false or fully rejected
7
+ * (retraction, spec §9.3).
8
+ */
9
+
10
+ export type Confidence = number
11
+
12
+ export type t = Confidence
13
+
14
+ /** Confidence of a claim with no explicit `@ N%` (spec §6.3). */
15
+ export const defaultConfidence = 1
16
+
17
+ /**
18
+ * Parses a percentage token (`90%`) to a decimal clamped to [0, 1]
19
+ * (spec §13.4 step 6: `@ 90%` → `0.9`). The `%` is required — the grammar
20
+ * (spec §16) defines confidence as a percentage ending in `%`, and demanding
21
+ * it keeps a mistyped context like `@ 2026` from silently becoming
22
+ * certainty.
23
+ * @returns `undefined` when the token is not a percentage.
24
+ */
25
+ export const parse = (token: string): undefined | Confidence => {
26
+ const match = /^(\d+(?:\.\d+)?)%$/.exec(token.trim())
27
+ if (!match || match[1] === undefined) {
28
+ return undefined
29
+ }
30
+ return clamp(Number(match[1]) / 100)
31
+ }
32
+
33
+ /** @returns confidence clamped to [0, 1]. */
34
+ export const clamp = (conf: number): Confidence =>
35
+ Math.min(1, Math.max(0, conf))
36
+
37
+ /** @returns canonical `N%` text: `0.9` → `90%`. */
38
+ export const format = (conf: Confidence): string => {
39
+ const percent = conf * 100
40
+ const rounded = Math.round(percent * 100) / 100
41
+ return `${rounded}%`
42
+ }
package/src/context.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Contexts — `@ctx` (spec §6.1).
3
+ *
4
+ * A context scopes a claim by time, place, source or logical scope:
5
+ * `@production`, `@2026-Q1`, `@auth.ts:42`, `@src:filing`. No space after
6
+ * `@` (with a space it is confidence, spec §6.3). Multiple contexts per
7
+ * claim are allowed and are part of the claim key (spec §9.2).
8
+ */
9
+
10
+ export type Context = string
11
+
12
+ export type t = Context
13
+
14
+ /** Recommended context prefixes (spec §6.1). */
15
+ export const prefixes = ['src', 'time', 'loc', 'scope'] as const
16
+
17
+ export type Prefix = (typeof prefixes)[number]
18
+
19
+ const prefixSet = new Set<string>(prefixes)
20
+
21
+ /**
22
+ * @returns the recommended prefix of a context, when it has one:
23
+ * `src:filing` → `src`, `production` → `undefined`.
24
+ */
25
+ export const prefix = (context: Context): undefined | Prefix => {
26
+ const colonAt = context.indexOf(':')
27
+ if (colonAt === -1) {
28
+ return undefined
29
+ }
30
+ const head = context.slice(0, colonAt)
31
+ return prefixSet.has(head) ? head as Prefix : undefined
32
+ }
33
+
34
+ /** @returns canonical `@ctx` text. */
35
+ export const format = (context: Context): string =>
36
+ `@${context}`
37
+
38
+ /**
39
+ * @returns deduplicated contexts in original order. Claim keys use the
40
+ * sorted form (see `Key`); emission preserves author order.
41
+ */
42
+ export const dedupe = (contexts: readonly Context[]): Context[] =>
43
+ [...new Set(contexts)]
package/src/entity.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Entity names (spec §4.1).
3
+ *
4
+ * Entities are compact names: `auth/middleware`, `react/hooks/use-memo`,
5
+ * `PostgreSQL`, `Sarah`. `/` separates scope segments (at most 3:
6
+ * `domain/entity/aspect`), segments are kebab-case, proper nouns keep their
7
+ * casing.
8
+ */
9
+
10
+ export type Entity = string
11
+
12
+ export type t = Entity
13
+
14
+ /**
15
+ * Normalizes an entity name (spec §13.4 step 4): trims and collapses runs of
16
+ * internal whitespace to `-`. Casing is preserved — proper nouns keep theirs.
17
+ *
18
+ * `token expiry` → `token-expiry`, `PostgreSQL` → `PostgreSQL`.
19
+ */
20
+ export const normalize = (name: string): Entity =>
21
+ name.trim().replace(/\s+/g, '-')
22
+
23
+ /** @returns scope segments of an entity name: `a/b/c` → `['a', 'b', 'c']`. */
24
+ export const segments = (entity: Entity): string[] =>
25
+ entity.split('/')
26
+
27
+ /**
28
+ * Advisory well-formedness check (spec §4.1). Returns a list of human-readable
29
+ * problems; an empty list means the name follows every convention. Problems
30
+ * are advisories, not parse errors — CAVE tolerates messy extraction input.
31
+ */
32
+ export const check = (entity: Entity): string[] => {
33
+ const problems: string[] = []
34
+ if (entity.length === 0) {
35
+ problems.push('empty entity name')
36
+ return problems
37
+ }
38
+ const parts = segments(entity)
39
+ if (parts.length > 3) {
40
+ problems.push(`more than 3 scope segments (${parts.length}): domain/entity/aspect is the maximum`)
41
+ }
42
+ if (parts.some(part => part.length === 0)) {
43
+ problems.push('empty scope segment')
44
+ }
45
+ if (/\s/.test(entity)) {
46
+ problems.push('whitespace in entity name (normalize to -)')
47
+ }
48
+ return problems
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `@cavelang/core` — the CAVE domain model (spec §2, §6, §7, §9).
3
+ *
4
+ * Dependency-free foundation of the CAVE monorepo: canonical claim shapes,
5
+ * values/units/multipliers, uncertainty and confidence semantics, claim keys
6
+ * and UUIDv7 transaction identifiers. Higher packages (parser, canonical,
7
+ * store, query, fusion, loop, cli) build on these types.
8
+ *
9
+ * Modules follow the `@prelude` convention: `import * as Claim from ...`,
10
+ * with each module exporting its principal type as `t`.
11
+ */
12
+
13
+ export * as Claim from './claim.ts'
14
+ export * as Confidence from './confidence.ts'
15
+ export * as Context from './context.ts'
16
+ export * as Entity from './entity.ts'
17
+ export * as Key from './key.ts'
18
+ export * as Multiplier from './multiplier.ts'
19
+ export * as Tag from './tag.ts'
20
+ export * as Uncertainty from './uncertainty.ts'
21
+ export * as Uuidv7 from './uuidv7.ts'
22
+ export * as Value from './value.ts'
23
+ export * as Verb from './verb.ts'
package/src/key.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Claim keys (spec §9.2).
3
+ *
4
+ * The claim key identifies the *fact* whose belief evolves over time: the
5
+ * latest transaction with the same key is the current belief (spec §9.1).
6
+ * Keys are computed on the canonical (primary-direction) form, so a forward
7
+ * claim and its inverse reading share one key — one fact, two names
8
+ * (spec §5.5).
9
+ *
10
+ * Key components:
11
+ *
12
+ * - relational claim: subject, verb, object, negated, context set
13
+ * - attribute/value claim: subject, verb, attribute, negated, context set
14
+ * - metric claim: subject, verb, negated, context set
15
+ *
16
+ * The value is *excluded* — it may change over time while the key stays
17
+ * about the same property. Confidence, tags, uncertainty, importance and
18
+ * comments are metadata and never key components. Contexts participate as a
19
+ * sorted, deduplicated set, which is what lets competing hypotheses
20
+ * differentiate by `@hyp:` context (spec §10.3).
21
+ *
22
+ * The key format is a JSON array — deterministic, collision-free and
23
+ * readable in the database.
24
+ */
25
+
26
+ import type * as Claim from './claim.ts'
27
+
28
+ // Every term kind gets its own prefix so the three encodings occupy
29
+ // disjoint namespaces — an entity literally named `code:<=` must not
30
+ // collide with the backticked code literal `<=`.
31
+ const termPart = (term: Claim.Term): string =>
32
+ term.kind === 'entity' ? `e:${term.text}` : `${term.kind}:${term.text}`
33
+
34
+ const payloadPart = (payload: Claim.Payload): string => {
35
+ switch (payload.kind) {
36
+ case 'relation':
37
+ return `r:${termPart(payload.object)}`
38
+ case 'attribute':
39
+ return `a:${payload.attribute}`
40
+ case 'metric':
41
+ return 'm'
42
+ case 'none':
43
+ return 'n'
44
+ }
45
+ }
46
+
47
+ /** @returns stable claim key of a canonical claim. */
48
+ export const of = (claim: Claim.t): string =>
49
+ JSON.stringify([
50
+ termPart(claim.subject),
51
+ claim.verb,
52
+ claim.negated ? 1 : 0,
53
+ payloadPart(claim.payload),
54
+ [...new Set(claim.contexts)].sort()
55
+ ])
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Numeric multipliers (spec §7.1).
3
+ *
4
+ * A multiplier is a single uppercase letter glued to a number: `20B`, `900M`,
5
+ * `1.5T`, `10K`. It scales the numeric value during canonicalization
6
+ * (spec §13.4 step 8: `20B` → `20000000000`); the raw text is preserved.
7
+ */
8
+
9
+ /** Multiplier letter. */
10
+ export type Multiplier = 'T' | 'B' | 'M' | 'K'
11
+
12
+ export type t = Multiplier
13
+
14
+ /** Scale factor per multiplier letter. */
15
+ export const factors: Readonly<Record<Multiplier, number>> = {
16
+ T: 1e12,
17
+ B: 1e9,
18
+ M: 1e6,
19
+ K: 1e3
20
+ }
21
+
22
+ /** @returns `true` if `s` is exactly one multiplier letter. */
23
+ export const is = (s: string): s is Multiplier =>
24
+ s === 'T' || s === 'B' || s === 'M' || s === 'K'
25
+
26
+ /** @returns scale factor of multiplier. */
27
+ export const factor = (m: Multiplier): number =>
28
+ factors[m]
package/src/tag.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Tags — flat `#tag` and scoped `#key:value` (spec §6.2).
3
+ *
4
+ * A flat `#security` is `key=security, value=undefined`; a scoped
5
+ * `#topic:auth-security` splits on the first `:`. Scoped tags subsume flat
6
+ * tags — a flat `#tag` is simply a tag with null value.
7
+ *
8
+ * Tags classify the claim, not the entity, and carry no independent belief
9
+ * history (the two-lane rule, spec §11.1).
10
+ */
11
+
12
+ export type Tag = {
13
+ readonly key: string
14
+ /** `undefined` for flat tags; spec stores NULL (spec §13.2). */
15
+ readonly value?: string
16
+ }
17
+
18
+ export type t = Tag
19
+
20
+ /** @returns tag from key and optional scoped value. */
21
+ export const of = (key: string, value?: string): Tag =>
22
+ value === undefined ? { key } : { key, value }
23
+
24
+ /**
25
+ * Splits a tag body (without the leading `#`) on the first `:`
26
+ * (spec §6.2 disambiguation): `topic:auth-security` → scoped,
27
+ * `security` → flat.
28
+ */
29
+ export const parse = (body: string): Tag => {
30
+ const colonAt = body.indexOf(':')
31
+ return colonAt === -1 ?
32
+ { key: body } :
33
+ { key: body.slice(0, colonAt), value: body.slice(colonAt + 1) }
34
+ }
35
+
36
+ /** @returns canonical `#key[:value]` text. */
37
+ export const format = (tag: Tag): string =>
38
+ tag.value === undefined ? `#${tag.key}` : `#${tag.key}:${tag.value}`
39
+
40
+ /** @returns `true` when both tags have the same key and value. */
41
+ export const equals = (a: Tag, b: Tag): boolean =>
42
+ a.key === b.key && a.value === b.value
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Value uncertainty — `+/- delta [(Nσ)]` (spec §7.2).
3
+ *
4
+ * `+/-` defines a symmetric interval around the value; the default
5
+ * interpretation is 2σ (≈95% interval). If the interval is Δ at kσ then
6
+ * σ = Δ / k:
7
+ *
8
+ * - `20B +/- 2B` → σ = 1B (default 2σ)
9
+ * - `20B +/- 2B (1σ)` → σ = 2B (wider)
10
+ * - `20B +/- 2B (3σ)` → σ ≈ 0.67B (tighter)
11
+ *
12
+ * Value uncertainty is aleatory (the quantity itself is imprecise) and is
13
+ * independent of epistemic claim confidence `@ N%` (spec §7.3).
14
+ */
15
+
16
+ /** Default σ level when `(Nσ)` is omitted (spec §7.2). */
17
+ export const defaultSigmaLevel = 2
18
+
19
+ /** @returns σ from an interval `delta` given at `level`σ: σ = Δ / k. */
20
+ export const sigma = (delta: number, level: number = defaultSigmaLevel): number => {
21
+ if (!(level > 0)) {
22
+ throw new Error(`Expected positive sigma level, got ${level}.`)
23
+ }
24
+ return delta / level
25
+ }
26
+
27
+ /** @returns symmetric `[low, high]` interval around `mean`. */
28
+ export const interval = (mean: number, delta: number): [number, number] =>
29
+ [mean - delta, mean + delta]
package/src/uuidv7.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * UUIDv7 transaction identifiers (spec §9.1).
3
+ *
4
+ * CAVE storage is append-only; each appended claim carries a transaction id.
5
+ * UUIDv7 is recommended because it encodes timestamp and ordering — ids sort
6
+ * lexicographically by creation time, so `MAX(tx)` resolves current belief.
7
+ *
8
+ * {@link next} is strictly monotonic within a process: same-millisecond calls
9
+ * increment a 12-bit sequence in the `rand_a` field, and a backwards system
10
+ * clock never produces a smaller id.
11
+ */
12
+
13
+ import { randomFillSync } from 'node:crypto'
14
+
15
+ const hex = (n: number, digits: number): string =>
16
+ n.toString(16).padStart(digits, '0')
17
+
18
+ /**
19
+ * Formats a UUIDv7 from parts — pure, for tests and deterministic imports.
20
+ *
21
+ * @param ms unix timestamp in milliseconds (48 bits)
22
+ * @param seq 12-bit sequence placed in `rand_a`
23
+ * @param rand 62 bits of randomness for `rand_b` as 8 bytes (top 2 bits are
24
+ * overwritten by the variant)
25
+ */
26
+ export const at = (ms: number, seq: number, rand: Uint8Array): string => {
27
+ if (!Number.isInteger(ms) || ms < 0 || ms > 0xffff_ffff_ffff) {
28
+ throw new Error(`Expected 48-bit millisecond timestamp, got ${ms}.`)
29
+ }
30
+ if (!Number.isInteger(seq) || seq < 0 || seq > 0xfff) {
31
+ throw new Error(`Expected 12-bit sequence, got ${seq}.`)
32
+ }
33
+ if (rand.length < 8) {
34
+ throw new Error(`Expected 8 random bytes, got ${rand.length}.`)
35
+ }
36
+ const time = hex(ms, 12)
37
+ const variantByte = (rand[0]! & 0x3f) | 0x80
38
+ return [
39
+ time.slice(0, 8),
40
+ time.slice(8, 12),
41
+ `7${hex(seq, 3)}`,
42
+ hex(variantByte, 2) + hex(rand[1]!, 2),
43
+ [...rand.slice(2, 8)].map(byte => hex(byte, 2)).join('')
44
+ ].join('-')
45
+ }
46
+
47
+ let lastMs = -1
48
+ let lastSeq = 0
49
+
50
+ /**
51
+ * @returns next monotonic UUIDv7. Strictly increasing (lexicographically)
52
+ * within the process even across same-millisecond calls and clock skew.
53
+ */
54
+ export const next = (now: () => number = Date.now): string => {
55
+ let ms = now()
56
+ if (ms <= lastMs) {
57
+ ms = lastMs
58
+ lastSeq += 1
59
+ if (lastSeq > 0xfff) {
60
+ ms += 1
61
+ lastSeq = 0
62
+ }
63
+ } else {
64
+ lastSeq = 0
65
+ }
66
+ lastMs = ms
67
+ const rand = new Uint8Array(8)
68
+ randomFillSync(rand)
69
+ return at(ms, lastSeq, rand)
70
+ }
71
+
72
+ const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
73
+
74
+ /** @returns `true` if `s` is a well-formed UUIDv7 string. */
75
+ export const is = (s: string): boolean =>
76
+ uuidRe.test(s)
package/src/value.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Values, units and multipliers (spec §7.1).
3
+ *
4
+ * A value is the payload of an attribute claim (`HAS attr: value`), a metric
5
+ * claim (`metric IS value`), or a `+/-` uncertainty delta. Parsing keeps the
6
+ * raw text verbatim and additionally extracts a normalized numeric value and
7
+ * unit when possible (spec §13.4 steps 7–9):
8
+ *
9
+ * - `30ms` → num 30, unit `ms`
10
+ * - `20B USD/yr` → num 20000000000, unit `USD/yr`
11
+ * - `~20B USD/yr` → the same, `approx` set
12
+ * - `94.5%` → num 94.5, unit `%`
13
+ * - `20 conn` → num 20, unit `conn`
14
+ * - `2026-H2` → date-like, kept textual
15
+ * - `token-expiry` → atom, kept textual
16
+ *
17
+ * Quoted (`"..."`) and backticked (`` `...` ``) values arrive from the parser
18
+ * with their kind already known; use {@link ofText} / {@link ofCode}.
19
+ */
20
+
21
+ import * as Multiplier from './multiplier.ts'
22
+
23
+ /** How the value text was classified. */
24
+ export type Kind =
25
+ | 'number' // numeric, possibly with multiplier and unit
26
+ | 'date' // date-like: 2026-H2, 2026-Q1, 2026-04-10
27
+ | 'atom' // bare word(s): token-expiry, critical
28
+ | 'text' // double-quoted natural-language literal
29
+ | 'code' // backticked exact literal
30
+
31
+ export type Value = {
32
+ /** Exactly as written, including `~` and multiplier letter. */
33
+ readonly raw: string
34
+ readonly kind: Kind
35
+ /** `~` prefix (spec §7.1): the value is approximate. */
36
+ readonly approx: boolean
37
+ /** Normalized numeric value with multiplier expanded, when parseable. */
38
+ readonly num?: number
39
+ /** Normalized unit expression (`USD/yr`, `ms`, `%`), when present. */
40
+ readonly unit?: string
41
+ }
42
+
43
+ export type t = Value
44
+
45
+ const numberRe = /^(-?\d+(?:\.\d+)?)([A-Za-z%]*)$/
46
+ const unitRe = /^[A-Za-z%$][A-Za-z0-9%$]*(?:\/[A-Za-z0-9%$]+)*$/
47
+ const dateRe = /^\d{4}-(?:H[1-2]|Q[1-4]|W\d{1,2}|\d{2})(?:-\d{2})?$/
48
+
49
+ /** @returns `true` if `s` is a valid unit expression (`USD/yr`, `ms`, `%`). */
50
+ export const isUnit = (s: string): boolean =>
51
+ unitRe.test(s)
52
+
53
+ /**
54
+ * @returns `true` if `s` is date-like (spec §16 `date_like`): `2026-H2`,
55
+ * `2026-Q1`, `2026-04`, `2026-04-10`. A bare year parses as a number instead.
56
+ */
57
+ export const isDateLike = (s: string): boolean =>
58
+ dateRe.test(s)
59
+
60
+ type Numeric = { num: number, unit?: string }
61
+
62
+ /**
63
+ * Parses `body` as number [multiplier] [unit] where the unit is either glued
64
+ * (`30ms`, `94.5%`) or space-separated (`20B USD/yr`, `20 conn`).
65
+ * @returns `undefined` when `body` is not numeric.
66
+ */
67
+ const parseNumeric = (body: string): undefined | Numeric => {
68
+ const spaceAt = body.indexOf(' ')
69
+ const head = spaceAt === -1 ? body : body.slice(0, spaceAt)
70
+ const tail = spaceAt === -1 ? undefined : body.slice(spaceAt + 1).trim()
71
+ const match = numberRe.exec(head)
72
+ if (!match) {
73
+ return undefined
74
+ }
75
+ const [, digits, glued] = match
76
+ let num = Number(digits)
77
+ let unit: undefined | string
78
+ if (glued !== undefined && glued !== '') {
79
+ if (Multiplier.is(glued)) {
80
+ num *= Multiplier.factor(glued)
81
+ } else if (isUnit(glued)) {
82
+ unit = glued
83
+ } else {
84
+ return undefined
85
+ }
86
+ }
87
+ if (tail !== undefined) {
88
+ if (unit !== undefined || !isUnit(tail)) {
89
+ return undefined
90
+ }
91
+ unit = tail
92
+ }
93
+ return unit === undefined ? { num } : { num, unit }
94
+ }
95
+
96
+ /**
97
+ * Parses an unquoted value string (spec §16 `value`), classifying it as
98
+ * number, date or atom. The raw text (including `~` and multiplier letters)
99
+ * is preserved verbatim.
100
+ */
101
+ export const parse = (raw: string): Value => {
102
+ const approx = raw.startsWith('~')
103
+ const body = approx ? raw.slice(1) : raw
104
+ const numeric = parseNumeric(body)
105
+ if (numeric !== undefined) {
106
+ return numeric.unit === undefined ?
107
+ { raw, kind: 'number', approx, num: numeric.num } :
108
+ { raw, kind: 'number', approx, num: numeric.num, unit: numeric.unit }
109
+ }
110
+ if (isDateLike(body)) {
111
+ return { raw, kind: 'date', approx }
112
+ }
113
+ return { raw, kind: 'atom', approx }
114
+ }
115
+
116
+ /** @returns value for a double-quoted natural-language literal (spec §4.2). */
117
+ export const ofText = (text: string): Value =>
118
+ ({ raw: text, kind: 'text', approx: false })
119
+
120
+ /** @returns value for a backticked exact code literal (spec §4.2). */
121
+ export const ofCode = (code: string): Value =>
122
+ ({ raw: code, kind: 'code', approx: false })
123
+
124
+ /**
125
+ * @returns canonical text of the value for emission. Raw text is already
126
+ * canonical (spec normalizes multipliers only in storage, never in text);
127
+ * quoted kinds are re-wrapped in their delimiters.
128
+ */
129
+ export const format = (value: Value): string => {
130
+ switch (value.kind) {
131
+ case 'text':
132
+ return `"${value.raw}"`
133
+ case 'code':
134
+ return `\`${value.raw}\``
135
+ default:
136
+ return value.raw
137
+ }
138
+ }