@bramas/simulant-engine 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,330 @@
1
+ /**
2
+ * Exploration of the state graph (plan, Sections 3.10 and 6.4).
3
+ *
4
+ * A reachable `failure` verdict is a counterexample. Liveness goals are
5
+ * decided on the expanded part of the graph with strongly connected
6
+ * components: under a synchronous round every cycle counts; under a fair scheduler a
7
+ * cycle counts only if it is scheduler-fair, i.e. every agent that acts in
8
+ * it is activated somewhere on it. Flags are never cleared, so which agents
9
+ * act is the same in every state of a component.
10
+ */
11
+ import type { Goal } from '@bramas/simulant-lang/ir';
12
+ import { Symmetry } from './symmetry';
13
+ import type { Choice, Config, Successor, System, Verdict } from './system';
14
+
15
+ export type ExploreOptions = {
16
+ maxDepth?: number;
17
+ maxStates?: number;
18
+ /** Symmetry reduction (default: on, with the derived group). */
19
+ symmetry?: boolean;
20
+ /** Initial configurations (default: the system's); several settings can share one graph. */
21
+ initial?: Successor[];
22
+ };
23
+
24
+ /** Cancellation and pacing of `runAsync`. */
25
+ export type AsyncOptions = {
26
+ signal?: AbortSignal;
27
+ /** Milliseconds of work between two returns to the event loop (default 50). */
28
+ slice?: number;
29
+ };
30
+
31
+ export type State = {
32
+ id: number;
33
+ config: Config;
34
+ key: string;
35
+ depth: number;
36
+ verdict: Verdict;
37
+ /** BFS parent, for shortest counterexamples. */
38
+ parent?: number;
39
+ /** Successor ids; undefined while the state is not expanded. */
40
+ succ?: number[];
41
+ /** Per successor edge, the agents some witness activates (fair schedulers only). */
42
+ act?: Set<number>[];
43
+ };
44
+
45
+ export type Step = { state: number; config: Config; verdict: Verdict; witness: Choice[] };
46
+
47
+ export type Counterexample = {
48
+ kind: 'failure' | 'lasso';
49
+ /**
50
+ * From an initial configuration. For a lasso, the last step returns to the
51
+ * state of `steps[cycleStart]`: the steps after `cycleStart` repeat forever.
52
+ */
53
+ steps: Step[];
54
+ cycleStart?: number;
55
+ /** The index, in the initial configurations explored, of the one the steps start from. */
56
+ initialIndex: number;
57
+ };
58
+
59
+ export type Result = {
60
+ status: 'success' | 'failure' | 'incomplete';
61
+ reason: string;
62
+ counterexample?: Counterexample;
63
+ stats: { states: number; expanded: number; edges: number; maxDepth: number; group: number };
64
+ };
65
+
66
+ export class Explorer {
67
+ readonly states: State[] = [];
68
+ readonly initial: number[] = [];
69
+ /** A concrete configuration of each initial state, before symmetry reduction, and its index. */
70
+ private readonly concreteInitial = new Map<number, { config: Config; index: number }>();
71
+ private readonly index = new Map<string, number>();
72
+ readonly symmetry: Symmetry;
73
+ private readonly goal: Goal;
74
+ private incomplete: string | undefined;
75
+ private edges = 0;
76
+
77
+ constructor(readonly system: System, readonly opts: ExploreOptions = {}) {
78
+ this.symmetry = new Symmetry(system, { enabled: opts.symmetry !== false });
79
+ this.goal = system.exp.task.goal;
80
+ }
81
+
82
+ private add(s: Successor, depth: number, parent?: number): number {
83
+ const rep = this.symmetry.trivial ? s : this.symmetry.canonical(s.config, s.key);
84
+ const known = this.index.get(rep.key);
85
+ if (known !== undefined) return known;
86
+ const id = this.states.length;
87
+ this.states.push({ id, config: rep.config, key: rep.key, depth, verdict: this.system.verdict(rep.config), parent });
88
+ this.index.set(rep.key, id);
89
+ return id;
90
+ }
91
+
92
+ /** Whether the successors of a state matter for the goal. */
93
+ private shouldExpand(s: State) {
94
+ if (s.verdict === 'failure') return false;
95
+ return !(this.goal === 'eventually' && s.verdict === 'success');
96
+ }
97
+
98
+ run(): Result {
99
+ const work = this.work();
100
+ for (;;) {
101
+ const r = work.next();
102
+ if (r.done) return r.value;
103
+ }
104
+ }
105
+
106
+ /** As `run`, returning to the event loop regularly, so that work can be cancelled (watch mode). */
107
+ async runAsync(opts: AsyncOptions = {}): Promise<Result> {
108
+ const work = this.work();
109
+ const slice = opts.slice ?? 50;
110
+ const check = () => { if (opts.signal?.aborted) throw opts.signal.reason ?? new Error('aborted'); };
111
+ check();
112
+ for (let start = Date.now(); ;) {
113
+ const r = work.next();
114
+ if (r.done) return r.value;
115
+ check();
116
+ if (Date.now() - start >= slice) {
117
+ await new Promise(resolve => setImmediate(resolve));
118
+ check();
119
+ start = Date.now();
120
+ }
121
+ }
122
+ }
123
+
124
+ /** The exploration, yielding every few hundred expanded states. */
125
+ private *work(): Generator<void, Result> {
126
+ const { system, opts } = this;
127
+ const maxDepth = opts.maxDepth ?? Infinity;
128
+ const maxStates = opts.maxStates ?? Infinity;
129
+ (opts.initial ?? system.initial()).forEach((s, index) => {
130
+ const id = this.add(s, 0);
131
+ if (!this.concreteInitial.has(id)) {
132
+ this.initial.push(id);
133
+ this.concreteInitial.set(id, { config: s.config, index });
134
+ }
135
+ });
136
+ const fairScheduler = system.exp.model.round !== 'fsync';
137
+ for (let cursor = 0; cursor < this.states.length; cursor++) {
138
+ if ((cursor & 255) === 255) yield;
139
+ const s = this.states[cursor];
140
+ if (s.verdict === 'failure') {
141
+ return this.result('failure', 'a reachable configuration fails the task', this.counterexample('failure', this.pathIds(s.id)));
142
+ }
143
+ if (!this.shouldExpand(s)) continue;
144
+ if (s.depth >= maxDepth) { this.incomplete ??= `depth limit ${maxDepth} reached`; continue; }
145
+ if (this.states.length >= maxStates) { this.incomplete ??= `state limit ${maxStates} reached`; break; }
146
+ const succ: number[] = [];
147
+ const act: Set<number>[] = [];
148
+ const successorIndex = new Map<number, number>();
149
+ for (const t of system.successors(s.config)) {
150
+ const id = this.add(t, s.depth + 1, s.id);
151
+ const at = successorIndex.get(id);
152
+ if (at === undefined) {
153
+ successorIndex.set(id, succ.length);
154
+ succ.push(id); act.push(new Set(t.activated));
155
+ } else if (fairScheduler) for (const i of t.activated) act[at].add(i);
156
+ }
157
+ s.succ = succ;
158
+ if (fairScheduler) s.act = act;
159
+ this.edges += succ.length;
160
+ }
161
+ const lasso = this.goal === 'safety' ? undefined : this.findLasso(fairScheduler);
162
+ if (lasso) return this.result('failure', `an execution never satisfies "${this.goal}"`, lasso);
163
+ if (this.incomplete) return this.result('incomplete', this.incomplete);
164
+ return this.result('success', 'every admissible execution satisfies the task');
165
+ }
166
+
167
+ private result(status: Result['status'], reason: string, counterexample?: Counterexample): Result {
168
+ return {
169
+ status, reason, counterexample,
170
+ stats: {
171
+ states: this.states.length,
172
+ expanded: this.states.filter(s => s.succ).length,
173
+ edges: this.edges,
174
+ maxDepth: this.states.reduce((m, s) => Math.max(m, s.depth), 0),
175
+ group: this.symmetry.group.length,
176
+ },
177
+ };
178
+ }
179
+
180
+ /** States that may lie on a bad cycle. */
181
+ private inGraph(s: State) {
182
+ if (!s.succ) return false;
183
+ return this.goal === 'eventually' ? s.verdict !== 'success' : true;
184
+ }
185
+
186
+ private findLasso(fairScheduler: boolean): Counterexample | undefined {
187
+ for (const scc of this.sccs()) {
188
+ const members = new Set(scc);
189
+ const inside = (u: number) => this.states[u].succ!.map((v, j) => [v, j] as const).filter(([v]) => members.has(v));
190
+ const nontrivial = scc.length > 1 || inside(scc[0]).some(([v]) => v === scc[0]);
191
+ if (!nontrivial) continue;
192
+ // The bad state the cycle must visit.
193
+ const bad = this.goal === 'eventually' ? scc[0] : scc.find(u => this.states[u].verdict !== 'success');
194
+ if (bad === undefined) continue;
195
+ // Scheduler fairness: every acting agent is activated on some edge of the component.
196
+ const needed: [number, number][] = [];
197
+ if (fairScheduler) {
198
+ const C = this.states[scc[0]].config;
199
+ const acting = C.agents.flatMap((a, i) => this.system.rt.acts(a) === 'never' ? [] : [i]);
200
+ let fair = true;
201
+ for (const agent of acting) {
202
+ const edge = scc.flatMap(u => inside(u).filter(([, j]) => this.states[u].act![j].has(agent)).map(([v]) => [u, v] as [number, number]))[0];
203
+ if (!edge) { fair = false; break; }
204
+ needed.push(edge);
205
+ }
206
+ if (!fair) continue;
207
+ }
208
+ return this.lasso(bad, members, needed);
209
+ }
210
+ }
211
+
212
+ /** Tarjan's algorithm, iteratively, on the states of `inGraph`. */
213
+ private sccs(): number[][] {
214
+ const n = this.states.length;
215
+ const index = new Int32Array(n).fill(-1);
216
+ const low = new Int32Array(n);
217
+ const onStack = new Uint8Array(n);
218
+ const stack: number[] = [];
219
+ const out: number[][] = [];
220
+ let counter = 0;
221
+ for (const root of this.states) {
222
+ if (!this.inGraph(root) || index[root.id] >= 0) continue;
223
+ const work: [number, number][] = [[root.id, 0]];
224
+ index[root.id] = low[root.id] = counter++;
225
+ stack.push(root.id); onStack[root.id] = 1;
226
+ while (work.length) {
227
+ const top = work[work.length - 1];
228
+ const [u, i] = top;
229
+ const succ = this.states[u].succ!;
230
+ if (i < succ.length) {
231
+ top[1]++;
232
+ const v = succ[i];
233
+ if (!this.inGraph(this.states[v])) continue;
234
+ if (index[v] < 0) {
235
+ index[v] = low[v] = counter++;
236
+ stack.push(v); onStack[v] = 1;
237
+ work.push([v, 0]);
238
+ } else if (onStack[v]) {
239
+ low[u] = Math.min(low[u], index[v]);
240
+ }
241
+ } else {
242
+ work.pop();
243
+ if (work.length) { const p = work[work.length - 1][0]; low[p] = Math.min(low[p], low[u]); }
244
+ if (low[u] === index[u]) {
245
+ const scc: number[] = [];
246
+ let w: number;
247
+ do { w = stack.pop()!; onStack[w] = 0; scc.push(w); } while (w !== u);
248
+ out.push(scc);
249
+ }
250
+ }
251
+ }
252
+ }
253
+ return out;
254
+ }
255
+
256
+ /** A shortest path inside `members` from `from` to `to` (at least one step when from === to). */
257
+ private route(from: number, to: number, members: Set<number>): number[] {
258
+ const prev = new Map<number, number>();
259
+ const queue = [from];
260
+ for (let c = 0; c < queue.length; c++) {
261
+ const u = queue[c];
262
+ for (const v of this.states[u].succ!) {
263
+ if (!members.has(v) || prev.has(v)) continue;
264
+ prev.set(v, u);
265
+ if (v === to) {
266
+ const path = [v];
267
+ let w = u;
268
+ while (w !== from) { path.push(w); w = prev.get(w)!; }
269
+ return path.reverse();
270
+ }
271
+ queue.push(v);
272
+ }
273
+ }
274
+ throw new Error('no route inside a strongly connected component');
275
+ }
276
+
277
+ private lasso(bad: number, members: Set<number>, needed: [number, number][]): Counterexample {
278
+ const prefix = this.pathIds(bad);
279
+ const cycle: number[] = [];
280
+ let at = bad;
281
+ for (const [u, v] of needed) {
282
+ if (u !== at) cycle.push(...this.route(at, u, members));
283
+ cycle.push(v);
284
+ at = v;
285
+ }
286
+ if (at !== bad || !cycle.length) cycle.push(...this.route(at, bad, members));
287
+ // The cycle ends at `bad`, which is the start of the loop.
288
+ const ids = [...prefix, ...cycle.slice(0, -1)];
289
+ return { ...this.counterexample('lasso', ids, cycle.length ? bad : undefined), cycleStart: prefix.length - 1 };
290
+ }
291
+
292
+ private counterexample(kind: Counterexample['kind'], ids: number[], loopTo?: number): Counterexample {
293
+ return { kind, steps: this.steps(ids, loopTo), initialIndex: this.concreteInitial.get(ids[0])!.index };
294
+ }
295
+
296
+ private pathIds(id: number): number[] {
297
+ const ids = [id];
298
+ while (this.states[ids[0]].parent !== undefined) ids.unshift(this.states[ids[0]].parent!);
299
+ return ids;
300
+ }
301
+
302
+ /** The key of the state that stores a configuration (its orbit's representative). */
303
+ private repKey(t: Successor) {
304
+ return this.symmetry.trivial ? t.key : this.symmetry.canonical(t.config, t.key).key;
305
+ }
306
+
307
+ /**
308
+ * Steps along stored states, replayed concretely: each configuration is the
309
+ * successor of the previous concrete one that the stored state represents,
310
+ * so that a path does not jump around the ring.
311
+ */
312
+ private steps(ids: number[], loopTo?: number): Step[] {
313
+ let concrete = this.concreteInitial.get(ids[0])!.config;
314
+ const steps: Step[] = [{ state: ids[0], config: concrete, verdict: this.states[ids[0]].verdict, witness: [] }];
315
+ const follow = (to: number) => {
316
+ const target = this.states[to].key;
317
+ const t = this.system.successors(concrete).find(t => this.repKey(t) === target);
318
+ if (!t) throw new Error('edge not found');
319
+ concrete = t.config;
320
+ steps.push({ state: to, config: t.config, verdict: this.states[to].verdict, witness: t.witness });
321
+ };
322
+ for (const id of ids.slice(1)) follow(id);
323
+ if (loopTo !== undefined) follow(loopTo);
324
+ return steps;
325
+ }
326
+ }
327
+
328
+ export function explore(system: System, opts: ExploreOptions = {}): Result {
329
+ return new Explorer(system, opts).run();
330
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Symmetry reduction (plan, Section 3.11). The group is derived from the
3
+ * model: rotations when every piece declares rotation equivariance, and
4
+ * reflections when, in addition, the frame model is closed under mirroring.
5
+ * Agent permutations need no declaration: anonymous agents are stored sorted.
6
+ */
7
+ import type { Experiment, Type } from '@bramas/simulant-lang/ir';
8
+ import type { Config, System } from './system';
9
+ import type { Automorphism } from './topology';
10
+ import { mapper, type Action, type V } from './values';
11
+
12
+ /** Why the derived group is what it is, for diagnostics. */
13
+ export type GroupInfo = { rotations: boolean; reflections: boolean; reasons: string[] };
14
+
15
+ export function derivedGroup(exp: Experiment): GroupInfo {
16
+ const { model, task } = exp;
17
+ const reasons: string[] = [];
18
+ if (model.symmetries === 'none') return { rotations: false, reflections: false, reasons: ['symmetries := none'] };
19
+ const pieces = [
20
+ ...model.components.map(c => ({ what: `component ${c.name}`, eq: c.equivariance })),
21
+ ...model.sensors.map(s => ({ what: `sensor ${s.name}`, eq: s.equivariance })),
22
+ ...model.components.flatMap(c => (c.sensors ?? []).map(s => ({ what: `sensor ${s.name}`, eq: s.equivariance }))),
23
+ { what: `task ${task.name}`, eq: task.equivariance },
24
+ ];
25
+ let rotations = true;
26
+ let reflections = true;
27
+ for (const p of pieces) {
28
+ if (!p.eq?.rotations) { rotations = false; reasons.push(`${p.what} is not declared rotation-equivariant`); }
29
+ if (!p.eq?.reflections) { reflections = false; reasons.push(`${p.what} is not declared reflection-equivariant`); }
30
+ }
31
+ if (model.frame.kind === 'common') {
32
+ reflections = false;
33
+ reasons.push('common chirality: a mirrored configuration behaves differently');
34
+ }
35
+ return { rotations, reflections: rotations && reflections, reasons };
36
+ }
37
+
38
+ export class Symmetry {
39
+ readonly group: Automorphism[];
40
+ readonly info: GroupInfo;
41
+ private readonly transform: (C: Config, g: Action) => Config;
42
+
43
+ constructor(readonly system: System, opts: { enabled?: boolean } = {}) {
44
+ const info = this.info = opts.enabled === false
45
+ ? { rotations: false, reflections: false, reasons: ['disabled'] }
46
+ : derivedGroup(system.exp);
47
+ const all = system.topo.automorphisms();
48
+ this.group = all.filter(g => g.reflects ? info.reflections : info.rotations);
49
+ if (!this.group.length) this.group = [all[0]]; // the identity (rotation 0)
50
+ this.transform = configMapper(system);
51
+ }
52
+
53
+ get trivial() {
54
+ return this.group.length === 1;
55
+ }
56
+
57
+ apply(C: Config, g: Action): Config {
58
+ return this.system.canonical(this.transform(C, g));
59
+ }
60
+
61
+ /** The representative of the orbit of `C` with the smallest key, and `g` with representative = g·C. */
62
+ canonical(C: Config, key = this.system.key(C)): { config: Config; key: string; g: Automorphism } {
63
+ let best = { config: C, key, g: this.group[0] };
64
+ for (let j = 1; j < this.group.length; j++) {
65
+ const g = this.group[j];
66
+ const D = this.apply(C, g);
67
+ const k = this.system.key(D);
68
+ if (k < best.key) best = { config: D, key: k, g };
69
+ }
70
+ return best;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The bounded test of an equivariance claim (plan 3.11): for each
76
+ * configuration C and group element g, the successors of g·C must be g
77
+ * applied to the successors of C, and the verdict must not change.
78
+ * Returns a description of each violation.
79
+ */
80
+ export function equivarianceViolations(system: System, configs: Config[], group: Automorphism[]): string[] {
81
+ const transform = configMapper(system);
82
+ const act = (C: Config, g: Action) => system.canonical(transform(C, g));
83
+ const keys = (cs: Config[]) => [...new Set(cs.map(C => system.key(C)))].sort();
84
+ const out: string[] = [];
85
+ for (const C of configs) {
86
+ const succ = system.successors(C).map(t => t.config);
87
+ for (const g of group) {
88
+ const D = act(C, g);
89
+ if (system.verdict(D) !== system.verdict(C)) out.push(`${g.label}: verdict of ${system.key(C)} changes`);
90
+ const expected = keys(succ.map(t => act(t, g)));
91
+ const actual = keys(system.successors(D).map(t => t.config));
92
+ if (expected.join('\n') !== actual.join('\n')) out.push(`${g.label}: successors of ${system.key(C)} are not mapped to successors`);
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /** How an automorphism acts on whole configurations, derived from the composed types. */
99
+ function configMapper(system: System): (C: Config, g: Action) => Config {
100
+ const { lay } = system;
101
+ const field = (t: Type) => mapper(t);
102
+ const agentFields = lay.agentFields.map(f => [f.name, field(f.type)] as const).filter(([, m]) => m);
103
+ const pub = field(lay.pub);
104
+ const mem = field(lay.mem);
105
+ const world = field(lay.worldType);
106
+ return (C, g) => ({
107
+ agents: C.agents.map(a => {
108
+ let f = a.f;
109
+ if (agentFields.length) {
110
+ f = { ...f };
111
+ for (const [n, m] of agentFields) f[n] = m!(a.f[n], g);
112
+ }
113
+ return { pos: g.node(a.pos), pub: pub ? pub(a.pub, g) : a.pub, mem: mem ? mem(a.mem, g) : a.mem, f };
114
+ }),
115
+ world: world ? world(C.world, g) : C.world,
116
+ });
117
+ }
118
+
119
+ export type { V };