@llm4ts/core 2.13.1 → 2.14.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,216 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import type { FactKey } from "./Fact.ts"
4
+ import type { Rule } from "./Rule.ts"
5
+
6
+ /**
7
+ * A ruleset (ADR 0020) is validated when it is built, not when it runs:
8
+ * every read is imported or produced, every export is produced, one
9
+ * producer per key, no constant or self-matching rule. Rules that cannot
10
+ * reach an export are pruned with a warning, so a company's ruleset never
11
+ * pays for a judgment nothing depends on. A valid ruleset is a value that
12
+ * runs many times.
13
+ */
14
+
15
+ export const Problem = Schema.Union([
16
+ Schema.Struct({
17
+ kind: Schema.Literal("UnproducedMatch"),
18
+ rule: Schema.String,
19
+ key: Schema.String
20
+ }),
21
+ Schema.Struct({ kind: Schema.Literal("UnproducedExport"), key: Schema.String }),
22
+ Schema.Struct({
23
+ kind: Schema.Literal("ManyProducers"),
24
+ key: Schema.String,
25
+ rules: Schema.Array(Schema.String)
26
+ }),
27
+ Schema.Struct({ kind: Schema.Literal("EmptyCondition"), rule: Schema.String }),
28
+ Schema.Struct({ kind: Schema.Literal("SelfMatch"), rule: Schema.String, key: Schema.String }),
29
+ Schema.Struct({ kind: Schema.Literal("DuplicateRuleName"), rule: Schema.String }),
30
+ Schema.Struct({ kind: Schema.Literal("UnusedImport"), key: Schema.String }),
31
+ Schema.Struct({ kind: Schema.Literal("Unreachable"), rule: Schema.String })
32
+ ])
33
+ export type Problem = typeof Problem.Type
34
+
35
+ const warningKinds: ReadonlySet<Problem["kind"]> = new Set(["UnusedImport", "Unreachable"])
36
+
37
+ export const isWarning = (problem: Problem): boolean => warningKinds.has(problem.kind)
38
+
39
+ export const renderProblem = (problem: Problem): string => {
40
+ switch (problem.kind) {
41
+ case "UnproducedMatch":
42
+ return `rule ${problem.rule} reads "${problem.key}", which nothing imports or produces`
43
+ case "UnproducedExport":
44
+ return `export "${problem.key}" is produced by no rule`
45
+ case "ManyProducers":
46
+ return `"${problem.key}" has several producers: ${problem.rules.join(", ")}`
47
+ case "EmptyCondition":
48
+ return `rule ${problem.rule} reads nothing`
49
+ case "SelfMatch":
50
+ return `rule ${problem.rule} reads "${problem.key}", which it produces`
51
+ case "DuplicateRuleName":
52
+ return `two rules are named ${problem.rule}`
53
+ case "UnusedImport":
54
+ return `import "${problem.key}" is read by no rule`
55
+ case "Unreachable":
56
+ return `rule ${problem.rule} reaches no export and is not run`
57
+ }
58
+ }
59
+
60
+ export class RulesetInvalid extends Schema.TaggedError<RulesetInvalid>()("RulesetInvalid", {
61
+ name: Schema.String,
62
+ problems: Schema.Array(Problem)
63
+ }) {
64
+ get message(): string {
65
+ return `ruleset "${this.name}" is invalid: ${this.problems.map(renderProblem).join("; ")}`
66
+ }
67
+ }
68
+
69
+ export interface RulesetOptions<Rs extends ReadonlyArray<Rule<unknown, unknown>>> {
70
+ readonly name: string
71
+ readonly imports: ReadonlyArray<FactKey<unknown>>
72
+ readonly exports: ReadonlyArray<FactKey<unknown>>
73
+ readonly rules: Rs
74
+ }
75
+
76
+ /** The union of the rules' error types, so a `judge` and a custom-error `rule` mix freely. */
77
+ export type ErrorOf<Rs extends ReadonlyArray<Rule<unknown, unknown>>> =
78
+ Rs[number] extends Rule<infer E, unknown> ? E : never
79
+
80
+ /** The union of the services the rules need. */
81
+ export type RequirementsOf<Rs extends ReadonlyArray<Rule<unknown, unknown>>> =
82
+ Rs[number] extends Rule<unknown, infer R> ? R : never
83
+
84
+ export interface Ruleset<E = never, R = never> {
85
+ readonly name: string
86
+ readonly imports: ReadonlyArray<string>
87
+ readonly exports: ReadonlyArray<string>
88
+ /** The rules that can reach an export, in the order given. */
89
+ readonly rules: ReadonlyArray<Rule<E, R>>
90
+ readonly warnings: ReadonlyArray<Problem>
91
+ /** One line per rule: `name: reads -> produces`. */
92
+ describe(): string
93
+ /** A Mermaid `flowchart LR` of keys and rules. */
94
+ mermaid(): string
95
+ }
96
+
97
+ const producersByKey = <E, R>(
98
+ imports: ReadonlyArray<string>,
99
+ rules: ReadonlyArray<Rule<E, R>>
100
+ ): ReadonlyMap<string, ReadonlyArray<string>> => {
101
+ const producers = new Map<string, ReadonlyArray<string>>()
102
+ for (const key of imports) producers.set(key, ["(import)"])
103
+ for (const rule of rules) {
104
+ for (const key of rule.produces) {
105
+ producers.set(key, [...(producers.get(key) ?? []), rule.name])
106
+ }
107
+ }
108
+ return producers
109
+ }
110
+
111
+ /** Rules whose products can reach an export, following reads backwards from the exports. */
112
+ const reachable = <E, R>(
113
+ exports: ReadonlyArray<string>,
114
+ rules: ReadonlyArray<Rule<E, R>>
115
+ ): ReadonlySet<string> => {
116
+ const needed = new Set(exports)
117
+ const kept = new Set<string>()
118
+ let grew = true
119
+ while (grew) {
120
+ grew = false
121
+ for (const rule of rules) {
122
+ if (kept.has(rule.name) || !rule.produces.some((key) => needed.has(key))) continue
123
+ kept.add(rule.name)
124
+ for (const key of rule.reads) needed.add(key)
125
+ grew = true
126
+ }
127
+ }
128
+ return kept
129
+ }
130
+
131
+ const escapeLabel = (label: string): string => label.replaceAll('"', "'")
132
+
133
+ const renderMermaid = <E, R>(name: string, rules: ReadonlyArray<Rule<E, R>>): string => {
134
+ const keys = [...new Set(rules.flatMap((rule) => [...rule.reads, ...rule.produces]))]
135
+ const keyId = new Map(keys.map((key, index) => [key, `k${index}`]))
136
+ const ruleId = new Map(rules.map((rule, index) => [rule.name, `r${index}`]))
137
+ const nodes = [
138
+ ...keys.map((key) => ` ${keyId.get(key)}(["${escapeLabel(key)}"])`),
139
+ ...rules.map((rule) => ` ${ruleId.get(rule.name)}["${escapeLabel(rule.name)}"]`)
140
+ ]
141
+ const edges = rules.flatMap((rule) => [
142
+ ...rule.reads.map((key) => ` ${keyId.get(key)} --> ${ruleId.get(rule.name)}`),
143
+ ...rule.produces.map((key) => ` ${ruleId.get(rule.name)} --> ${keyId.get(key)}`)
144
+ ])
145
+ return ["flowchart LR", ` %% ${escapeLabel(name)}`, ...nodes, ...edges].join("\n")
146
+ }
147
+
148
+ /**
149
+ * Typed by overload: the ruleset's error and service types are the unions
150
+ * over its rules, so a `judge` and a custom-error `rule` mix in one array.
151
+ * The implementation only needs names, so it works over loose rules.
152
+ */
153
+ export function makeRuleset<const Rs extends ReadonlyArray<Rule<unknown, unknown>>>(
154
+ options: RulesetOptions<Rs>
155
+ ): Effect.Effect<Ruleset<ErrorOf<Rs>, RequirementsOf<Rs>>, RulesetInvalid>
156
+ export function makeRuleset(
157
+ options: RulesetOptions<ReadonlyArray<Rule<unknown, unknown>>>
158
+ ): Effect.Effect<Ruleset<unknown, unknown>, RulesetInvalid> {
159
+ const given = options.rules
160
+ const imports = options.imports.map((key) => key.name)
161
+ const exports = options.exports.map((key) => key.name)
162
+ const problems: Array<Problem> = []
163
+ const seen = new Set<string>()
164
+ for (const rule of given) {
165
+ if (seen.has(rule.name)) problems.push({ kind: "DuplicateRuleName", rule: rule.name })
166
+ seen.add(rule.name)
167
+ if (rule.reads.length === 0) problems.push({ kind: "EmptyCondition", rule: rule.name })
168
+ for (const key of rule.reads) {
169
+ if (rule.produces.includes(key)) problems.push({ kind: "SelfMatch", rule: rule.name, key })
170
+ }
171
+ }
172
+ const producers = producersByKey(imports, given)
173
+ for (const [key, names] of producers) {
174
+ if (names.length > 1) problems.push({ kind: "ManyProducers", key, rules: names })
175
+ }
176
+ for (const rule of given) {
177
+ for (const key of rule.reads) {
178
+ if (!producers.has(key)) problems.push({ kind: "UnproducedMatch", rule: rule.name, key })
179
+ }
180
+ }
181
+ for (const key of exports) {
182
+ if (!producers.has(key)) problems.push({ kind: "UnproducedExport", key })
183
+ }
184
+ if (problems.length > 0) {
185
+ return Effect.fail(RulesetInvalid.make({ name: options.name, problems }))
186
+ }
187
+ const warnings: Array<Problem> = []
188
+ const kept = reachable(exports, given)
189
+ for (const rule of given) {
190
+ if (!kept.has(rule.name)) warnings.push({ kind: "Unreachable", rule: rule.name })
191
+ }
192
+ const rules = given.filter((rule) => kept.has(rule.name))
193
+ // An import only pruned rules read is unused too: nothing that runs needs it.
194
+ const read = new Set(rules.flatMap((rule) => rule.reads))
195
+ for (const key of imports) {
196
+ if (!read.has(key)) warnings.push({ kind: "UnusedImport", key })
197
+ }
198
+ return Effect.succeed({
199
+ name: options.name,
200
+ imports,
201
+ exports,
202
+ rules,
203
+ warnings,
204
+ describe: () =>
205
+ [
206
+ `ruleset ${options.name}`,
207
+ `imports: ${imports.join(", ")}`,
208
+ `exports: ${exports.join(", ")}`,
209
+ ...rules.map(
210
+ (rule) => `${rule.name}: ${rule.reads.join(", ")} -> ${rule.produces.join(", ")}`
211
+ ),
212
+ ...warnings.map((warning) => `warning: ${renderProblem(warning)}`)
213
+ ].join("\n"),
214
+ mermaid: () => renderMermaid(options.name, rules)
215
+ })
216
+ }
@@ -0,0 +1,251 @@
1
+ import * as Clock from "effect/Clock"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Option from "effect/Option"
4
+ import * as Result from "effect/Result"
5
+ import * as Schema from "effect/Schema"
6
+ import { Board, DuplicateFact, writeOnce, type Fact } from "./Fact.ts"
7
+ import type { Facts, Posted, Rule } from "./Rule.ts"
8
+ import type { Ruleset } from "./Ruleset.ts"
9
+
10
+ /**
11
+ * Forward chaining in rounds (ADR 0020). A round fires, concurrently, every
12
+ * rule whose condition is satisfied by the current snapshot; the facts they
13
+ * post are then applied one by one, write-once. The run ends when a round
14
+ * fires nothing, and it succeeds only if every export is on the board. A
15
+ * rule's failure is recorded and its defaults posted; a defect fails the run.
16
+ */
17
+
18
+ const JudgmentNote = Schema.Struct({ backend: Schema.String, identity: Schema.String })
19
+
20
+ export class Firing extends Schema.Class<Firing>("Firing")({
21
+ rule: Schema.String,
22
+ kind: Schema.Literals(["derive", "judge", "rule"]),
23
+ readKeys: Schema.Array(Schema.String),
24
+ postedKeys: Schema.Array(Schema.String),
25
+ /** Epoch milliseconds. */
26
+ startedAt: Schema.Number,
27
+ /** Milliseconds. */
28
+ duration: Schema.Number,
29
+ /** `failed`: the consequence failed and `postedKeys` are the rule's defaults. */
30
+ outcome: Schema.Literals(["posted", "failed"]),
31
+ judgment: Schema.optionalKey(JudgmentNote)
32
+ }) {}
33
+
34
+ /**
35
+ * A rule's typed failure. `error` is the value itself while the result is in
36
+ * memory; `tag` and `message` are what survive encoding, since `Defect`
37
+ * encodes any error as a plain one.
38
+ */
39
+ export class RuleFailure extends Schema.Class<RuleFailure>("RuleFailure")({
40
+ rule: Schema.String,
41
+ tag: Schema.String,
42
+ message: Schema.String,
43
+ error: Schema.Defect()
44
+ }) {}
45
+
46
+ const isTagged = (error: unknown): error is { readonly _tag: string } =>
47
+ typeof error === "object" && error !== null && "_tag" in error && typeof error._tag === "string"
48
+
49
+ const ruleFailure = (rule: string, error: unknown): RuleFailure =>
50
+ RuleFailure.make({
51
+ rule,
52
+ tag: isTagged(error) ? error._tag : error instanceof Error ? error.name : typeof error,
53
+ message: error instanceof Error ? error.message : String(error),
54
+ error
55
+ })
56
+
57
+ export class RunResult extends Schema.Class<RunResult>("RunResult")({
58
+ board: Board,
59
+ trace: Schema.Array(Firing),
60
+ failures: Schema.Array(RuleFailure)
61
+ }) {}
62
+
63
+ export class UndeclaredFact extends Schema.TaggedError<UndeclaredFact>()("UndeclaredFact", {
64
+ rule: Schema.String,
65
+ key: Schema.String
66
+ }) {
67
+ get message(): string {
68
+ return `rule ${this.rule} posted "${this.key}", which it does not declare`
69
+ }
70
+ }
71
+
72
+ export class MissingImport extends Schema.TaggedError<MissingImport>()("MissingImport", {
73
+ keys: Schema.Array(Schema.String)
74
+ }) {
75
+ get message(): string {
76
+ return `initial facts miss the imports: ${this.keys.join(", ")}`
77
+ }
78
+ }
79
+
80
+ export class WaitingRule extends Schema.Class<WaitingRule>("WaitingRule")({
81
+ rule: Schema.String,
82
+ missingKeys: Schema.Array(Schema.String)
83
+ }) {}
84
+
85
+ /** Why an export is missing: who still waits, and who ran and did not post it. */
86
+ export class MissingExport extends Schema.Class<MissingExport>("MissingExport")({
87
+ key: Schema.String,
88
+ waitingRules: Schema.Array(WaitingRule),
89
+ /** Producers that fired, failed or were vetoed by `when` without posting the key. */
90
+ silentRules: Schema.Array(Schema.String)
91
+ }) {}
92
+
93
+ export class ExportsMissing extends Schema.TaggedError<ExportsMissing>()("ExportsMissing", {
94
+ missing: Schema.Array(MissingExport),
95
+ /** What did run, so a stall can be explained. */
96
+ trace: Schema.Array(Firing),
97
+ failures: Schema.Array(RuleFailure)
98
+ }) {
99
+ get message(): string {
100
+ const reasons = this.missing.map((entry) => {
101
+ const waiting = entry.waitingRules.map(
102
+ (rule) => `${rule.rule} waits for ${rule.missingKeys.join(", ")}`
103
+ )
104
+ const silent = entry.silentRules.map((rule) => `${rule} fired without posting it`)
105
+ return `"${entry.key}" (${[...waiting, ...silent].join("; ")})`
106
+ })
107
+ const failed =
108
+ this.failures.length === 0
109
+ ? ""
110
+ : `; failed: ${this.failures.map((f) => `${f.rule} (${f.tag})`).join(", ")}`
111
+ return `the run ended without: ${reasons.join(", ")}${failed}`
112
+ }
113
+ }
114
+
115
+ export const RunError = Schema.Union([DuplicateFact, UndeclaredFact, MissingImport, ExportsMissing])
116
+ export type RunError = typeof RunError.Type
117
+
118
+ interface Ready<E, R> {
119
+ readonly rule: Rule<E, R>
120
+ readonly firing: Effect.Effect<Posted, E, R>
121
+ }
122
+
123
+ interface Outcome<E, R> {
124
+ readonly rule: Rule<E, R>
125
+ readonly startedAt: number
126
+ readonly duration: number
127
+ readonly result: Result.Result<Posted, E>
128
+ }
129
+
130
+ /** Apply posted facts write-once; the keys actually written, in first-seen order. */
131
+ const post = (
132
+ facts: Facts,
133
+ rule: string,
134
+ allowed: ReadonlyArray<string>,
135
+ posted: ReadonlyArray<Fact>
136
+ ): Effect.Effect<[Facts, ReadonlyArray<string>], DuplicateFact | UndeclaredFact> =>
137
+ Effect.gen(function* () {
138
+ let current = facts
139
+ const keys: Array<string> = []
140
+ for (const fact of posted) {
141
+ if (!allowed.includes(fact.key)) {
142
+ return yield* Effect.fail(UndeclaredFact.make({ rule, key: fact.key }))
143
+ }
144
+ const written = writeOnce(current, fact.key, yield* fact.encoded)
145
+ if (Result.isFailure(written)) return yield* Effect.fail(written.failure)
146
+ current = written.success
147
+ if (!keys.includes(fact.key)) keys.push(fact.key)
148
+ }
149
+ return [current, keys]
150
+ })
151
+
152
+ const fireAll = <E, R>(
153
+ ready: ReadonlyArray<Ready<E, R>>
154
+ ): Effect.Effect<ReadonlyArray<Outcome<E, R>>, never, R> =>
155
+ Effect.forEach(
156
+ ready,
157
+ ({ rule, firing }) =>
158
+ Effect.gen(function* () {
159
+ const startedAt = yield* Clock.currentTimeMillis
160
+ const result = yield* Effect.result(firing)
161
+ const finishedAt = yield* Clock.currentTimeMillis
162
+ return { rule, startedAt, duration: finishedAt - startedAt, result }
163
+ }),
164
+ { concurrency: "unbounded" }
165
+ )
166
+
167
+ export const runRuleset = <E, R>(
168
+ ruleset: Ruleset<E, R>,
169
+ initial: ReadonlyArray<Fact>
170
+ ): Effect.Effect<RunResult, RunError, R> =>
171
+ Effect.gen(function* () {
172
+ const initialKeys = initial.map((fact) => fact.key)
173
+ const missingImports = ruleset.imports.filter((key) => !initialKeys.includes(key))
174
+ if (missingImports.length > 0) {
175
+ return yield* Effect.fail(MissingImport.make({ keys: missingImports }))
176
+ }
177
+ let [facts] = yield* post(new Map(), "(initial)", ruleset.imports, initial)
178
+ const pending = new Map(ruleset.rules.map((rule) => [rule.name, rule]))
179
+ const trace: Array<Firing> = []
180
+ const failures: Array<RuleFailure> = []
181
+ for (;;) {
182
+ const ready: Array<Ready<E, R>> = []
183
+ for (const rule of pending.values()) {
184
+ if (!rule.reads.every((key) => facts.has(key))) continue
185
+ const prepared = yield* rule.prepare(facts)
186
+ // Every read is present and facts never change, so a None now is a No forever.
187
+ pending.delete(rule.name)
188
+ if (Option.isSome(prepared)) ready.push({ rule, firing: prepared.value })
189
+ }
190
+ if (ready.length === 0) break
191
+ const outcomes = yield* fireAll(ready)
192
+ for (const outcome of outcomes) {
193
+ const failed = Result.isFailure(outcome.result)
194
+ if (Result.isFailure(outcome.result)) {
195
+ failures.push(ruleFailure(outcome.rule.name, outcome.result.failure))
196
+ }
197
+ const posted: Posted = Result.isSuccess(outcome.result)
198
+ ? outcome.result.success
199
+ : { facts: outcome.rule.defaults }
200
+ const [next, postedKeys] = yield* post(
201
+ facts,
202
+ outcome.rule.name,
203
+ outcome.rule.produces,
204
+ posted.facts
205
+ )
206
+ facts = next
207
+ trace.push(
208
+ Firing.make({
209
+ rule: outcome.rule.name,
210
+ kind: outcome.rule.kind,
211
+ readKeys: outcome.rule.reads,
212
+ postedKeys,
213
+ startedAt: outcome.startedAt,
214
+ duration: outcome.duration,
215
+ outcome: failed ? "failed" : "posted",
216
+ ...(posted.judgment === undefined ? {} : { judgment: posted.judgment })
217
+ })
218
+ )
219
+ }
220
+ }
221
+ const missing = ruleset.exports.filter((key) => !facts.has(key))
222
+ if (missing.length > 0) {
223
+ return yield* Effect.fail(
224
+ ExportsMissing.make({
225
+ missing: missing.map((key) =>
226
+ MissingExport.make({
227
+ key,
228
+ waitingRules: [...pending.values()]
229
+ .filter((rule) => rule.produces.includes(key))
230
+ .map((rule) =>
231
+ WaitingRule.make({
232
+ rule: rule.name,
233
+ missingKeys: rule.reads.filter((read) => !facts.has(read))
234
+ })
235
+ ),
236
+ silentRules: ruleset.rules
237
+ .filter((rule) => rule.produces.includes(key) && !pending.has(rule.name))
238
+ .map((rule) => rule.name)
239
+ })
240
+ ),
241
+ trace,
242
+ failures
243
+ })
244
+ )
245
+ }
246
+ return RunResult.make({
247
+ board: Board.make({ facts: Object.fromEntries(facts) }),
248
+ trace,
249
+ failures
250
+ })
251
+ })