Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change the runtime behavior of then #1304

Merged
merged 10 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add a run operator `A.expect(P)` to test the state predicate `P` in the state resulting from applying action `A` (#1303)

### Changed

- Change in `A.then(B)`: If `A` returns `false`, `A.then(B)` fails (#1304)

### Deprecated
### Removed
### Fixed
Expand Down
4 changes: 3 additions & 1 deletion doc/builtin.md
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,9 @@ run test = (x' = 0).then(a).then(assert(x == 3))

`a` is true for a step from `s1` to `t` and `b` is true for a step from `t` to `s2`.

This is the action composition operator.
This is the action composition operator. If `a` evaluates to `false`, then
`a.then(b)` reports an error. If `b` evaluates to `false` after `a`, then
`a.then(b)` returns `false`.

### Examples

Expand Down
20 changes: 13 additions & 7 deletions doc/lang.md
Original file line number Diff line number Diff line change
Expand Up @@ -1658,13 +1658,19 @@ then(A, B)

The semantics of this operator is as follows. When `A.then(B)` is applied to a
state `s_1`, the operator computes a next state `s_2` of `s_1` by applying
action `A`, if such a state exists. If `A` returns `true`, then the operator
`A.then(B)` computes a next state `s_3` of `s_2` by applying action `B`, if
such a state exists. If `B` returns true, then the operator `A.then(B)` returns
`true`, the old state is equal to `s_1`, and the new state is equal to `s_3`.
In all other cases, the operator returns `false`.

This operator is equivalent to `A \cdot B` of TLA+.
action `A`, if such a state exists. Depending on the result of `A`, two scenarios
are possible:

- When `A` returns `true`, then the operator `A.then(B)` computes a next state
`s_3` of `s_2` by applying action `B`, if such a state exists.
If `B` returns true, then the operator `A.then(B)` returns
`true`, the old state is equal to `s_1`, and the new state is equal to `s_3`.
Otherwise, the operator `A.then(B)` returns `false`.

- If `A` returns `false`, then it is impossible to continue. A runtime error
should be reported.

This operator is equivalent to `A \cdot B` of TLA+ (except for the runtime errors).

**Example.** Consider the specification `counters`:

Expand Down
4 changes: 2 additions & 2 deletions examples/language-features/counters.qnt
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ module counters {
// this tests for a failing condition
run failingTest = {
init
.then(10.reps(_ => all {
.then(all {
n == 0,
step,
}))
})
.fail()
}

Expand Down
4 changes: 3 additions & 1 deletion quint/src/builtin.qnt
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,9 @@ module builtin {
///
/// `a` is true for a step from `s1` to `t` and `b` is true for a step from `t` to `s2`.
///
/// This is the action composition operator.
/// This is the action composition operator. If `a` evaluates to `false`, then
/// `a.then(b)` reports an error. If `b` evaluates to `false` after `a`, then
/// `a.then(b)` returns `false`.
///
/// ### Examples
///
Expand Down
2 changes: 2 additions & 0 deletions quint/src/quintError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export type ErrorCode =
| 'QNT511'
/* QNT512: Simulation failure */
| 'QNT512'
/* QNT513: Cannot continue in 'then' */
| 'QNT513'

/* Additional data for a Quint error */
export interface QuintErrorData {
Expand Down
36 changes: 30 additions & 6 deletions quint/src/runtime/impl/compilerImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1257,8 +1257,18 @@
}
}

// compute all { ... } or A.then(B)...then(E) for a chain of actions
private chainAllOrThen(actions: Computable[], kind: 'all' | 'then'): Maybe<EvalResult> {
/**
* Compute all { ... } or A.then(B)...then(E) for a chain of actions.
* @param actions actions as computable to execute
* @param kind is it 'all { ... }' or 'A.then(B)'?
* @param actionId given the action index, return the id that produced this action
* @returns evaluation result
*/
private chainAllOrThen(
actions: Computable[],
kind: 'all' | 'then',
actionId: (idx: number) => bigint
shonfeder marked this conversation as resolved.
Show resolved Hide resolved
): Maybe<EvalResult> {
// save the values of the next variables, as actions may update them
const savedValues = this.snapshotNextVars()
const savedTrace = this.trace()
Expand All @@ -1270,14 +1280,27 @@
for (const action of actions) {
nactionsLeft--
result = action.eval()
if (result.isNone() || !(result.value as RuntimeValue).toBool()) {
const isFalse = result.isJust() && !(result.value as RuntimeValue).toBool()
if (result.isNone() || isFalse) {
// As soon as one of the arguments does not evaluate to true,
// break out of the loop.
// Restore the values of the next variables,
// as evaluation was not successful.
this.recoverNextVars(savedValues)
this.resetTrace(just(rv.mkList(savedTrace)))
break

if (kind === 'then' && nactionsLeft > 0 && isFalse) {
// Cannot extend a run. Emit an error message.
const actionNo = actions.length - (nactionsLeft + 1)
this.errorTracker.addRuntimeError(
actionId(actionNo),
konnov marked this conversation as resolved.
Show resolved Hide resolved
'QNT513',
`Cannot continue in A.then(B), A evaluates to 'false'`
)
return none()
} else {
return result
}
}

// switch to the next frame, when implementing A.then(B)
Expand All @@ -1302,7 +1325,7 @@
const args = this.compStack.splice(-app.args.length)

const kind = app.opcode === 'then' ? 'then' : 'all'
const lazyCompute = () => this.chainAllOrThen(args, kind)
const lazyCompute = () => this.chainAllOrThen(args, kind, idx => app.args[idx].id)

this.compStack.push(mkFunComputable(lazyCompute))
}
Expand Down Expand Up @@ -1384,7 +1407,8 @@
},
}
})
return this.chainAllOrThen(actions, 'then')
// In case the case of reps, we have multiple copies of the same action. This is why all occurrences have the same id.

Check warning on line 1410 in quint/src/runtime/impl/compilerImpl.ts

View workflow job for this annotation

GitHub Actions / quint-linting

This line has a length of 128. Maximum allowed is 120
return this.chainAllOrThen(actions, 'then', _ => app.args[1].id)
konnov marked this conversation as resolved.
Show resolved Hide resolved
})
.join()
}
Expand Down
152 changes: 108 additions & 44 deletions quint/test/runtime/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { assert } from 'chai'
import { Either, left, right } from '@sweet-monads/either'
import { just } from '@sweet-monads/maybe'
import { expressionToString } from '../../src/ir/IRprinting'
import { Computable, ComputableKind, fail, kindName } from '../../src/runtime/runtime'
import { Callable, Computable, ComputableKind, fail, kindName } from '../../src/runtime/runtime'
import { noExecutionListener } from '../../src/runtime/trace'
import {
CompilationContext,
Expand Down Expand Up @@ -80,56 +80,84 @@ function assertVarExists(kind: ComputableKind, name: string, input: string) {
res.mapLeft(m => assert.fail(m))
}

// compile a computable for a run definition
function callableFromContext(ctx: CompilationContext, callee: string): Either<string, Callable> {
let key = undefined
const lastModule = ctx.compilationState.modules[ctx.compilationState.modules.length - 1]
const def = lastModule.declarations.find(def => def.kind === 'def' && def.name === callee)
if (!def) {
return left(`${callee} definition not found`)
}
key = kindName('callable', def.id)
if (!key) {
return left(`${callee} not found`)
}
const run = ctx.evaluationState.context.get(key) as Callable
if (!run) {
return left(`${callee} not found via ${key}`)
}

return right(run)
}

// Scan the context for a callable. If found, evaluate it and return the value of the given var.
// Assumes the input has a single callable
// `callee` is used for error reporting.
function evalVarAfterCall(varName: string, callee: string, input: string): Either<string, string> {
// Assumes the input has a single definition whose name is stored in `callee`.
function evalVarAfterRun(varName: string, callee: string, input: string): Either<string, string> {
// use a combination of Maybe and Either.
// Recall that left(...) is used for errors,
// whereas right(...) is used for non-errors in sweet monads.
const callback = (ctx: CompilationContext): Either<string, string> => {
let key = undefined
const lastModule = ctx.compilationState.modules[ctx.compilationState.modules.length - 1]
const def = lastModule.declarations.find(def => def.kind === 'def' && def.name === callee)
if (!def) {
return left(`${callee} definition not found`)
}
key = kindName('callable', def.id)
if (!key) {
return left(`${callee} not found`)
}
const run = ctx.evaluationState.context.get(key)
if (!run) {
return left(`${callee} not found via ${key}`)
}
return callableFromContext(ctx, callee)
.mapRight(run => {
return run
.eval()
.map(res => {
if ((res as RuntimeValue).toBool() === true) {
// extract the value of the state variable
const nextVal = (ctx.evaluationState.context.get(kindName('nextvar', varName)) ?? fail).eval()
if (nextVal.isNone()) {
return left(`Value of the variable ${varName} is undefined`)
} else {
return right(expressionToString(nextVal.value.toQuintEx(idGen)))
}
} else {
const s = expressionToString(res.toQuintEx(idGen))
const m = `Callable ${callee} was expected to evaluate to true, found: ${s}`
return left<string, string>(m)
}
})
.or(just(left(`Value of ${callee} is undefined`)))
.unwrap()
})
.join()
}

return run
.eval()
.map(res => {
if ((res as RuntimeValue).toBool() === true) {
// extract the value of the state variable
const nextVal = (ctx.evaluationState.context.get(kindName('nextvar', varName)) ?? fail).eval()
// using if-else, as map-or-unwrap confuses the compiler a lot
if (nextVal.isNone()) {
return left(`Value of the variable ${varName} is undefined`)
} else {
return right(expressionToString(nextVal.value.toQuintEx(idGen)))
}
} else {
const s = expressionToString(res.toQuintEx(idGen))
const m = `Callable ${callee} was expected to evaluate to true, found: ${s}`
return left<string, string>(m)
}
return evalInContext(input, callback)
}

// Evaluate a run and return the result.
function evalRun(callee: string, input: string): Either<string, string> {
// Recall that left(...) is used for errors,
// whereas right(...) is used for non-errors in sweet monads.
const callback = (ctx: CompilationContext): Either<string, string> => {
return callableFromContext(ctx, callee)
.mapRight(run => {
return run
.eval()
.map(res => {
return right<string, string>(expressionToString(res.toQuintEx(idGen)))
})
.or(just(left(`Value of ${callee} is undefined`)))
.unwrap()
})
.or(just(left(`Value of ${callee} is undefined`)))
.unwrap()
.join()
}

return evalInContext(input, callback)
}

function assertVarAfterCall(varName: string, expected: string, callee: string, input: string) {
evalVarAfterCall(varName, callee, input)
evalVarAfterRun(varName, callee, input)
.mapLeft(m => assert.fail(m))
.mapRight(output => assert(expected === output, `Expected ${varName} == ${expected}, found ${output}`))
}
Expand Down Expand Up @@ -933,7 +961,7 @@ describe('compiling specs to runtime values', () => {
})

describe('compile runs', () => {
it('then', () => {
it('then ok', () => {
const input = dedent(
`var n: int
|run run1 = (n' = 1).then(n' = n + 2).then(n' = n * 4)
Expand All @@ -943,6 +971,30 @@ describe('compiling specs to runtime values', () => {
assertVarAfterCall('n', '12', 'run1', input)
})

it('then fails when rhs is unreachable', () => {
const input = dedent(
`var n: int
|run run1 = (n' = 1).then(all { n == 0, n' = n + 2 }).then(n' = 3)
`
)

evalRun('run1', input)
.mapRight(result => assert.fail(`Expected the run to fail, found: ${result}`))
.mapLeft(m => assert.equal(m, 'Value of run1 is undefined'))
})

it('then returns false when rhs is false', () => {
const input = dedent(
`var n: int
|run run1 = (n' = 1).then(all { n == 0, n' = n + 2 })
`
)

evalRun('run1', input)
.mapRight(result => assert.equal(result, 'false'))
.mapLeft(m => assert.fail(`Expected the run to return false, found: ${m}`))
})

it('reps', () => {
const input = dedent(
`var n: int
Expand All @@ -958,14 +1010,26 @@ describe('compiling specs to runtime values', () => {
assertVarAfterCall('hist', 'List(0, 1, 2)', 'run2', input)
})

it('reps fails when action is false', () => {
const input = dedent(
`var n: int
|run run1 = (n' = 0).then(10.reps(i => all { n < 5, n' = n + 1 }))
`
)

evalRun('run1', input)
.mapRight(result => assert.fail(`Expected the run to fail, found: ${result}`))
.mapLeft(m => assert.equal(m, 'Value of run1 is undefined'))
})

it('fail', () => {
const input = dedent(
`var n: int
|run run1 = (n' = 1).fail()
`
)

evalVarAfterCall('n', 'run1', input).mapRight(m => assert.fail(`Expected the run to fail, found: ${m}`))
evalVarAfterRun('n', 'run1', input).mapRight(m => assert.fail(`Expected the run to fail, found: ${m}`))
})

it('assert', () => {
Expand All @@ -975,7 +1039,7 @@ describe('compiling specs to runtime values', () => {
`
)

evalVarAfterCall('n', 'run1', input).mapRight(m => assert.fail(`Expected an error, found: ${m}`))
evalVarAfterRun('n', 'run1', input).mapRight(m => assert.fail(`Expected an error, found: ${m}`))
})

it('expect fails', () => {
Expand All @@ -985,7 +1049,7 @@ describe('compiling specs to runtime values', () => {
`
)

evalVarAfterCall('n', 'run1', input).mapRight(m => assert.fail(`Expected the run to fail, found: ${m}`))
evalVarAfterRun('n', 'run1', input).mapRight(m => assert.fail(`Expected the run to fail, found: ${m}`))
})

it('expect ok', () => {
Expand All @@ -1005,7 +1069,7 @@ describe('compiling specs to runtime values', () => {
`
)

evalVarAfterCall('n', 'run1', input).mapRight(m => assert.fail(`Expected the run to fail, found: ${m}`))
evalVarAfterRun('n', 'run1', input).mapRight(m => assert.fail(`Expected the run to fail, found: ${m}`))
})

it('expect and then expect fail', () => {
Expand All @@ -1015,7 +1079,7 @@ describe('compiling specs to runtime values', () => {
`
)

evalVarAfterCall('n', 'run1', input).mapRight(m => assert.fail(`Expected the run to fail, found: ${m}`))
evalVarAfterRun('n', 'run1', input).mapRight(m => assert.fail(`Expected the run to fail, found: ${m}`))
})

it('q::debug', () => {
Expand Down