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