UNPKG

@informalsystems/quint

Version:

Core tool for the Quint specification language

520 lines 24.6 kB
"use strict"; /* ---------------------------------------------------------------------------------- * Copyright 2022-2024 Informal Systems * Licensed under the Apache License, Version 2.0. * See LICENSE in the project root for license information. * --------------------------------------------------------------------------------- */ Object.defineProperty(exports, "__esModule", { value: true }); exports.nameWithNamespaces = exports.buildDef = exports.buildExpr = exports.Builder = void 0; /** * A builder to build arrow functions used to evaluate Quint expressions. * * Caching and var storage are heavily based on the original `compilerImpl.ts` file written by Igor Konnov. * The performance of evaluation relies on a lot of memoization done mainly with closures in this file. * We define registers and cached value data structures that work as pointers, to avoid the most lookups and * memory usage as possible. This adds a lot of complexity to the code, but it is necessary to achieve feasible * performance, as the functions built here will be called thousands of times by the simulator. * * @author Igor Konnov, Gabriela Moreira * * @module */ const either_1 = require("@sweet-monads/either"); const runtimeValue_1 = require("./runtimeValue"); const builtins_1 = require("./builtins"); const VarStorage_1 = require("./VarStorage"); const immutable_1 = require("immutable"); const nondet_1 = require("./nondet"); /** * A builder to build arrow functions used to evaluate Quint expressions. * It can be understood as a Quint compiler that compiles Quint expressions into * typescript arrow functions. It is called a builder instead of compiler because * the compiler term is overloaded. */ class Builder { /** * Constructs a new Builder instance. * * @param table - The lookup table containing definitions. * @param storeMetadata - A flag indicating whether to store metadata (`actionTaken` and `nondetPicks`). */ constructor(table, storeMetadata) { this.paramRegistry = new Map(); this.constRegistry = new Map(); this.scopedCachedValues = new Map(); this.initialNondetPicks = new Map(); this.memo = new Map(); this.memoByInstance = new Map(); this.namespaces = (0, immutable_1.List)(); this.table = table; this.varStorage = new VarStorage_1.VarStorage(storeMetadata, this.initialNondetPicks); } /** * Adds a variable to the var storage if it is not there yet. * * @param id * @param name */ discoverVar(id, name) { // Keep the key as simple as possible const key = [id, ...this.namespaces].join('#'); if (this.varStorage.vars.has(key)) { return; } const varName = nameWithNamespaces(name, this.namespaces); const register = { name: varName, value: (0, VarStorage_1.initialRegisterValue)(varName) }; const nextRegister = { name: varName, value: (0, VarStorage_1.initialRegisterValue)(varName) }; this.varStorage.vars = this.varStorage.vars.set(key, register); this.varStorage.nextVars = this.varStorage.nextVars.set(key, nextRegister); } /** * Gets the register for a variable by its id and the namespaces in scope (tracked by this builder). * * @param def - The variable to get the register for. * * @returns the register for the variable */ getVar(def) { const key = [def.id, ...this.namespaces].join('#'); const result = this.varStorage.vars.get(key); if (!result) { this.discoverVar(def.id, def.name); return this.varStorage.vars.get(key); } return result; } /** * Gets the register for the next state of a variable by its id and the namespaces in scope (tracked by this builder). * * @param id - The identifier of the variable. * * @returns the register for the next state of the variable */ getNextVar(id) { const key = [id, ...this.namespaces].join('#'); const result = this.varStorage.nextVars.get(key); if (!result) { throw new Error(`Variable not found: ${key}`); } return result; } /** * Gets the register for a constant by its id and the instances in scope (tracked by this builder). * * @param id - The identifier of the constant. * @param name - The constant name to be used in error messages. * * @returns the register for the constant */ registerForConst(id, name) { let register = this.constRegistry.get(id); if (!register) { const message = `Uninitialized const ${name}. Use: import <moduleName>(${name}=<value>).*`; register = { value: (0, either_1.left)({ code: 'QNT500', message }) }; this.constRegistry.set(id, register); return register; } return register; } } exports.Builder = Builder; /* Building functionality is given by functions that take a builder instead of Builder methods. * This should help separating responsibility and splitting this into multiple files if ever needed */ /** * Builds an evaluation function for a given Quint expression. * * This function first checks if the expression has already been memoized. If it has, * it returns the memoized evaluation function. If not, it builds the core evaluation * function for the expression and wraps it to handle errors and memoization. * * @param builder - The Builder instance used to construct the evaluation function. * @param expr - The Quint expression to evaluate. * * @returns An evaluation function that takes a context and returns either a QuintError or a RuntimeValue. */ function buildExpr(builder, expr) { if (builder.memo.has(expr.id)) { return builder.memo.get(expr.id); } const exprEval = buildExprCore(builder, expr); const wrappedEval = ctx => { try { // This is where we add the reference to the error, if it is not already there. // This way, we don't need to worry about references anywhere else :) return exprEval(ctx).mapLeft(err => (err.reference === undefined ? { ...err, reference: expr.id } : err)); } catch (error) { const message = error instanceof Error ? error.message : 'unknown error'; return (0, either_1.left)({ code: 'QNT501', message: message, reference: expr.id }); } }; builder.memo.set(expr.id, wrappedEval); return wrappedEval; } exports.buildExpr = buildExpr; /** * Builds an evaluation function for a given definition. * * This function first checks if the definition has already been memoized. If it has, * it returns the memoized evaluation function. If the definition is imported from an instance, * it builds the evaluation function under the context of the instance. Otherwise, it builds the * core evaluation function for the definition and wraps it to handle errors and memoization. * * @param builder - The Builder instance used to construct the evaluation function. * @param def - The LookupDefinition to evaluate. * * @returns An evaluation function that takes a context and returns either a QuintError or a RuntimeValue. */ function buildDef(builder, def) { if (!def.importedFrom || def.importedFrom.kind !== 'instance') { return buildDefWithMemo(builder, def); } return buildUnderDefContext(builder, def, () => buildDefWithMemo(builder, def)); } exports.buildDef = buildDef; /** * Given an arrow that builds something, wrap it in modifications over the builder so it has the proper context. * Specifically, this includes instance overrides in context so that the build function use the right registers * for the instance if it originated from an instance. * * @param builder - The builder instance. * @param def - The definition for which the context is being built. * @param buildFunction - The function that builds the EvalFunction. * * @returns the result of buildFunction, evaluated under the right context. */ function buildUnderDefContext(builder, def, buildFunction) { if (!def.importedFrom || def.importedFrom.kind !== 'instance') { // Nothing to worry about if there are no instances involved return buildFunction(); } // This originates from an instance, so we need to handle overrides const instance = def.importedFrom; // Save how the builder was before so we can restore it after const memoBefore = builder.memo; const namespacesBefore = builder.namespaces; // We need separate memos for each instance. // For example, if N is a constant, the expression N + 1 can have different values for different instances. // We re-use the same memo for the same instance. So, let's check if there is an existing memo, // or create and save a new one if (builder.memoByInstance.has(instance.id)) { builder.memo = builder.memoByInstance.get(instance.id); } else { builder.memo = new Map(); builder.memoByInstance.set(instance.id, builder.memo); } // We also need to update the namespaces to include the instance's namespaces. // So, if variable x is updated, we update the instance's x, i.e. my_instance::my_module::x builder.namespaces = (0, immutable_1.List)(def.namespaces); // Pre-compute as much as possible for the overrides: find the registers and find the expressions to evaluate // so we don't need to look that up in runtime const overrides = instance.overrides.map(([param, expr]) => { const id = builder.table.get(param.id).id; const register = builder.registerForConst(id, param.name); // Build the expr as a pure val def so it gets properly cached const purevalEval = buildDef(builder, { kind: 'def', qualifier: 'pureval', expr, name: param.name, id: param.id }); return [register, purevalEval]; }); // Here, we have the right context to build the function. That is, all constants are pointing to the right registers, // and all namespaces are set for unambiguous variable access and update. const result = buildFunction(); // Restore the builder to its previous state builder.namespaces = namespacesBefore; builder.memo = memoBefore; // And then, in runtime, we only need to evaluate the override expressions, update the respective registers // and then call the function that was built return ctx => { overrides.forEach(([register, evaluate]) => (register.value = evaluate(ctx))); return result(ctx); }; } /** * Given a lookup definition, build the evaluation function for it, without worring about memoization or error handling. * * @param builder - The builder instance. * @param def - The definition for which the evaluation function is being built. * * @returns the evaluation function for the given definition. */ function buildDefCore(builder, def) { switch (def.kind) { case 'def': { if (def.qualifier === 'action') { // Create an app to be recorded const app = { id: def.id, kind: 'app', opcode: def.name, args: [] }; const body = buildExpr(builder, def.expr); return (ctx) => { if (def.expr.kind !== 'lambda') { // Lambdas are recorded when they are called, no need to record them here ctx.recorder.onUserOperatorCall(app); } if (ctx.varStorage.actionTaken === undefined) { ctx.varStorage.actionTaken = def.name; } const result = body(ctx); if (def.expr.kind !== 'lambda') { ctx.recorder.onUserOperatorReturn(app, [], result); } return result; }; } if (def.expr.kind === 'lambda' || def.depth === undefined || def.depth === 0) { // We need to avoid scoped caching in lambdas or top-level expressions // We still have memoization. This caching is special for scoped defs (let-ins) return buildExpr(builder, def.expr); } // Else, we are dealing with a scoped value. // We need to cache it, so every time we access it, it has the same value. const cachedValue = builder.scopedCachedValues.get(def.id); const bodyEval = buildExpr(builder, def.expr); return ctx => { if (cachedValue.value === undefined) { cachedValue.value = bodyEval(ctx); } return cachedValue.value; }; } case 'param': { // Every parameter has a single register, and we just change this register's value before evaluating the body // So, a reference to a parameter simply evaluates to the value of the register. const register = builder.paramRegistry.get(def.id); if (!register) { const reg = { value: (0, either_1.left)({ code: 'QNT501', message: `Parameter ${def.name} not set` }) }; builder.paramRegistry.set(def.id, reg); return _ => reg.value; } return _ => register.value; } case 'var': { // Every variable has a single register, and we just change this register's value at each state // So, a reference to a variable simply evaluates to the value of the register. const register = builder.getVar(def); return _ => { return register.value; }; } case 'const': { // Every constant has a single register, and we just change this register's value when overrides are present // So, a reference to a constant simply evaluates to the value of the register. const register = builder.registerForConst(def.id, def.name); return _ => register.value; } default: return _ => (0, either_1.left)({ code: 'QNT000', message: `Not implemented for def kind ${def.kind}` }); } } /** * Builds a definition with memoization and caching. * - Memoization: use the same built function for the same definition. * - Caching: for top-level value definitions, cache the resulting value being aware of variable changes. * * @param builder - The builder instance. * @param def - The definition for which the evaluation function is being built. * * @returns the evaluation function for the given definition. */ function buildDefWithMemo(builder, def) { if (builder.memo.has(def.id)) { return builder.memo.get(def.id); } const defEval = buildDefCore(builder, def); // For top-level value definitions, we can cache the resulting value, as long as we are careful with state changes. const statefulCachingCondition = def.kind === 'def' && (def.qualifier === 'pureval' || def.qualifier === 'val') && (def.depth === undefined || def.depth === 0); if (!statefulCachingCondition) { // Only use memo, no runtime caching builder.memo.set(def.id, defEval); return defEval; } // PS: Since we memoize things separately per instance, we can store even values that depend on constants // Construct a cached value object (a register with optional value) const cachedValue = { value: undefined }; if (def.qualifier === 'val') { // This definition may use variables, so we need to clear the cache when they change builder.varStorage.cachesToClear.push(cachedValue); } // Wrap the evaluation function with caching const wrappedEval = ctx => { if (cachedValue.value === undefined) { cachedValue.value = defEval(ctx); } return cachedValue.value; }; builder.memo.set(def.id, wrappedEval); return wrappedEval; } /** * Given an expression, build the evaluation function for it, without worring about memoization or error handling. * * @param builder - The builder instance. * @param expr - The expression for which the evaluation function is being built. * * @returns the evaluation function for the given expression. */ function buildExprCore(builder, expr) { switch (expr.kind) { case 'int': case 'bool': case 'str': { // These are already values, just return them const value = (0, either_1.right)(runtimeValue_1.rv.fromQuintEx(expr)); return _ => value; } case 'lambda': { // Lambda is also like a value, but we should construct it with the context const body = buildExpr(builder, expr.expr); const lambda = runtimeValue_1.rv.mkLambda(expr.params, body, builder.paramRegistry); return _ => (0, either_1.right)(lambda); } case 'name': { const def = builder.table.get(expr.id); if (!def) { // FIXME: If this refers to a builtin operator, we need to return it as an arrow (see #1332) return (0, builtins_1.builtinValue)(expr.name); } return buildDef(builder, def); } case 'app': { if (expr.opcode === 'assign') { // Assign is too special, so we handle it separately. // We need to build things under the context of the variable being assigned, as it may come from an instance, // and that changed everything const varDef = builder.table.get(expr.args[0].id); return buildUnderDefContext(builder, varDef, () => { builder.discoverVar(varDef.id, varDef.name); const register = builder.getNextVar(varDef.id); const exprEval = buildExpr(builder, expr.args[1]); return ctx => { return exprEval(ctx).map(value => { register.value = (0, either_1.right)(value); return runtimeValue_1.rv.mkBool(true); }); }; }); } const args = expr.args.map(arg => buildExpr(builder, arg)); // If the operator is a lazy operator, we can't evaluate the arguments before evaluating application if (builtins_1.lazyOps.includes(expr.opcode)) { const op = (0, builtins_1.lazyBuiltinLambda)(expr.opcode); return ctx => op(ctx, args).mapLeft(err => { // Improve reference of `then`-related errors if (expr.opcode === 'then' && err.code === 'QNT513' && err.reference === undefined) { if (expr.args[0].kind === 'app' && expr.args[0].opcode === 'then') { return { ...err, reference: expr.args[0].args[1].id }; } return { ...err, reference: expr.args[0].id }; } return err; }); } // Otherwise, this is either a normal (eager) builtin, or an user-defined operator. // For both, we first evaluate the arguments and then apply the operator. const operatorFunction = buildApp(builder, expr); const userDefined = builder.table.has(expr.id); return ctx => { if (userDefined) { ctx.recorder.onUserOperatorCall(expr); } const argValues = []; for (const arg of args) { const argValue = arg(ctx); if (argValue.isLeft()) { return argValue; } argValues.push(argValue.unwrap()); } const result = operatorFunction(ctx, argValues); if (userDefined) { ctx.recorder.onUserOperatorReturn(expr, argValues, result); } return result; }; } case 'let': { if (expr.opdef.qualifier === 'nondet') { // For `nondet`, we want to retry the `oneOf()` call in case the body returns false. // So we take care of the compilation at the let-in level. if (expr.opdef.expr.kind !== 'app') { // impossible, added to make Typescript's type checker happy. throw new Error('Impossible case: nondet let expression is not an app'); } // Create an entry in the map for this nondet pick, // as we want the resulting record to be the same at every state. // Value is optional, and starts with undefined builder.initialNondetPicks.set(expr.opdef.name, undefined); // Set up cache and memo in the same way we do for regular let-ins. // We don't need to add it to `scopedCachedValues` since we'll clear // the cache in here let cache = { value: undefined }; builder.memo.set(expr.opdef.id, _ => cache.value); const setEval = buildExpr(builder, expr.opdef.expr.args[0]); const bodyEval = buildExpr(builder, expr.expr); return (0, nondet_1.evalNondet)(expr.opdef.name, cache, setEval, bodyEval); } // First, we create a cached value (a register with optional value) for the definition in this let expression let cachedValue = builder.scopedCachedValues.get(expr.opdef.id); if (!cachedValue) { // TODO: check if either this is always the case or never the case. cachedValue = { value: undefined }; builder.scopedCachedValues.set(expr.opdef.id, cachedValue); } // Then, we build the expression for the let body. It will use the lookup table and, every time it needs the value // for the definition under the let, it will use the cached value (or eval a new value and store it). const bodyEval = buildExpr(builder, expr.expr); return ctx => { const saved = cachedValue.value; cachedValue.value = undefined; const result = bodyEval(ctx); // After evaluating the whole let expression, we clear the cached value, as it is no longer in scope. // The next time the whole let expression is evaluated, the definition will be re-evaluated. cachedValue.value = saved; return result; }; } } } /** * Builds the application function for a given Quint application. * * This function first checks if the application corresponds to a user-defined operator. * If it does, it retrieves the corresponding evaluation function. If the operator is a built-in, * it retrieves the built-in lambda function. The resulting function evaluates the operator * with the given context and arguments. * * @param builder - The Builder instance * @param app - The Quint application expression to evaluate. * * @returns A function that takes a context and arguments, and returns either a QuintError or a RuntimeValue. */ function buildApp(builder, app) { const def = builder.table.get(app.id); if (!def) { // If it is not in the lookup table, it must be a builtin operator return (0, builtins_1.builtinLambda)(app.opcode); } const defEval = buildDef(builder, def); return (ctx, args) => { return defEval(ctx).chain(lambda => { const arrow = lambda.toArrow(); return arrow(ctx, args); }); }; } /** * Constructs a fully qualified name by combining the given name with the namespaces. * * The namespaces are reversed and joined with the name using the "::" delimiter. * * @param name - The name to be qualified. * @param namespaces - A list of namespaces to be included in the fully qualified name. * * @returns The fully qualified name as a string. */ function nameWithNamespaces(name, namespaces) { const revertedNamespaces = namespaces.reverse(); return revertedNamespaces.push(name).join('::'); } exports.nameWithNamespaces = nameWithNamespaces; //# sourceMappingURL=builder.js.map