@bramas/simulant-engine 0.0.0-stage → 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.
package/dist/values.js ADDED
@@ -0,0 +1,129 @@
1
+ const encoders = new WeakMap();
2
+ export function encoder(t) {
3
+ let e = encoders.get(t);
4
+ if (!e) {
5
+ e = makeEncoder(t);
6
+ encoders.set(t, e);
7
+ }
8
+ return e;
9
+ }
10
+ function makeEncoder(t) {
11
+ switch (t.k) {
12
+ case 'unit': return () => '';
13
+ case 'bool': return v => v ? '1' : '0';
14
+ case 'frame': return v => v ? 'm' : 'i';
15
+ case 'nat':
16
+ case 'int':
17
+ case 'node':
18
+ case 'agent': return v => String(v);
19
+ case 'move':
20
+ case 'enum':
21
+ case 'verdict': return v => v;
22
+ case 'string': return v => JSON.stringify(v);
23
+ case 'option': {
24
+ const inner = encoder(t.of);
25
+ return v => v === null ? '-' : `+${inner(v)}`;
26
+ }
27
+ case 'list':
28
+ case 'multiset': {
29
+ const inner = encoder(t.of);
30
+ return v => {
31
+ let s = '[';
32
+ for (let i = 0; i < v.length; i++)
33
+ s += (i ? ',' : '') + inner(v[i]);
34
+ return s + ']';
35
+ };
36
+ }
37
+ case 'record': {
38
+ const fields = t.fields.map(f => [f.name, encoder(f.type)]);
39
+ return v => {
40
+ let s = '{';
41
+ for (let i = 0; i < fields.length; i++)
42
+ s += (i ? ',' : '') + fields[i][1](v[fields[i][0]]);
43
+ return s + '}';
44
+ };
45
+ }
46
+ default:
47
+ return () => { throw new Error(`values of type ${t.k} cannot be encoded`); };
48
+ }
49
+ }
50
+ function isAtomic(t) {
51
+ return !['option', 'list', 'multiset', 'record'].includes(t.k);
52
+ }
53
+ export function equality(t) {
54
+ if (isAtomic(t))
55
+ return (a, b) => a === b;
56
+ if (t.k === 'option' && isAtomic(t.of))
57
+ return (a, b) => a === b;
58
+ const enc = encoder(t);
59
+ return (a, b) => a === b || enc(a) === enc(b);
60
+ }
61
+ /** Sorts the elements of a multiset into canonical order. */
62
+ export function canonicalSort(elem) {
63
+ if (elem.k === 'nat' || elem.k === 'int' || elem.k === 'node' || elem.k === 'agent') {
64
+ return xs => xs.slice().sort((a, b) => a - b);
65
+ }
66
+ const enc = encoder(elem);
67
+ return xs => xs.map(x => [enc(x), x]).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0).map(p => p[1]);
68
+ }
69
+ /**
70
+ * `show`, or, with `compact` (explanations of steps, for reading), lists of booleans as one
71
+ * word: `[XX.X.]` for `[true, true, false, true, false]`.
72
+ */
73
+ export function show(t, compact = false) {
74
+ switch (t.k) {
75
+ case 'unit': return () => '()';
76
+ case 'agent': return v => `a${v}`;
77
+ case 'node': return v => `u${v}`;
78
+ case 'frame': return v => v ? 'mirrored' : 'as is';
79
+ case 'string': return v => v;
80
+ case 'option': {
81
+ const inner = show(t.of, compact);
82
+ return v => v === null ? 'none' : `some ${inner(v)}`;
83
+ }
84
+ case 'list':
85
+ case 'multiset': {
86
+ if (compact && t.of.k === 'bool')
87
+ return v => `[${v.map((b) => b ? 'X' : '.').join('')}]`;
88
+ const inner = show(t.of, compact);
89
+ return v => `[${v.map(inner).join(', ')}]`;
90
+ }
91
+ case 'record': {
92
+ const fields = t.fields.map(f => [f.name, show(f.type, compact)]);
93
+ return v => `{ ${fields.map(([n, s]) => `${n} := ${s(v[n])}`).join(', ')} }`;
94
+ }
95
+ default: return v => String(v);
96
+ }
97
+ }
98
+ /** The action on values of type `t`, or `undefined` when it is the identity. */
99
+ export function mapper(t) {
100
+ switch (t.k) {
101
+ case 'node': return (v, g) => g.node(v);
102
+ case 'frame': return (v, g) => g.frame(v);
103
+ case 'option': {
104
+ const inner = mapper(t.of);
105
+ return inner && ((v, g) => v === null ? null : inner(v, g));
106
+ }
107
+ case 'list': {
108
+ const inner = mapper(t.of);
109
+ return inner && ((v, g) => v.map((x) => inner(x, g)));
110
+ }
111
+ case 'multiset': {
112
+ const inner = mapper(t.of);
113
+ const sort = canonicalSort(t.of);
114
+ return inner && ((v, g) => sort(v.map((x) => inner(x, g))));
115
+ }
116
+ case 'record': {
117
+ const fields = t.fields.map(f => [f.name, mapper(f.type)]).filter(([, m]) => m);
118
+ if (!fields.length)
119
+ return undefined;
120
+ return (v, g) => {
121
+ const out = { ...v };
122
+ for (const [n, m] of fields)
123
+ out[n] = m(v[n], g);
124
+ return out;
125
+ };
126
+ }
127
+ default: return undefined;
128
+ }
129
+ }
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@bramas/simulant-engine",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "license": "CECILL-B",
6
+ "type": "module",
7
+ "dependencies": {
8
+ "@bramas/simulant-lang": "0.1.0"
9
+ },
10
+ "exports": {
11
+ "./*": {
12
+ "types": "./src/*.ts",
13
+ "default": "./dist/*.js"
14
+ }
15
+ },
16
+ "publishConfig": {
17
+ "registry": "https://registry.npmjs.org",
18
+ "access": "public"
19
+ },
20
+ "files": [
21
+ "src",
22
+ "dist",
23
+ "LICENCE.txt"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/Bramas/simulant.git",
28
+ "directory": "packages/engine"
29
+ }
6
30
  }
package/src/check.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Checks of experiments, for the CLI and the UI (plan, Phase 6).
3
+ *
4
+ * `checkSettings` explores many settings at once: settings with the same ring
5
+ * size share one state graph. This is sound because parameters reach only
6
+ * the initialisation and the topology size (the checker enforces it), so the
7
+ * transition relation is the same for every setting of a size; a
8
+ * counterexample starts from the initial configuration of one setting, which
9
+ * is reported with it. Initial configurations equal up to the symmetry group
10
+ * merge, which is the enumeration "up to the group" of plan Section 3.11.
11
+ *
12
+ * A counterexample is also given as a path of the execution adapter
13
+ * (`packages/simulator-ui/src/sim/execution.ts`): the first index chooses an
14
+ * initial configuration of the setting, each further index a successor, in
15
+ * the order of `System.successors`. With the setting, it replays in the UI.
16
+ */
17
+ import type { Experiment } from '@bramas/simulant-lang/ir';
18
+ import { Explorer, type AsyncOptions, type Counterexample, type Result } from './explorer';
19
+ import { System, type Config, type Successor, type SystemOptions, type Verdict } from './system';
20
+ import type { V } from './values';
21
+
22
+ export type CheckLimits = {
23
+ maxDepth?: number;
24
+ maxStates?: number;
25
+ symmetry?: boolean;
26
+ /** Options of the systems built (native steps). */
27
+ system?: SystemOptions;
28
+ };
29
+
30
+ export type Trace = {
31
+ kind: Counterexample['kind'];
32
+ settings: Record<string, V>;
33
+ /** A path of the execution adapter, from the setting's initial configurations. */
34
+ path: number[];
35
+ /**
36
+ * For a lasso: the index (in `path` and `steps`) of the configuration the last step returns to,
37
+ * up to the symmetry group; the steps after it repeat forever.
38
+ */
39
+ cycleStart?: number;
40
+ steps: { config: Config; verdict: Verdict; label: string }[];
41
+ };
42
+
43
+ export type CheckResult = {
44
+ status: Result['status'];
45
+ reason: string;
46
+ stats: Result['stats'];
47
+ trace?: Trace;
48
+ };
49
+
50
+ export type CheckAllResult = CheckResult & {
51
+ /** Settings checked, and state graphs explored (one per ring size when shared). */
52
+ settings: number;
53
+ graphs: number;
54
+ };
55
+
56
+ const run = (explorer: Explorer, async?: AsyncOptions) => async ? explorer.runAsync(async) : Promise.resolve(explorer.run());
57
+
58
+ /** The experiment's own limits (`limits := { depth, states }`), unless the caller gives others. */
59
+ export function withExperimentLimits<L extends CheckLimits>(exp: Experiment, limits: L): L {
60
+ return { ...limits, maxDepth: limits.maxDepth ?? exp.limits?.depth, maxStates: limits.maxStates ?? exp.limits?.states };
61
+ }
62
+
63
+ /** The adapter path of a counterexample whose first configuration is `initial[initialIndex]`. */
64
+ export function tracePath(system: System, cx: Counterexample, settings: Record<string, V>, initialIndex: number): Trace {
65
+ const path = [initialIndex];
66
+ for (let i = 1; i < cx.steps.length; i++) {
67
+ const key = system.key(cx.steps[i].config);
68
+ const index = system.successors(cx.steps[i - 1].config).findIndex(t => t.key === key);
69
+ if (index < 0) throw new Error('tracePath: a step of the counterexample is not a successor');
70
+ path.push(index);
71
+ }
72
+ return {
73
+ kind: cx.kind, settings, path, cycleStart: cx.cycleStart,
74
+ steps: cx.steps.map(s => ({ config: s.config, verdict: s.verdict, label: s.witness.map(c => c.label).join('; ') })),
75
+ };
76
+ }
77
+
78
+ /** Checks one setting. */
79
+ export async function checkSetting(exp: Experiment, settings: Record<string, V>, limits: CheckLimits = {}, async?: AsyncOptions): Promise<CheckResult> {
80
+ limits = withExperimentLimits(exp, limits);
81
+ const system = new System(exp, settings, limits.system);
82
+ const r = await run(new Explorer(system, limits), async);
83
+ return { ...r, trace: r.counterexample && tracePath(system, r.counterexample, settings, r.counterexample.initialIndex) };
84
+ }
85
+
86
+ /**
87
+ * Checks every setting, sharing a state graph between settings of the same ring size
88
+ * (`shared: false` explores each setting alone). Stops at the first failure.
89
+ */
90
+ export async function checkSettings(
91
+ exp: Experiment, settings: Iterable<Record<string, V>>,
92
+ limits: CheckLimits & { shared?: boolean; maxConfigs?: number } = {}, async?: AsyncOptions,
93
+ onProgress?: (done: number, total: number) => void,
94
+ ): Promise<CheckAllResult> {
95
+ limits = withExperimentLimits(exp, limits);
96
+ const all: Record<string, V>[] = [];
97
+ const maxConfigs = limits.maxConfigs ?? Infinity;
98
+ if (maxConfigs > 0) {
99
+ for (const setting of settings) {
100
+ all.push(setting);
101
+ if (all.length >= maxConfigs) break;
102
+ }
103
+ }
104
+ const stats: Result['stats'] = { states: 0, expanded: 0, edges: 0, maxDepth: 0, group: 0 };
105
+ const add = (s: Result['stats']) => {
106
+ stats.states += s.states; stats.expanded += s.expanded; stats.edges += s.edges;
107
+ stats.maxDepth = Math.max(stats.maxDepth, s.maxDepth); stats.group = Math.max(stats.group, s.group);
108
+ };
109
+ let incomplete: string | undefined;
110
+ let graphs = 0;
111
+ const groups: Record<string, V>[][] = [];
112
+ if (limits.shared === false) {
113
+ for (const s of all) groups.push([s]);
114
+ } else if (all.length) {
115
+ const sizer = new System(exp, all[0], limits.system);
116
+ const bySize = new Map<number, Record<string, V>[]>();
117
+ for (const s of all) {
118
+ const size = sizer.sizeWith(s);
119
+ const group = bySize.get(size);
120
+ if (group) group.push(s);
121
+ else bySize.set(size, [s]);
122
+ }
123
+ for (const group of bySize.values()) groups.push(group);
124
+ }
125
+ let done = 0;
126
+ for (const group of groups) {
127
+ const system = new System(exp, group[0], limits.system);
128
+ // Each initial configuration, with the setting it comes from and its index there.
129
+ const initial: Successor[] = [];
130
+ const origin: { settings: Record<string, V>; index: number }[] = [];
131
+ for (const s of group) {
132
+ system.initialWith(s).forEach((t, index) => { initial.push(t); origin.push({ settings: s, index }); });
133
+ }
134
+ const r = await run(new Explorer(system, { ...limits, initial }), async);
135
+ graphs++;
136
+ add(r.stats);
137
+ done += group.length;
138
+ onProgress?.(done, all.length);
139
+ if (r.status === 'failure') {
140
+ const at = origin[r.counterexample!.initialIndex];
141
+ return { status: 'failure', reason: r.reason, stats, settings: all.length, graphs, trace: tracePath(system, r.counterexample!, at.settings, at.index) };
142
+ }
143
+ if (r.status === 'incomplete') incomplete ??= group.length > 1 ? `${r.reason} (settings of size ${system.topo.size})` : `${r.reason} (${JSON.stringify(group[0])})`;
144
+ }
145
+ if (incomplete) return { status: 'incomplete', reason: incomplete, stats, settings: all.length, graphs };
146
+ return { status: 'success', reason: 'every admissible execution of every setting satisfies the task', stats, settings: all.length, graphs };
147
+ }
148
+
149
+ /**
150
+ * Follows the first successor at each step until the verdict decides the goal on this single
151
+ * execution: a failure, a success under `eventually`, or a repeated configuration (the execution
152
+ * is then a lasso, judged by the goal on its cycle). For `simulant check`, a quick smoke test.
153
+ */
154
+ export function checkFirstBranch(exp: Experiment, settings: Record<string, V>, limits: CheckLimits = {}): CheckResult {
155
+ limits = withExperimentLimits(exp, limits);
156
+ const system = new System(exp, settings, limits.system);
157
+ const goal = exp.task.goal;
158
+ const first = system.initial()[0];
159
+ const steps = [{ config: first.config, verdict: system.verdict(first.config), label: '' }];
160
+ const path = [0];
161
+ const seen = new Map<string, number>([[first.key, 0]]);
162
+ const maxDepth = limits.maxDepth ?? Infinity;
163
+ const stats = () => ({ states: steps.length, expanded: steps.length - 1, edges: steps.length - 1, maxDepth: steps.length - 1, group: 1 });
164
+ const trace = (kind: Trace['kind'], cycleStart?: number): Trace => ({ kind, settings, path: [...path], cycleStart, steps: [...steps] });
165
+ for (;;) {
166
+ const last = steps[steps.length - 1];
167
+ if (last.verdict === 'failure') return { status: 'failure', reason: 'the first branch reaches a failing configuration', stats: stats(), trace: trace('failure') };
168
+ if (goal === 'eventually' && last.verdict === 'success') return { status: 'success', reason: 'the first branch reaches a success', stats: stats() };
169
+ if (steps.length - 1 >= maxDepth) return { status: 'incomplete', reason: `depth limit ${maxDepth} reached`, stats: stats() };
170
+ const next = system.successors(last.config)[0];
171
+ if (!next) return { status: 'incomplete', reason: 'the first branch ends in a configuration without successor', stats: stats() };
172
+ path.push(0);
173
+ steps.push({ config: next.config, verdict: system.verdict(next.config), label: next.witness.map(c => c.label).join('; ') });
174
+ const again = seen.get(next.key);
175
+ if (again === undefined) { seen.set(next.key, steps.length - 1); continue; }
176
+ // A lasso: the steps from `again` repeat forever.
177
+ const cycle = steps.slice(again, -1);
178
+ const ok = goal === 'safety' || (goal === 'eventually' ? false : cycle.every(s => s.verdict === 'success'));
179
+ return ok
180
+ ? { status: 'success', reason: 'the first branch repeats a cycle that satisfies the goal', stats: stats() }
181
+ : { status: 'failure', reason: `the first branch repeats a cycle that never satisfies "${goal}"`, stats: stats(), trace: trace('lasso', again) };
182
+ }
183
+ }
184
+
185
+ export type StateGraph = {
186
+ states: { id: number; config: Config; verdict: Verdict; depth: number }[];
187
+ edges: { from: number; to: number; label: string }[];
188
+ complete: boolean;
189
+ };
190
+
191
+ /** The explored state graph of a setting (concrete states, no symmetry reduction), for drawing. */
192
+ export function stateGraph(exp: Experiment, settings: Record<string, V>, limits: CheckLimits = {}): StateGraph {
193
+ limits = withExperimentLimits(exp, limits);
194
+ const system = new System(exp, settings, limits.system);
195
+ const explorer = new Explorer(system, { ...limits, symmetry: false });
196
+ const r = explorer.run();
197
+ const ids = new Map(explorer.states.map(s => [s.key, s.id]));
198
+ const edges: StateGraph['edges'] = [];
199
+ for (const s of explorer.states) {
200
+ if (!s.succ) continue;
201
+ for (const t of system.successors(s.config)) {
202
+ const to = ids.get(t.key);
203
+ if (to !== undefined) edges.push({ from: s.id, to, label: t.witness.map(c => c.label).join('; ') });
204
+ }
205
+ }
206
+ return {
207
+ states: explorer.states.map(s => ({ id: s.id, config: s.config, verdict: s.verdict, depth: s.depth })),
208
+ edges, complete: r.status !== 'incomplete',
209
+ };
210
+ }