@bramas/simulant-lean-gen 0.0.0-stage → 0.1.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.
@@ -0,0 +1,196 @@
1
+ import { T, showType, typeEq } from "@bramas/simulant-lang/types";
2
+ import { fail, ident, Translator } from "./translate.js";
3
+ /** Enum types mentioned by a type. */
4
+ export function enumsOf(t, out = new Map()) {
5
+ switch (t.k) {
6
+ case 'enum':
7
+ out.set(t.name, t.ctors);
8
+ break;
9
+ case 'option':
10
+ case 'list':
11
+ case 'multiset':
12
+ enumsOf(t.of, out);
13
+ break;
14
+ case 'record':
15
+ for (const f of t.fields)
16
+ enumsOf(f.type, out);
17
+ break;
18
+ }
19
+ return out;
20
+ }
21
+ export function enumsInExpr(e, out) {
22
+ if (!e)
23
+ return;
24
+ const visit = (x) => {
25
+ if (!x || typeof x !== 'object')
26
+ return;
27
+ if (Array.isArray(x)) {
28
+ x.forEach(visit);
29
+ return;
30
+ }
31
+ const o = x;
32
+ if (o.e === 'lit' || o.k)
33
+ enumsOf((o.e === 'lit' ? o.type : o), out);
34
+ for (const v of Object.values(o))
35
+ visit(v);
36
+ };
37
+ visit(e);
38
+ }
39
+ export function enumDecl(name, ctors) {
40
+ return `inductive ${ident(name)} where\n${ctors.map(c => ` | ${ident(c)}`).join('\n')}\nderiving DecidableEq, Repr, Inhabited\n`;
41
+ }
42
+ export function structDecl(name, params, fields, type) {
43
+ if (!fields.length)
44
+ return `structure ${name}${params} where\nderiving DecidableEq, Repr\n`;
45
+ return `structure ${name}${params} where\n${fields.map(f => ` ${ident(f.name)} : ${type(f.type)}`).join('\n')}\nderiving DecidableEq, Repr\n`;
46
+ }
47
+ /** The experiment parameters read directly by initial values (`site := param site`). */
48
+ function inputsOf(c) {
49
+ const inputs = new Map();
50
+ const fields = [...(c.world ?? []), ...(c.agent ?? [])];
51
+ const visit = (x, expected) => {
52
+ if (!x || typeof x !== 'object')
53
+ return;
54
+ const o = x;
55
+ if (o.e === 'param') {
56
+ if (!expected)
57
+ fail(`${c.name}: the type of parameter ${o.name} is not known (use it directly as an initial value)`);
58
+ inputs.set(o.name, expected);
59
+ return;
60
+ }
61
+ for (const v of Object.values(o))
62
+ if (v && typeof v === 'object')
63
+ visit(v);
64
+ };
65
+ for (const f of fields) {
66
+ const init = c.init?.world?.[f.name] ?? c.init?.agent?.[f.name];
67
+ if (init?.e === 'param')
68
+ inputs.set(init.name, f.type);
69
+ else
70
+ visit(init);
71
+ }
72
+ return [...inputs].map(([name, type]) => ({ name, type })).sort((a, b) => a.name.localeCompare(b.name));
73
+ }
74
+ export function componentInfo(c, namespace) {
75
+ const enums = new Map();
76
+ for (const f of [...(c.params ?? []), ...(c.world ?? []), ...(c.agent ?? []), ...(c.actions ?? [])])
77
+ enumsOf(f.type, enums);
78
+ for (const e of [c.beforeLook, c.beforeMove, c.afterMove, ...Object.values(c.init?.world ?? {}), ...Object.values(c.init?.agent ?? {}),
79
+ ...(c.sensors ?? []).map(s => s.body)])
80
+ enumsInExpr(e, enums);
81
+ return {
82
+ name: c.name, namespace, inputs: inputsOf(c),
83
+ hasWorld: !!c.world?.length, hasFields: !!c.agent?.length,
84
+ enums: [...enums].map(([name, ctors]) => ({ name, ctors })).filter(e => e.name !== 'Move'),
85
+ };
86
+ }
87
+ /** The Lean module of a component. */
88
+ export function generateComponent(c, namespace) {
89
+ const info = componentInfo(c, namespace);
90
+ const world = c.world ?? [];
91
+ const fields = c.agent ?? [];
92
+ const flags = c.flags ?? [];
93
+ const actions = c.actions ?? [];
94
+ const params = c.params ?? [];
95
+ const worldT = T.record(world);
96
+ const fieldsT = T.record(fields);
97
+ const enumNames = new Set(info.enums.map(e => e.name));
98
+ /** The part of the world the component owns. */
99
+ const own = world.length ? '(World σ.N)' : 'Unit';
100
+ const env = (node, N, k) => ({
101
+ node, agent: `Fin ${k}`, N, k, pub: 'σ.P',
102
+ typeName: t => {
103
+ if (t.k === 'enum' && enumNames.has(t.name))
104
+ return ident(t.name);
105
+ if (t.k === 'record' && typeEq(t, worldT))
106
+ return `World ${N}`;
107
+ if (t.k === 'record' && typeEq(t, fieldsT))
108
+ return `Fields ${N}`;
109
+ return fail(`${c.name}: no Lean name for ${showType(t)}`);
110
+ },
111
+ worldField: (C, f) => `(L.world.get ${C}.world).${ident(f)}`,
112
+ agentFlag: (a, f) => `(Flags.${ident(f)} ${a}.flags)`,
113
+ agentField: (a, f) => `(L.fields.get ${a}.fields).${ident(f)}`,
114
+ decisionAction: (d, a) => `(Actions.${ident(a)} ${d}.act)`,
115
+ param: name => ident(name),
116
+ paramType: name => info.inputs.find(i => i.name === name)?.type ?? fail(`unknown parameter ${name}`),
117
+ effect: (w, raise) => {
118
+ const worldCode = w.length ? `fun x => { x with ${w.map(([f, v]) => `${ident(f)} := ${v}`).join(', ')} }` : 'id';
119
+ const one = ([flag, r]) => {
120
+ const cond = r.pred ? `${r.pred} a` : `(${r.set}).contains a`;
121
+ return `(if ${cond} then (Flags.raise_${ident(flag)} : σ.F) else FlagSet.none)`;
122
+ };
123
+ const raiseCode = raise.length
124
+ ? raise.map(one).reduce((acc, x) => `FlagSet.merge ${acc} ${x}`)
125
+ : 'FlagSet.none';
126
+ return `({ world := ${worldCode}, raise := fun a => ${raiseCode} } : OwnEffect σ ${own})`;
127
+ },
128
+ ts: 'ts', ds: 'ds',
129
+ layout: { pub: T.pub, mem: T.unit, worldType: worldT, flags, agentFields: fields, actions },
130
+ defs: new Map((c.defs ?? []).map(d => [d.name, d])),
131
+ defName: n => ident(n),
132
+ });
133
+ const hookEnv = env('Fin σ.N', 'σ.N', 'σ.k');
134
+ const initEnv = env('Fin N', 'N', 'k');
135
+ const tr = new Translator(hookEnv);
136
+ const trInit = new Translator(initEnv);
137
+ const paramVars = params.map(p => [p.name, ident(p.name), p.type]);
138
+ const paramBinders = params.map(p => `(${ident(p.name)} : ${tr.type(p.type)})`).join(' ');
139
+ const inputBinders = info.inputs.map(p => `(${ident(p.name)} : ${trInit.type(p.type)})`).join(' ');
140
+ const out = [];
141
+ out.push(`import Simulant\n\n/-! Generated from the IR of component \`${c.name}\` by packages/lean-gen. Do not edit. -/\n\nset_option linter.unusedVariables false\n`);
142
+ out.push(`namespace ${namespace}\nopen Simulant\n`);
143
+ for (const e of info.enums)
144
+ out.push(enumDecl(e.name, e.ctors));
145
+ if (world.length)
146
+ out.push(structDecl('World', ' (N : Nat)', world, t => trInit.type(t)));
147
+ if (fields.length)
148
+ out.push(structDecl('Fields', ' (N : Nat)', fields, t => trInit.type(t)));
149
+ out.push(`/-- The flags declared by \`${c.name}\`, with the laws of a composition's flags. -/`);
150
+ if (flags.length) {
151
+ out.push(`class Flags (F : Type) [FlagSet F] where\n${flags.map(f => {
152
+ const n = ident(f.name);
153
+ return ` ${n} : F → Bool\n raise_${n} : F\n ${n}_raise : ${n} raise_${n} = true\n ${n}_merge : ∀ a b : F, ${n} (FlagSet.merge a b) = (${n} a || ${n} b)\n ${n}_none : ${n} (FlagSet.none : F) = false`;
154
+ }).join('\n')}\n`);
155
+ }
156
+ else {
157
+ out.push('class Flags (F : Type) [FlagSet F] where\n');
158
+ }
159
+ out.push(actions.length
160
+ ? `class Actions (A : Type) where\n${actions.map(a => ` ${ident(a.name)} : A → ${trInit.type(a.type)}`).join('\n')}\n`
161
+ : 'class Actions (A : Type) where\n');
162
+ out.push(`/-- What the composition gives the component: lenses on the state it owns. -/\nstructure Ctx (σ : Sig) where\n${[world.length ? ' world : Lens σ.W (World σ.N)' : '', fields.length ? ' fields : Lens σ.X (Fields σ.N)' : ''].filter(Boolean).join('\n')}\n`);
163
+ // Initial values
164
+ if (world.length) {
165
+ const binds = world.map(f => {
166
+ const r = trInit.root(c.init.world[f.name], paramVars);
167
+ return [f.name, r];
168
+ });
169
+ out.push(`def initWorld {N : Nat} [NeZero N] ${paramBinders} ${inputBinders} : ND (World N) := do\n${binds.map(([n, r]) => ` let ${ident(n)} ← ${trInit.nd(r)}`).join('\n')}\n pure { ${world.map(f => `${ident(f.name)} := ${ident(f.name)}`).join(', ')} }\n`);
170
+ }
171
+ for (const f of fields) {
172
+ const r = trInit.root(c.init.agent[f.name], [['i', 'i', T.nat], ...paramVars]);
173
+ out.push(`def init_${ident(f.name)} {N : Nat} [NeZero N] ${paramBinders} ${inputBinders} (i : Nat) : ND (${trInit.type(f.type)}) :=\n ${trInit.nd(r)}\n`);
174
+ }
175
+ out.push(`section\nvariable {σ : Sig} [FlagSet σ.F] [Flags σ.F] [Actions σ.A]\n`);
176
+ for (const s of c.sensors ?? []) {
177
+ const r = tr.root(s.body, [['C', 'C', T.config], ['me', 'me', T.agent], ['frame', 'frame', T.frame], ...paramVars], 'C');
178
+ out.push(`def sensor_${ident(s.name)} (L : Ctx σ) ${paramBinders} (ts : TState) (C : Config σ) (me : Fin σ.k) (frame : Bool) : ND (${tr.type(r.t)}) :=\n ${tr.nd(r)}\n`);
179
+ }
180
+ const hook = (body, args) => {
181
+ if (!body)
182
+ return `fun ${args} => pure ⟨id, fun _ => FlagSet.none⟩`;
183
+ return `fun ${args} => ${tr.nd(tr.root(body, [['C', 'C', T.config], ...paramVars], 'C'))}`;
184
+ };
185
+ out.push(`/-- The hooks, on the part of the world the component owns. -/
186
+ def hooks ${paramBinders} (L : Ctx σ) : Hooks σ ${own} where
187
+ beforeLook := ${hook(c.beforeLook, 'ts C')}
188
+ beforeMove := ${hook(c.beforeMove, 'ts C ds')}
189
+ afterMove := ${hook(c.afterMove, 'ts C')}
190
+
191
+ def component ${paramBinders} (L : Ctx σ) : Component σ :=
192
+ (hooks ${params.map(p => ident(p.name)).join(' ')} L).toComponent ${world.length ? 'L.world' : '(Lens.unit σ.W)'}
193
+ `);
194
+ out.push(`end\n\nend ${namespace}\n`);
195
+ return out.join('\n');
196
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Differential test of the generated Lean model against the interpreter
3
+ * (plan, Section 5.3): on a small instance, the computable Lean round and
4
+ * the interpreter must give the same successors, and every witness of the
5
+ * interpreter must be a witness in Lean (replay of interpreter paths).
6
+ *
7
+ * The interpreter explores the instance with agents kept in index order
8
+ * (as in Lean) and without symmetry reduction; a generated Lean file then
9
+ * evaluates the round on each configuration and prints the successors.
10
+ */
11
+ import { execFileSync } from "node:child_process";
12
+ import { mkdirSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { resolvePub } from "@bramas/simulant-lang/types";
15
+ import { Explorer } from "@bramas/simulant-engine/explorer";
16
+ import { System } from "@bramas/simulant-engine/system";
17
+ import { canonicalSort } from "@bramas/simulant-engine/values";
18
+ import { generateModel } from "./model.js";
19
+ import { fail } from "./translate.js";
20
+ /** Groups the composed fields by owning component, in component order. */
21
+ function parts(lay, which) {
22
+ const owners = [];
23
+ for (const f of lay[which])
24
+ if (!owners.includes(f.owner))
25
+ owners.push(f.owner);
26
+ return owners.map(owner => ({ owner, fields: lay[which].filter(f => f.owner === owner) }));
27
+ }
28
+ /** A Lean term for a value, with anonymous constructors (the expected type is always known). */
29
+ function term(v, t) {
30
+ switch (t.k) {
31
+ case 'unit': return '()';
32
+ case 'bool':
33
+ case 'frame':
34
+ case 'nat': return String(v);
35
+ case 'int': return `(${v} : Int)`;
36
+ case 'node': return `⟨${v}, by decide⟩`;
37
+ case 'move': return `Simulant.Move.${v}`;
38
+ case 'enum': return `.${v}`;
39
+ case 'option': return v === null ? 'none' : `(some ${term(v, t.of)})`;
40
+ case 'list':
41
+ case 'multiset': return `[${v.map(x => term(x, t.of)).join(', ')}]`;
42
+ case 'record': return `⟨${t.fields.map(f => term(v[f.name], f.type)).join(', ')}⟩`;
43
+ default: return fail(`no Lean term for values of type ${t.k}`);
44
+ }
45
+ }
46
+ let depth = 0;
47
+ /** Lean code printing `code` (of type `t`) as JSON. */
48
+ function json(code, t) {
49
+ switch (t.k) {
50
+ case 'unit': return '"null"';
51
+ case 'bool':
52
+ case 'frame':
53
+ case 'nat':
54
+ case 'int': return `(toString ${code})`;
55
+ case 'node': return `(toString ${code}.val)`;
56
+ case 'move':
57
+ case 'enum': return `("\\"" ++ reprStr ${code} ++ "\\"")`;
58
+ case 'option': {
59
+ const y = `y${depth++}`;
60
+ return `(match ${code} with | none => "null" | some ${y} => ${json(y, t.of)})`;
61
+ }
62
+ case 'list':
63
+ case 'multiset': {
64
+ const y = `y${depth++}`;
65
+ return `("[" ++ ",".intercalate (${code}.map (fun ${y} => ${json(y, t.of)})) ++ "]")`;
66
+ }
67
+ case 'record':
68
+ return `("{" ++ ${t.fields.map(f => `"\\"${f.name}\\":" ++ ${json(`(${code}).${f.name}`, f.type)}`).join(' ++ "," ++ ')} ++ "}")`
69
+ .replace('"{" ++ ++ "}"', '"{}"');
70
+ default: return fail(`no JSON printer for type ${t.k}`);
71
+ }
72
+ }
73
+ /** Back to interpreter values: enum names lose their Lean namespace, multisets are sorted. */
74
+ function normalize(v, t) {
75
+ switch (t.k) {
76
+ case 'move':
77
+ case 'enum': return String(v).split('.').pop();
78
+ case 'option': return v === null ? null : normalize(v, t.of);
79
+ case 'list': return v.map((x) => normalize(x, t.of));
80
+ case 'multiset': return canonicalSort(t.of)(v.map((x) => normalize(x, t.of)));
81
+ case 'record': return Object.fromEntries(t.fields.map(f => [f.name, normalize(v[f.name], f.type)]));
82
+ case 'unit': return null;
83
+ default: return v;
84
+ }
85
+ }
86
+ /** `mutate` edits the generated Lean source: tests use it to check that differences are detected. */
87
+ export function differential(exp, params, leanDir, maxStates = 40, mutate = x => x) {
88
+ const system = new System(exp, params, { sortAgents: false });
89
+ const explorer = new Explorer(system, { symmetry: false, maxStates });
90
+ explorer.run();
91
+ const configs = explorer.states.map(s => s.config);
92
+ const lay = system.lay;
93
+ const pub = lay.pub, mem = lay.mem;
94
+ const N = system.topo.size;
95
+ const k = configs[0]?.agents.length ?? 0;
96
+ const worldParts = parts(lay, 'world');
97
+ const fieldParts = parts(lay, 'agentFields');
98
+ const sig = `(sig ${N} ${k})`;
99
+ const agentTerm = (a) => `{ pos := ⟨${a.pos}, by decide⟩, flags := ⟨${lay.flags.map(f => String(!!a.f[f.name])).join(', ')}⟩, fields := ⟨${fieldParts.map(p => `⟨${p.fields.map(f => term(a.f[f.name], resolvePub(f.type, pub))).join(', ')}⟩`).join(', ')}⟩, pub := ${term(a.pub, pub)}, mem := ${term(a.mem, mem)} }`;
100
+ const configTerm = (C) => `({ agents := #v[${C.agents.map(agentTerm).join(', ')}], world := ⟨${worldParts.map(p => `⟨${p.fields.map(f => term(C.world[f.name], f.type)).join(', ')}⟩`).join(', ')}⟩ } : Config ${sig})`;
101
+ depth = 0;
102
+ const agentJson = `"{\\"pos\\":" ++ toString a.pos.val ++ ",\\"pub\\":" ++ ${json('a.pub', pub)} ++ ",\\"mem\\":" ++ ${json('a.mem', mem)} ++ ",\\"f\\":{" ++ ${[...lay.flags.map(f => `"\\"${f.name}\\":" ++ toString a.flags.${f.name}`),
103
+ ...fieldParts.flatMap(p => p.fields.map(f => `"\\"${f.name}\\":" ++ ${json(`a.fields.${p.owner}.${f.name}`, f.type)}`))].join(' ++ "," ++ ') || '""'} ++ "}}"`;
104
+ const worldJson = `"{" ++ ${worldParts.flatMap(p => p.fields.map(f => `"\\"${f.name}\\":" ++ ${json(`C.world.${p.owner}.${f.name}`, f.type)}`)).join(' ++ "," ++ ') || '""'} ++ "}"`;
105
+ const ns = 'DiffModel';
106
+ const lean = `${generateModel(exp, ns)}
107
+ open Simulant ${ns}
108
+
109
+ def jAgent (a : Agent ${sig}) : String := ${agentJson}
110
+ def jConfig (C : Config ${sig}) : String :=
111
+ "{\\"agents\\":[" ++ ",".intercalate (C.agents.toList.map jAgent) ++ "],\\"world\\":" ++ (${worldJson}) ++ "}"
112
+ def jWitness (w : Witness) : String := "[" ++ ",".intercalate (w.map fun (s, i) => "[\\"" ++ s ++ "\\"," ++ toString i ++ "]") ++ "]"
113
+
114
+ def configs : List (Config ${sig}) := [
115
+ ${configs.map(configTerm).join(',\n ')}]
116
+
117
+ #eval show IO Unit from do
118
+ for C in configs do
119
+ let succ := (model ${N} ${k}).roundA act0 C
120
+ IO.println ("[" ++ ",".intercalate (succ.map fun (w, _, C') => "[" ++ jWitness w ++ "," ++ jConfig C' ++ "]") ++ "]")
121
+ `;
122
+ const dir = join(leanDir, '.diff');
123
+ mkdirSync(dir, { recursive: true });
124
+ const file = join(dir, `${exp.name.replace(/\W+/g, '_')}.lean`);
125
+ writeFileSync(file, mutate(lean));
126
+ const out = execFileSync('lake', ['env', 'lean', file], { cwd: leanDir, encoding: 'utf8', maxBuffer: 1 << 28 });
127
+ const lines = out.split('\n').filter(l => l.startsWith('['));
128
+ if (lines.length !== configs.length)
129
+ fail(`Lean printed ${lines.length} lines for ${configs.length} configurations:\n${out.slice(0, 2000)}`);
130
+ const normConfig = (c) => ({
131
+ agents: c.agents.map((a) => ({
132
+ pos: a.pos, pub: normalize(a.pub, pub), mem: normalize(a.mem, mem),
133
+ f: Object.fromEntries([...lay.flags.map(f => [f.name, a.f[f.name]]),
134
+ ...lay.agentFields.map(f => [f.name, normalize(a.f[f.name], f.type)])]),
135
+ })),
136
+ world: normalize(c.world, lay.worldType),
137
+ });
138
+ const mismatches = [];
139
+ let successors = 0;
140
+ configs.forEach((C, i) => {
141
+ const lean = JSON.parse(lines[i])
142
+ .map(([w, c]) => ({ w: JSON.stringify(w), key: system.key(normConfig(c)) }));
143
+ const engine = system.successors(C).map(s => ({ w: JSON.stringify(s.witness.map(c => [c.site, c.index])), key: s.key }));
144
+ successors += engine.length;
145
+ const leanKeys = new Set(lean.map(x => x.key));
146
+ const engineKeys = new Set(engine.map(x => x.key));
147
+ for (const key of engineKeys)
148
+ if (!leanKeys.has(key))
149
+ mismatches.push(`state ${i}: successor ${key} only in the interpreter`);
150
+ for (const key of leanKeys)
151
+ if (!engineKeys.has(key))
152
+ mismatches.push(`state ${i}: successor ${key} only in Lean`);
153
+ for (const e of engine) {
154
+ if (!lean.some(l => l.key === e.key && l.w === e.w))
155
+ mismatches.push(`state ${i}: witness ${e.w} of ${e.key} is not a Lean witness`);
156
+ }
157
+ });
158
+ return { states: configs.length, successors, mismatches };
159
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Generates the component library (`lean/Simulant/Library/*.lean`) from the
3
+ * standard library's IR: npx tsx packages/lean-gen/src/library.ts
4
+ */
5
+ import { writeFileSync } from "node:fs";
6
+ import { stdlibElaborator } from "@bramas/simulant-lang/elab/node";
7
+ import { resolveComponent } from "@bramas/simulant-lang/standalone";
8
+ import { generateComponent } from "./component.js";
9
+ /**
10
+ * The library's components, from the `.sim` standard library. Their
11
+ * arguments do not matter: parameters stay symbolic.
12
+ */
13
+ export const libraryComponents = (() => {
14
+ const elab = stdlibElaborator();
15
+ return ['Hazards.BlackHole .mayEmerge', 'Faults.CrashStop 1', 'Faults.CrashAtNode', 'Storage.Pebbles',
16
+ 'Frames.PersistentReflection', 'Monitors.Visited'].map(e => resolveComponent(elab.evaluate(e)));
17
+ })();
18
+ export const libraryNamespace = (name) => `Simulant.Library.${name}`;
19
+ export function generateLibrary(dir) {
20
+ const names = [];
21
+ for (const c of libraryComponents) {
22
+ writeFileSync(new URL(`Library/${c.name}.lean`, dir), generateComponent(c, libraryNamespace(c.name)));
23
+ names.push(c.name);
24
+ }
25
+ writeFileSync(new URL('Library.lean', dir), names.map(n => `import Simulant.Library.${n}\n`).join(''));
26
+ return names;
27
+ }
28
+ if (import.meta.url === `file://${process.argv[1]}`) {
29
+ console.log(generateLibrary(new URL('../../../lean/Simulant/', import.meta.url)).join('\n'));
30
+ }