tla-ts 0.0.2 → 0.0.3-dev.a50fc89

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,184 @@
1
+ // tlapm's E_tuply_declarations: declarations such as <<x, y>> \in S become declarations of names.
2
+
3
+ import type { Bound, BoundDomain, Defn, Expr } from "./obligation-expression";
4
+
5
+ export type TuplyBound =
6
+ | { kind: "Name"; name: string; domain: BoundDomain }
7
+ | { kind: "Tuple"; names: string[]; domain: Expr };
8
+
9
+ const getOpaque = (name: string): Expr => ({ kind: "Opaque", name });
10
+
11
+ const getNamesTuple = (names: string[]): Expr => ({
12
+ kind: "Tuple",
13
+ elements: names.map(getOpaque),
14
+ });
15
+
16
+ const getEquality = (left: Expr, right: Expr): Expr => ({
17
+ kind: "Apply",
18
+ operator: { kind: "Internal", builtin: "Eq" },
19
+ args: [left, right],
20
+ });
21
+
22
+ export const getUnbounded = (names: string[]): Bound[] =>
23
+ names.map((name) => ({ name, level: "Constant", domain: { kind: "NoDomain" } }));
24
+
25
+ const getFactors = (domain: Expr | undefined, names: string[]) =>
26
+ domain?.kind === "Product" && domain.factors.length === names.length ? domain.factors : undefined;
27
+
28
+ // Each name is the matching item of the tuple: x == t[1], y == t[2].
29
+ const getItemDefinitions = (tuple: string, names: string[]): Defn[] =>
30
+ names.map((name, index) => ({
31
+ kind: "Operator",
32
+ name,
33
+ body: {
34
+ kind: "FcnApp",
35
+ function: getOpaque(tuple),
36
+ args: [{ kind: "Num", integer: String(index + 1), fraction: "" }],
37
+ },
38
+ }));
39
+
40
+ const getFactorBounds = (names: string[], factors: Expr[]): Bound[] =>
41
+ names.map((name, index) => ({
42
+ name,
43
+ level: "Constant",
44
+ domain: { kind: "Domain", expression: factors[index] },
45
+ }));
46
+
47
+ const getNamedBound = (bound: Extract<TuplyBound, { kind: "Name" }>): Bound => ({
48
+ name: bound.name,
49
+ level: "Constant",
50
+ domain: bound.domain,
51
+ });
52
+
53
+ // \E <<x, y>> \in A \X B: P becomes \E x \in A, y \in B: P, and \E <<x, y>> \in S: P becomes
54
+ // \E x, y: <<x, y>> \in S /\ P. Each domain quantifies separately, the last one innermost.
55
+ export const expandQuant = (
56
+ quantifier: "Forall" | "Exists",
57
+ bounds: TuplyBound[],
58
+ body: Expr,
59
+ ): Expr => {
60
+ const groups: TuplyBound[][] = [];
61
+ bounds.forEach((bound) => {
62
+ if (bound.kind === "Name" && bound.domain.kind === "Ditto") groups.at(-1)?.push(bound);
63
+ else groups.push([bound]);
64
+ });
65
+ return groups.reduceRight((inner: Expr, group): Expr => {
66
+ const first = group[0];
67
+ if (first.kind === "Name") {
68
+ return {
69
+ kind: "Quant",
70
+ quantifier,
71
+ bounds: group.flatMap((bound) => (bound.kind === "Name" ? [getNamedBound(bound)] : [])),
72
+ body: inner,
73
+ location: inner.location,
74
+ };
75
+ }
76
+ const factors = getFactors(first.domain, first.names);
77
+ if (factors) {
78
+ return {
79
+ kind: "Quant",
80
+ quantifier,
81
+ bounds: getFactorBounds(first.names, factors),
82
+ body: inner,
83
+ location: inner.location,
84
+ };
85
+ }
86
+ const membership: Expr = {
87
+ kind: "Apply",
88
+ operator: { kind: "Internal", builtin: quantifier === "Exists" ? "Mem" : "Notmem" },
89
+ args: [getNamesTuple(first.names), first.domain],
90
+ };
91
+ return {
92
+ kind: "Quant",
93
+ quantifier,
94
+ bounds: getUnbounded(first.names),
95
+ body: {
96
+ kind: "List",
97
+ bullet: quantifier === "Exists" ? "And" : "Or",
98
+ items: [membership, inner],
99
+ },
100
+ location: inner.location,
101
+ };
102
+ }, body);
103
+ };
104
+
105
+ // CHOOSE <<x, y>> \in A \X B: P binds t \in A \X B with x == t[1], y == t[2], and any other
106
+ // domain binds t with \E x, y: t = <<x, y>> /\ P.
107
+ const getChooseBody = (
108
+ name: string,
109
+ names: string[],
110
+ domain: Expr | undefined,
111
+ body: Expr,
112
+ ): Expr =>
113
+ getFactors(domain, names)
114
+ ? { kind: "Let", definitions: getItemDefinitions(name, names), body }
115
+ : {
116
+ kind: "Quant",
117
+ quantifier: "Exists",
118
+ bounds: getUnbounded(names),
119
+ body: {
120
+ kind: "List",
121
+ bullet: "And",
122
+ items: [getEquality(getOpaque(name), getNamesTuple(names)), body],
123
+ },
124
+ };
125
+
126
+ export const expandChoose = (names: string[], domain: Expr | undefined, body: Expr): Expr => {
127
+ const name = `choose#${names[0]}`;
128
+ return { kind: "Choose", name, domain, body: getChooseBody(name, names, domain, body) };
129
+ };
130
+
131
+ export const expandSetSt = (names: string[], domain: Expr, body: Expr): Expr => {
132
+ const name = `setst#${names[0]}`;
133
+ return { kind: "SetSt", name, domain, body: getChooseBody(name, names, domain, body) };
134
+ };
135
+
136
+ // A set or function constructor binds each tuple as fcn#x, with its names defined in a LET around
137
+ // the value. A set constructor over a matching product binds the names to its factors instead.
138
+ const expandConstructorBounds = (bounds: TuplyBound[], value: Expr, isSetOf: boolean) => {
139
+ const expanded = bounds.map((bound): { bounds: Bound[]; definitions: Defn[] } => {
140
+ if (bound.kind === "Name") return { bounds: [getNamedBound(bound)], definitions: [] };
141
+ const factors = getFactors(bound.domain, bound.names);
142
+ if (factors && isSetOf) {
143
+ return { bounds: getFactorBounds(bound.names, factors), definitions: [] };
144
+ }
145
+ const name = `fcn#${bound.names[0]}`;
146
+ const fcnBound: Bound = {
147
+ name,
148
+ level: "Constant",
149
+ domain: { kind: "Domain", expression: bound.domain },
150
+ };
151
+ if (factors) return { bounds: [fcnBound], definitions: getItemDefinitions(name, bound.names) };
152
+ const choice = `choose#${name}`;
153
+ return {
154
+ bounds: [fcnBound],
155
+ definitions: [
156
+ {
157
+ kind: "Operator",
158
+ name: choice,
159
+ body: expandChoose(
160
+ bound.names,
161
+ undefined,
162
+ getEquality(getOpaque(name), getNamesTuple(bound.names)),
163
+ ),
164
+ },
165
+ ...getItemDefinitions(choice, bound.names),
166
+ ],
167
+ };
168
+ });
169
+ const definitions = expanded.flatMap((item) => item.definitions);
170
+ return {
171
+ bounds: expanded.flatMap((item) => item.bounds),
172
+ value: definitions.length === 0 ? value : { kind: "Let" as const, definitions, body: value },
173
+ };
174
+ };
175
+
176
+ export const expandSetOf = (element: Expr, bounds: TuplyBound[]): Expr => {
177
+ const expanded = expandConstructorBounds(bounds, element, true);
178
+ return { kind: "SetOf", element: expanded.value, bounds: expanded.bounds };
179
+ };
180
+
181
+ export const expandFcn = (bounds: TuplyBound[], body: Expr): Expr => {
182
+ const expanded = expandConstructorBounds(bounds, body, false);
183
+ return { kind: "Fcn", bounds: expanded.bounds, body: expanded.value };
184
+ };
@@ -0,0 +1,316 @@
1
+ // Generates the proof obligations tlapm prints for a module, in tlapm's order and layout.
2
+
3
+ import { TlaProofError, TlaSyntaxError, type SyntaxFailure } from "./errors";
4
+ import { createAnonymizer } from "./obligation-anon";
5
+ import {
6
+ getAtPrefix,
7
+ getErrorNote,
8
+ getLocusPosition,
9
+ getLocusPrefix,
10
+ type Locus,
11
+ type ObligationWarning,
12
+ } from "./obligation-expression";
13
+ import { createGenerator, type LineRange } from "./obligation-generate";
14
+ import {
15
+ compareNames,
16
+ expandInstance,
17
+ flatten,
18
+ generate,
19
+ getDependencies,
20
+ normalize,
21
+ STANDARD_MODULES,
22
+ type ModuleSummary,
23
+ type Mule,
24
+ type TheoremSummary,
25
+ type Pipeline,
26
+ } from "./obligation-module";
27
+ import { formatObligation } from "./obligation-printer";
28
+ import type { Counter } from "./obligation-proof";
29
+ import { createSource } from "./obligation-source";
30
+ import { tokenize } from "./lexer";
31
+ import { parseTlapmModule } from "./parser";
32
+ import type { Module } from "./ast";
33
+
34
+ export interface ObligationSource {
35
+ file: string;
36
+ source: string;
37
+ }
38
+
39
+ export interface PrintedObligation {
40
+ id: number;
41
+ location: string;
42
+ text: string;
43
+ }
44
+
45
+ export interface ModuleObligations {
46
+ name: string;
47
+ location: Locus | undefined;
48
+ obligations: PrintedObligation[];
49
+ summary: ModuleSummary;
50
+ }
51
+
52
+ export const WHOLE_MODULE: LineRange = { start: 0, end: Infinity };
53
+
54
+ interface ObligationOptions {
55
+ lines?: LineRange;
56
+ onWarning?: (warning: ObligationWarning) => void;
57
+ }
58
+
59
+ const MODULE_HEADER = /-{4,} *MODULE/;
60
+
61
+ const END_FAILURE = "Unexpected EOF";
62
+
63
+ const formatLocus = (locus: Locus | undefined) =>
64
+ locus ? `${locus.startLine}:${locus.startColumn}:${locus.endLine}:${locus.endColumn}` : "";
65
+
66
+ const findTab = (source: string) => {
67
+ const tokens = tokenize(source);
68
+ const lines = source.split("\n");
69
+ const endLine = tokens.at(-1)?.line ?? lines.length;
70
+ const moduleEnd =
71
+ lines.slice(0, endLine - 1).reduce((total, line) => total + line.length + 1, 0) +
72
+ lines[endLine - 1].indexOf("====");
73
+ const offset = source.indexOf("\t", source.search(MODULE_HEADER));
74
+ if (offset === -1 || offset >= moduleEnd) return undefined;
75
+ const line = source.slice(0, offset).split("\n").length;
76
+ const column = offset - source.lastIndexOf("\n", offset - 1);
77
+ const last = tokens.findLast(
78
+ (token) =>
79
+ token.kind !== "End" && (token.line < line || (token.line === line && token.column < column)),
80
+ );
81
+ const end: SyntaxFailure = {
82
+ reason: END_FAILURE,
83
+ line: last?.line ?? line,
84
+ column: last ? last.endColumn + 1 : column,
85
+ isEnd: true,
86
+ };
87
+ return { offset, line, column, end };
88
+ };
89
+
90
+ const readModule = <Value>(read: () => Value): Value | SyntaxFailure => {
91
+ try {
92
+ return read();
93
+ } catch (error) {
94
+ if (!(error instanceof TlaSyntaxError) || !error.failure) throw error;
95
+ return error.failure;
96
+ }
97
+ };
98
+
99
+ const isSyntaxFailure = (value: unknown): value is SyntaxFailure =>
100
+ typeof value === "object" && value !== null && "reason" in value;
101
+
102
+ const parseSource = (source: string): Module | SyntaxFailure[] => {
103
+ const tab = readModule(() => findTab(source));
104
+ if (isSyntaxFailure(tab)) return [tab];
105
+ const parsed = readModule(() => parseTlapmModule(tab ? source.slice(0, tab.offset) : source));
106
+ if (tab && (!isSyntaxFailure(parsed) || parsed.isEnd)) {
107
+ return [
108
+ {
109
+ reason: "Unexpected TAB character. TLAPS does not handle TAB characters in source files.",
110
+ line: tab.line,
111
+ column: tab.column,
112
+ isEnd: false,
113
+ },
114
+ isSyntaxFailure(parsed) && parsed.reason === END_FAILURE ? parsed : tab.end,
115
+ ];
116
+ }
117
+ return isSyntaxFailure(parsed) ? [parsed] : parsed;
118
+ };
119
+
120
+ // Module.Save.parse_file warns when a module's name differs from its file's, then renames it.
121
+ const loadMule = (
122
+ input: ObligationSource,
123
+ counter: Counter,
124
+ isMain: boolean,
125
+ onWarning: (warning: ObligationWarning) => void,
126
+ citation: string,
127
+ ): Mule => {
128
+ if (!MODULE_HEADER.test(input.source)) throw new TlaProofError("No tokens in file");
129
+ const parsed = parseSource(input.source);
130
+ if (Array.isArray(parsed)) {
131
+ parsed.forEach((failure) => {
132
+ const text = `File ${JSON.stringify(input.file)}, line ${failure.line}, character ${failure.column}\n${failure.reason}`;
133
+ onWarning({ text, channel: "stderr" });
134
+ onWarning({ text: `${text}\n`, channel: "toolboxOnly" });
135
+ });
136
+ const message = `Could not parse ${JSON.stringify(input.file)} successfully.`;
137
+ onWarning({ text: `${citation}Error: ${message}`, channel: "stderr" });
138
+ throw new TlaProofError(message, { failure: "Module.Parser.parse_file" });
139
+ }
140
+ const mule = createSource(input.file, input.source, counter, isMain).convertModule(parsed);
141
+ const fileName = input.file.slice(input.file.lastIndexOf("/") + 1);
142
+ const name = fileName.replace(/\.tla$/, "");
143
+ if (mule.name === name) return mule;
144
+ onWarning({
145
+ text: `${getLocusPrefix(mule.location)}Warning: module name ${JSON.stringify(mule.name)} does not match file name ${JSON.stringify(fileName)}.`,
146
+ channel: "toolbox",
147
+ });
148
+ return { ...mule, name };
149
+ };
150
+
151
+ // Module.Dep.schedule: dependencies first, visiting modules and their dependencies by name.
152
+ const schedule = (modules: Map<string, Mule>) => {
153
+ const dependencies = new Map(
154
+ [...modules].map(([name, module]) => [name, getDependencies(module).dependencies]),
155
+ );
156
+ const seen = new Set<string>();
157
+ const order: Mule[] = [];
158
+ const visit = (name: string) => {
159
+ if (seen.has(name)) return;
160
+ seen.add(name);
161
+ const module = modules.get(name);
162
+ if (!module) throw new TlaProofError(`Module "${name}" not found`);
163
+ (dependencies.get(name) ?? []).forEach(visit);
164
+ order.push(module);
165
+ };
166
+ [...modules.keys()].sort(compareNames).forEach(visit);
167
+ return order;
168
+ };
169
+
170
+ const formatProofs = (kind: "missing" | "omitted", loci: Locus[], before: number) =>
171
+ loci.length === 0
172
+ ? []
173
+ : [
174
+ ` ${kind}_proofs_count = ${loci.length}`,
175
+ ...loci.map(
176
+ (locus, index) => ` ${kind}_proof_${before + index + 1} at ${getLocusPosition(locus)}`,
177
+ ),
178
+ ];
179
+
180
+ const formatTheoremSummary = (theorem: TheoremSummary, missing: number, omitted: number) => {
181
+ const location = theorem.location;
182
+ const start = location
183
+ ? getLocusPosition({
184
+ ...location,
185
+ endLine: location.startLine,
186
+ endColumn: location.startColumn,
187
+ })
188
+ : "<unknown location>";
189
+ return [
190
+ ` ---- incomplete proof of theorem at ${start} ----`,
191
+ ...formatProofs("missing", theorem.absent, missing),
192
+ ...formatProofs("omitted", theorem.omitted, omitted),
193
+ " ====",
194
+ ];
195
+ };
196
+
197
+ export const formatSummary = (result: ModuleObligations) => {
198
+ const summary = result.summary;
199
+ const theorems = summary.theorems.reduce(
200
+ (state, theorem) =>
201
+ theorem.absent.length + theorem.omitted.length === 0
202
+ ? state
203
+ : {
204
+ lines: [...state.lines, ...formatTheoremSummary(theorem, state.missing, state.omitted)],
205
+ missing: state.missing + theorem.absent.length,
206
+ omitted: state.omitted + theorem.omitted.length,
207
+ },
208
+ { lines: new Array<string>(), missing: 0, omitted: 0 },
209
+ );
210
+ return [
211
+ `---- summary of module ${JSON.stringify(result.name)} ----`,
212
+ ` obligations_count = ${summary.total}`,
213
+ ...(summary.absent.length > 0 ? [` missing_proofs_count = ${summary.absent.length}`] : []),
214
+ ...(summary.omitted.length > 0 ? [` omitted_proofs_count = ${summary.omitted.length}`] : []),
215
+ ...theorems.lines,
216
+ "====",
217
+ ].join("\n");
218
+ };
219
+
220
+ // Each main module is one file given to tlapm. They load into one context and report in schedule
221
+ // order, each numbering its obligations from 1.
222
+ export const getObligations = (
223
+ mains: ObligationSource[],
224
+ loadModule: (name: string) => ObligationSource | undefined,
225
+ options: ObligationOptions = {},
226
+ ): ModuleObligations[] => {
227
+ const lines = options.lines ?? WHOLE_MODULE;
228
+ const onWarning = options.onWarning ?? (() => {});
229
+ const counter = { value: 0 };
230
+ const modules = new Map<string, Mule>(STANDARD_MODULES);
231
+ mains.forEach((main) => {
232
+ const module = {
233
+ ...loadMule(main, counter, true, onWarning, 'File "<unknown>":\n'),
234
+ isImportant: true,
235
+ };
236
+ modules.set(module.name, module);
237
+ });
238
+ // Module.Save.complete_load: breadth first from every known module, by name.
239
+ const queue = [...modules.values()].sort((left, right) => compareNames(left.name, right.name));
240
+ // A missing module is fine only if some loaded module declares it as a submodule.
241
+ const requested = new Map<string, Locus | undefined>();
242
+ const found = new Set<string>();
243
+ for (let index = 0; index < queue.length; index++) {
244
+ const result = getDependencies(queue[index]);
245
+ result.submodules.forEach((submodule, name) => {
246
+ found.add(name);
247
+ if (!modules.has(name)) modules.set(name, submodule);
248
+ });
249
+ // A file named like a standard module replaces it, and is read again for each module citing it.
250
+ for (const name of result.dependencies) {
251
+ if (modules.has(name) && !STANDARD_MODULES.has(name)) continue;
252
+ const input = loadModule(name);
253
+ if (!input) {
254
+ if (!modules.has(name) && !requested.has(name))
255
+ requested.set(name, result.locations.get(name));
256
+ continue;
257
+ }
258
+ const loaded = loadMule(
259
+ input,
260
+ counter,
261
+ false,
262
+ onWarning,
263
+ getAtPrefix(result.locations.get(name)),
264
+ );
265
+ modules.set(name, loaded);
266
+ queue.push(loaded);
267
+ }
268
+ }
269
+ const missing = [...requested.keys()].filter((name) => !found.has(name)).sort(compareNames);
270
+ missing.forEach((name) =>
271
+ onWarning(getErrorNote(requested.get(name), `Unknown module ${JSON.stringify(name)}`)),
272
+ );
273
+ if (missing.length > 0)
274
+ throw new TlaProofError(`Unknown module ${JSON.stringify(missing[0])}`, {
275
+ failure: "Module.Parser.load_module",
276
+ });
277
+ const pipeline: Pipeline = {
278
+ anonymizer: createAnonymizer(
279
+ counter,
280
+ (scope, definition) => expandInstance(pipeline, scope, definition),
281
+ onWarning,
282
+ ),
283
+ generator: createGenerator(counter, new Set(mains.map((main) => main.file)), lines, onWarning),
284
+ salts: { value: 0 },
285
+ latestModules: new Map(),
286
+ onWarning,
287
+ };
288
+ const order = schedule(modules).map((module) =>
289
+ module.isStandard ? module : flatten(modules, module, new Set()),
290
+ );
291
+ // Instances see each module as flattened, and as normalized once its turn has come.
292
+ const normalized = new Map(order.map((module) => [module.name, module]));
293
+ return order.flatMap((module): ModuleObligations[] => {
294
+ if (module.isStandard) return [];
295
+ const result = normalize(pipeline, normalized, undefined, module);
296
+ if (!module.isImportant) return [];
297
+ const generated = generate(pipeline, [], result);
298
+ // tlapm numbers the obligations, then drops the omitted ones and those outside the lines.
299
+ const obligations = generated.obligations.flatMap((obligation, index): PrintedObligation[] => {
300
+ const location = obligation.location;
301
+ if (
302
+ obligation.kind.kind === "Omitted" ||
303
+ (location && (location.startLine < lines.start || location.endLine > lines.end))
304
+ )
305
+ return [];
306
+ const text =
307
+ obligation.kind.kind === "Error"
308
+ ? obligation.kind.message
309
+ : `${formatObligation(obligation.sequent)}\n`;
310
+ return [{ id: index + 1, location: formatLocus(location), text }];
311
+ });
312
+ return [
313
+ { name: module.name, location: module.location, obligations, summary: generated.summary },
314
+ ];
315
+ });
316
+ };